test: приёмка переписывает только объявленные эталоны

`passed` означает «в пределах порога», а не «байт в байт»: comparePng считает
diffRatio, и статус ставится по нему. А приёмка копировала кандидата поверх
КАЖДОГО эталона матрицы, поэтому подпороговый дрейф уезжал в контракт молча — и
накапливался: каждая приёмка подтягивала эталон к последней среде, порог не
пересекался никогда, а эталон уходил. Так 1e341c60 заменил 22 картинки, объявив
четыре.

Проект уже сталкивался с этим: ad3f9981 восстанавливал девять уехавших эталонов
руками. Такую работу обязан делать инструмент.

Теперь копируются только сцены из --expect-change и --expect-new; остальные
сохраняют и файл, и свой хеш из прежнего индекса. Индекс по-прежнему
перезаписывается на полный набор — сирота или пропавшая запись делают манифест
недействительным целиком.

Решение вынесено в чистую функцию goldenAcceptancePlan: оно одно, и ошибка в нём
дорога. Отсутствие прежнего хеша у необъявленной сцены — ошибка, а не повод
взять кандидата: без эталона бывает только новая сцена, а она обязана быть
названа в --expect-new.

Логика вернулась в demo/golden/accept.mjs, где ей и место: после #344 эти файлы
исключены из корпуса отпечатка, так что правка больше не требует пересборки и
пересъёмки. scripts/golden-accept.mjs остался проходным вызовом ради
документированной команды.

Проверено сквозным прогоном на синтетическом кандидате: у двух сцен байты
другие, объявлена одна — на диске изменились ровно два файла, эталон и индекс, а
хеш второй сцены остался прежним. Два мутанта убиты руками: «брать кандидата
вместо прежнего хеша» и «заменять всё».

Issue: #351
User-Visible: no
This commit is contained in:
Claude
2026-08-28 11:52:36 +03:00
parent 1e341c608c
commit 0c9cf9503e
5 changed files with 166 additions and 68 deletions
+10 -55
View File
@@ -1,76 +1,31 @@
#!/usr/bin/env node
/**
* Приёмка эталонов с объявлением намерения (#334).
* Проходной вызов `demo/golden/accept.mjs` (#334, #350, #351).
*
* node scripts/golden-accept.mjs --reviewed --expect-change=<id,id>
* node scripts/golden-accept.mjs --reviewed --expect-new=<id,id>
* node scripts/golden-accept.mjs --reviewed --expect-change=<id> --from=<распакованный артефакт>
*
* Обёртка появилась потому, что до #344 любой `.mjs` из `demo/golden` входил в
* корпус отпечатка, и правка инструмента приёмки объявляла устаревшими бандл и
* оба манифеста. После #344 это ограничение снято, правило живёт в самом
* `accept.mjs`, а этот файл остался ради документированной команды и передаёт
* аргументы как есть.
*
* Два флага утверждают разное: `--expect-change` — «я знаю, почему старый кадр
* изменился», `--expect-new` — «я посмотрел на новый кадр». Путаница между ними
* останавливает приёмку (#350).
*
* Обёртка над `demo/golden/accept.mjs`, а не правка его самого: любой `.mjs` из
* `demo/golden` входит в `sourceFingerprint`, поэтому его правка объявляет
* устаревшими бандл и оба манифеста — см. `scripts/golden-acceptance.mjs`.
*
* Проверка идёт ДО делегирования: `accept.mjs` копирует картинки целым набором,
* и запрет обязан сработать раньше, чем каталог эталонов будет тронут.
* изменился», `--expect-new` — «я посмотрел на новый кадр». Необъявленная сцена
* не переписывается вовсе: её эталон и хеш остаются прежними (#351).
*/
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { goldenAcceptanceRefusal, goldenSilentDeclarations } from './golden-acceptance.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const argv = process.argv.slice(2);
const value = (name) => {
const found = argv.find((item) => item.startsWith(`--${name}=`));
return found ? found.slice(name.length + 3) : '';
};
if (!argv.includes('--reviewed')) {
console.error('приёмка требует явного --reviewed');
process.exit(2);
}
const from = resolve(value('from') || resolve(ROOT, 'artifacts/golden'));
const list = (name) => value(name).split(',').map((id) => id.trim()).filter(Boolean);
const declared = list('expect-change');
const declaredNew = list('expect-new');
const reportPath = resolve(from, 'golden-report.json');
if (!existsSync(reportPath)) {
console.error(`отчёт кандидатов не найден: ${reportPath}`);
process.exit(2);
}
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
const refusal = goldenAcceptanceRefusal(report.results, declared, declaredNew);
if (refusal) {
console.error(refusal);
process.exit(1);
}
const silent = goldenSilentDeclarations(report.results, declared);
if (silent.length) {
console.log(`Объявлены как изменённые, но совпали с эталоном: ${silent.join(', ')}.`);
}
// Новые эталоны печатаются отдельной строкой, а не в общем списке: раньше они
// растворялись среди изменившихся, и три кадра каталога устройств уехали в
// контракт незамеченными (#350).
const fresh = (report.results || []).filter((result) => result.status === 'missing-baseline')
.map((result) => result.id).sort();
const changed = (report.results || []).filter((result) => result.status === 'different')
.map((result) => result.id).sort();
console.log(changed.length
? `Будут заменены эталоны: ${changed.join(', ')}.`
: 'Ни один существующий эталон не изменился.');
if (fresh.length) console.log(`СТАНУТ КОНТРАКТОМ ВПЕРВЫЕ: ${fresh.join(', ')}.`);
if (!changed.length && !fresh.length) console.log('Будет перезаписан только манифест.');
console.log(`Съёмка: chromium ${report.chromium || '?'}, матрица ${report.matrixVersion}.`);
const accept = spawnSync(process.execPath, [
resolve(ROOT, 'demo/golden/accept.mjs'), '--reviewed', `--from=${from}`,
resolve(ROOT, 'demo/golden/accept.mjs'), ...argv,
], { cwd: ROOT, stdio: 'inherit' });
process.exit(accept.status ?? 1);
+43
View File
@@ -98,3 +98,46 @@ export const goldenAcceptanceRefusal = (results, declared = [], declaredNew = []
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 };
};