mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Declaring the whole matrix in --expect-change could accept a completely foreign capture (different font stack, different machine): no undeclared passed scenes would remain, and undeclared passed scenes are exactly what proves the capture environment equals the accepted baseline's. The realistic failure is fatigue, not malice — a mass framing change where the author lists "everything that went red", accidentally sweeping in scenes that diverged because of the environment. Acceptance now requires a witness floor: after subtracting --expect-change/--expect-new, at least min(10, 10% of baseline scenes) undeclared scenes must match their accepted baselines BYTE-FOR-BYTE (a sub-threshold 'passed' proves nothing about the environment — #351). A truly total repaint passes only with an explicit --no-witnesses --reason="…", and the reason is written into the baseline manifest — a trace in the artifact and its git history, not just in the shell history. A first-ever capture with no baselines requires no witnesses: every frame there is declared in --expect-new anyway. Issue: #355 User-Visible: no
206 lines
13 KiB
JavaScript
206 lines
13 KiB
JavaScript
/**
|
||
* Правило допустимости съёмки golden-кандидатов (#334).
|
||
*
|
||
* Прежде эталон принимался только из артефакта CI: растеризация шрифтов на
|
||
* другой машине может отличаться, а доказать обратное было нечем. Цена —
|
||
* двойной roundtrip на каждый визуальный фикс: пуш, семь минут CI, скачивание
|
||
* артефакта, приёмка, второй пуш, второй полный прогон. При версии матрицы 48
|
||
* это платится регулярно.
|
||
*
|
||
* Доказательство есть, и оно эмпирическое: среда съёмки равна раннеру, если
|
||
* КАЖДАЯ сцена, которую менять не собирались, совпала со своим принятым
|
||
* эталоном. Расхождение растеризации нельзя спрятать — оно задевает все сцены
|
||
* с текстом, а не только правленные. Поэтому ревьюер объявляет намерение
|
||
* списком `--expect-change`, и всё, что разошлось помимо списка, приёмку
|
||
* запрещает: либо это регрессия рендера, либо среда не та.
|
||
*
|
||
* То же правило ловит другое, независимо от среды: «принять всё, чтобы CI
|
||
* позеленел». Именно так эталон перестаёт быть эталоном — молча, одной
|
||
* командой, без единого названного намерения.
|
||
*
|
||
* Новая сцена объявляется отдельно, флагом `--expect-new` (#350). Прежде она
|
||
* проходила молча, и три кадра каталога устройств стали контрактом без единого
|
||
* взгляда. Половина прежнего обоснования верна и остаётся: расхождение
|
||
* растеризации новая сцена выявить не может, для доказательства параллельности
|
||
* среды годятся только сцены с эталонами. Но правило отвечало лишь на вопрос
|
||
* «та ли это среда» и молчало про второй — «правильный ли это кадр». Пустой,
|
||
* обрезанный или снятый в неверном состоянии кадр новая сцена закрепляет ровно
|
||
* так же надёжно, как испорченный старый.
|
||
*
|
||
* Поэтому два флага утверждают разное и не заменяют друг друга:
|
||
* `--expect-change` — «я знаю, почему старый кадр изменился», `--expect-new` —
|
||
* «я посмотрел на новый кадр». Путаница между ними тоже запрещена: имя в чужом
|
||
* флаге останавливает приёмку.
|
||
* она могла бы противоречить.
|
||
*
|
||
* Почему это лежит в `scripts/`, а не рядом с `accept.mjs`. Отпечаток
|
||
* `sourceFingerprint` включает ВСЕ `.mjs` из `demo/golden`, включая
|
||
* `accept.mjs` и `policy.mjs`, которые исполняются после того, как картинка уже
|
||
* снята, и ни одного пикселя изменить не могут. Правка любого из них объявляет
|
||
* устаревшими и закоммиченный бандл, и манифест скриншотов документации, и
|
||
* манифест эталонов — то есть требует ровно того двойного цикла, который эта
|
||
* задача убирает. Сужение корпуса отпечатка — отдельная задача: оно неизбежно
|
||
* требует пересборки бандла, потому что сам `source-fingerprint.mjs` в корпусе.
|
||
*/
|
||
|
||
export const goldenAcceptanceRefusal = (results, declared = [], declaredNew = []) => {
|
||
if (!Array.isArray(results)) return 'отчёт кандидатов не содержит результатов сцен';
|
||
const expected = new Set(declared.filter(Boolean));
|
||
const expectedNew = new Set(declaredNew.filter(Boolean));
|
||
const statusById = new Map(results.map((result) => [result.id, result.status]));
|
||
|
||
const unknown = [...expected, ...expectedNew].filter((id) => !statusById.has(id));
|
||
if (unknown.length) {
|
||
return 'названы сцены, которых нет в отчёте:'
|
||
+ ` ${[...new Set(unknown)].sort().join(', ')}`;
|
||
}
|
||
// Путаница между флагами — не мелочь: она означает, что ревьюер думал об
|
||
// одной сцене, а утверждал про другую.
|
||
const newInWrongFlag = [...expected]
|
||
.filter((id) => statusById.get(id) === 'missing-baseline').sort();
|
||
if (newInWrongFlag.length) {
|
||
return `у этих сцен эталона ещё нет, их место в --expect-new: ${newInWrongFlag.join(', ')}`;
|
||
}
|
||
const oldInWrongFlag = [...expectedNew]
|
||
.filter((id) => statusById.get(id) !== 'missing-baseline').sort();
|
||
if (oldInWrongFlag.length) {
|
||
return 'у этих сцен эталон уже есть, их место в --expect-change:'
|
||
+ ` ${oldInWrongFlag.join(', ')}`;
|
||
}
|
||
|
||
const undeclared = results
|
||
.filter((result) => result.status === 'different' && !expected.has(result.id))
|
||
.map((result) => result.id)
|
||
.sort();
|
||
if (undeclared.length) {
|
||
return 'съёмка разошлась с принятыми эталонами в сценах, которые менять не собирались:'
|
||
+ ` ${undeclared.join(', ')}.`
|
||
+ ' Либо это регрессия рендера, либо среда съёмки не совпадает с раннером —'
|
||
+ ' в обоих случаях приёмка запрещена. Намеренные сцены перечисляются в'
|
||
+ ' --expect-change=<id,id>';
|
||
}
|
||
|
||
const undeclaredNew = results
|
||
.filter((result) => result.status === 'missing-baseline' && !expectedNew.has(result.id))
|
||
.map((result) => result.id)
|
||
.sort();
|
||
if (undeclaredNew.length) {
|
||
return 'у этих сцен эталона ещё нет, и они станут контрактом:'
|
||
+ ` ${undeclaredNew.join(', ')}.`
|
||
+ ' Посмотрите кадры в artifacts/golden/actual — пустой или обрезанный кадр'
|
||
+ ' закрепляется так же надёжно, как испорченный старый, — и перечислите их'
|
||
+ ' в --expect-new=<id,id>';
|
||
}
|
||
return null;
|
||
};
|
||
|
||
/** Сцены, объявленные изменёнными, но совпавшие с эталоном: не ошибка, но и не молчание. */
|
||
export const goldenSilentDeclarations = (results, declared = []) => declared
|
||
.filter((id) => results.find((result) => result.id === id)?.status === 'passed')
|
||
.sort();
|
||
|
||
/**
|
||
* План замены: что переписать, что оставить как было (#351).
|
||
*
|
||
* Чистая функция, потому что решение здесь одно и ошибка в нём дорога:
|
||
* `passed` означает «в пределах порога», а не «байт в байт». Прежняя приёмка
|
||
* копировала кандидата поверх КАЖДОГО эталона, поэтому подпороговый дрейф уезжал
|
||
* в контракт молча и накапливался: каждая приёмка подтягивала эталон к последней
|
||
* среде, порог не пересекался никогда, а эталон уходил. Так `1e341c60` заменил
|
||
* 22 картинки, объявив четыре.
|
||
*
|
||
* Необъявленная сцена сохраняет и файл, и свой хеш из прежнего индекса. Хеша нет
|
||
* только у сцены без эталона, а такая обязана быть названа в `--expect-new` —
|
||
* поэтому его отсутствие здесь ошибка, а не повод взять кандидата.
|
||
*/
|
||
export const goldenAcceptancePlan = ({
|
||
scenarioIds, results, previousHashes = {}, declared = [], declaredNew = [],
|
||
}) => {
|
||
const accepted = new Set([...declared, ...declaredNew].filter(Boolean));
|
||
const byId = new Map((results || []).map((result) => [result.id, result]));
|
||
const replace = [];
|
||
const keep = [];
|
||
const hashes = {};
|
||
for (const id of scenarioIds) {
|
||
if (accepted.has(id)) {
|
||
const digest = byId.get(id)?.actualSha256;
|
||
if (typeof digest !== 'string' || !digest) {
|
||
throw new Error(`объявленная сцена без хеша кандидата: ${id}`);
|
||
}
|
||
replace.push(id);
|
||
hashes[id] = digest;
|
||
continue;
|
||
}
|
||
const existing = previousHashes[id];
|
||
if (typeof existing !== 'string' || !existing) {
|
||
throw new Error(`необъявленная сцена без прежнего эталона: ${id};`
|
||
+ ' назовите её в --expect-new');
|
||
}
|
||
hashes[id] = existing;
|
||
keep.push(id);
|
||
}
|
||
return { replace, keep, hashes };
|
||
};
|
||
|
||
/**
|
||
* Floor сцен-свидетелей (#355).
|
||
*
|
||
* Смысл непереименованных прошедших сцен — быть СВИДЕТЕЛЯМИ среды: если бы
|
||
* растеризация или окружение съёмки отличались от эталонных, они бы разошлись.
|
||
* Перечислив ВСЮ матрицу в `--expect-change`, можно принять полностью чужую
|
||
* съёмку — свидетелей не останется, а след останется только в истории команды.
|
||
* Практический сценарий — не злой умысел, а усталость: массовая framing-правка,
|
||
* автор перечисляет «всё, что покраснело», захватывая и сцены, разошедшиеся по
|
||
* причине среды.
|
||
*
|
||
* Свидетель — необъявленная сцена, чей кандидат совпал с принятым эталоном
|
||
* БАЙТ-В-БАЙТ: `passed` означает лишь «в пределах порога» (#351), а среду
|
||
* доказывает только точное совпадение. Floor — 10 свидетелей или 10% сцен с
|
||
* эталонами, что меньше; матрица без эталонов (первичная съёмка) свидетелей
|
||
* требовать не может — там каждый кадр и так объявляется в `--expect-new`.
|
||
*
|
||
* Осознанный обход для действительно тотальных перерисовок — `--no-witnesses`
|
||
* с обязательной причиной: она уезжает в манифест эталонов, то есть в артефакт
|
||
* и его git-историю, а не только в историю shell.
|
||
*/
|
||
export const goldenWitnessFloor = (baselineCount) => (baselineCount > 0
|
||
? Math.min(10, Math.ceil(baselineCount * 0.1))
|
||
: 0);
|
||
|
||
export const goldenWitnessRefusal = ({
|
||
results, declared = [], declaredNew = [], previousHashes = {},
|
||
skipWitnesses = false, skipReason = '',
|
||
}) => {
|
||
if (skipWitnesses) {
|
||
if (typeof skipReason !== 'string' || !skipReason.trim()) {
|
||
return { refusal: '--no-witnesses требует --reason="…": причина обхода'
|
||
+ ' обязана остаться в отчёте приёмки', witnesses: [], floor: 0 };
|
||
}
|
||
return { refusal: null, witnesses: [], floor: 0 };
|
||
}
|
||
const accepted = new Set([...declared, ...declaredNew].filter(Boolean));
|
||
const withBaseline = results.filter((result) => result.status !== 'missing-baseline');
|
||
const floor = goldenWitnessFloor(withBaseline.length);
|
||
const witnesses = withBaseline
|
||
.filter((result) => !accepted.has(result.id)
|
||
&& result.status === 'passed'
|
||
&& typeof result.actualSha256 === 'string'
|
||
&& result.actualSha256 === previousHashes[result.id])
|
||
.map((result) => result.id)
|
||
.sort();
|
||
if (witnesses.length < floor) {
|
||
return {
|
||
refusal: 'сцен-свидетелей среды недостаточно:'
|
||
+ ` ${witnesses.length} из необходимых ${floor}`
|
||
+ ` (эталонных сцен ${withBaseline.length}, объявлено ${accepted.size}).`
|
||
+ ' Свидетель — необъявленная сцена, совпавшая с эталоном байт-в-байт;'
|
||
+ ' именно они доказывают, что среда съёмки та же, что у принятого'
|
||
+ ' эталона. Если перерисовка действительно тотальная и осознанная —'
|
||
+ ' --no-witnesses --reason="…" оставит причину в манифесте эталонов',
|
||
witnesses,
|
||
floor,
|
||
};
|
||
}
|
||
return { refusal: null, witnesses, floor };
|
||
};
|