test: локальная съёмка golden допустима по доказательству, а не по доверию

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

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

То же правило независимо от среды запрещает «принять всё, чтобы CI позеленел» —
именно так эталон перестаёт быть эталоном, молча и одной командой.

scripts/golden-container.mjs снимает кандидатов в образе Playwright той же
версии, что залочена в package-lock. Хозяйский node_modules прячется анонимным
томом: он собран под Windows, и npm ci внутри контейнера сломал бы дерево.

Обёртка, а не правка demo/golden/accept.mjs, — намеренно. sourceFingerprint
включает ВСЕ .mjs из demo/golden, включая accept.mjs и policy.mjs, которые
исполняются после съёмки и ни одного пикселя изменить не могут. Их правка
объявляет устаревшими бандл и оба манифеста, то есть требует ровно того двойного
цикла, который эта задача убирает. Сужение корпуса отпечатка — отдельная задача:
сам source-fingerprint.mjs в корпусе, и одна пересборка бандла неизбежна.

Issue: #334
User-Visible: no
This commit is contained in:
Claude
2026-08-28 01:59:45 +03:00
parent 2c20f2dc35
commit 4800c44e51
5 changed files with 308 additions and 5 deletions
+52
View File
@@ -6,6 +6,9 @@ import {
goldenRunFailed,
goldenScenarioSetsMatch,
} from '../demo/golden/policy.mjs';
import {
goldenAcceptanceRefusal, goldenSilentDeclarations,
} from '../scripts/golden-acceptance.mjs';
test('golden metadata cannot be mistaken for a Home Assistant integration manifest', () => {
assert.equal(GOLDEN_BASELINE_MANIFEST, 'baselines-index.json');
@@ -36,3 +39,52 @@ test('golden baseline inventory rejects orphan hashes and PNGs', () => {
assert.equal(goldenScenarioSetsMatch(['a'], ['a'], ['a', 'orphan']), false);
assert.equal(goldenScenarioSetsMatch(['a', 'b'], ['a'], ['a', 'b']), false);
});
// #334. Локальная съёмка допустима не по доверию, а по доказательству: среда
// равна раннеру, если всё, что менять не собирались, совпало с эталонами.
const results = (entries) => entries.map(([id, status]) => ({ id, status }));
test('приёмка отвергает расхождение в сцене, которую менять не собирались (#334)', () => {
const refusal = goldenAcceptanceRefusal(
results([['a', 'different'], ['b', 'different'], ['c', 'passed']]), ['a'],
);
assert.match(refusal, /^съёмка разошлась/);
assert.match(refusal, / b\./);
// Объявленная сцена в перечислении не появляется — иначе сообщение
// указывало бы на автора вместо среды.
assert.equal(/ a[,.]/.test(refusal), false);
});
test('приёмка проходит, когда разошлись ровно объявленные сцены (#334)', () => {
assert.equal(goldenAcceptanceRefusal(
results([['a', 'different'], ['b', 'passed'], ['c', 'missing-baseline']]), ['a'],
), null);
});
test('новая сцена объявления не требует: эталона, которому противоречить, нет (#334)', () => {
assert.equal(goldenAcceptanceRefusal(results([['new', 'missing-baseline']]), []), null);
});
test('приёмка без объявлений запрещает «принять всё, чтобы CI позеленел» (#334)', () => {
// Ровно то, чем эталон перестаёт быть эталоном: одна команда, три подмены,
// ни одного названного намерения.
const refusal = goldenAcceptanceRefusal(
results([['a', 'different'], ['b', 'different'], ['c', 'different']]), [],
);
assert.match(refusal, /a, b, c/);
});
test('приёмка отвергает объявление сцены, которой нет в отчёте (#334)', () => {
const refusal = goldenAcceptanceRefusal(results([['a', 'passed']]), ['a', 'опечатка']);
assert.match(refusal, /которых нет в отчёте: опечатка/);
});
test('приёмка отвергает отчёт без результатов сцен (#334)', () => {
assert.match(goldenAcceptanceRefusal(null, []), /не содержит результатов/);
});
test('объявленная, но совпавшая сцена называется, а не проглатывается (#334)', () => {
const list = results([['a', 'passed'], ['b', 'different']]);
assert.deepEqual(goldenSilentDeclarations(list, ['a', 'b']), ['a']);
assert.deepEqual(goldenSilentDeclarations(list, ['b']), []);
});