Files
houseplan-card/test/golden-policy.test.mjs
T
Claude 0c9cf9503e 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
2026-08-28 11:52:36 +03:00

179 lines
9.9 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
assertGoldenInvocation,
GOLDEN_BASELINE_MANIFEST,
goldenRunFailed,
goldenScenarioSetsMatch,
} from '../demo/golden/policy.mjs';
import {
goldenAcceptancePlan, 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');
// The old check compared against 'manifest.json' and passed while the file
// was called 'baseline-manifest.json' — which is exactly what the HACS glob
// `*manifest.json` matches, and what turned Hassfest red on PR #9004
// (2026-08-11). Ending with the word is enough to break the submission.
assert.equal(GOLDEN_BASELINE_MANIFEST.endsWith('manifest.json'), false);
});
test('golden capture fails on runtime errors but permits missing baselines', () => {
assert.equal(goldenRunFailed('capture', false, [{ status: 'error' }]), true);
assert.equal(goldenRunFailed('capture', false, [{ status: 'missing-baseline' }]), false);
assert.equal(goldenRunFailed('verify', true, [{ status: 'different' }]), true);
assert.equal(goldenRunFailed('verify', true, [{ status: 'passed' }]), false);
});
test('golden verification cannot make a partial success claim', () => {
assert.doesNotThrow(() => assertGoldenInvocation('capture', 'one-scenario'));
assert.doesNotThrow(() => assertGoldenInvocation('verify', ''));
assert.throws(() => assertGoldenInvocation('verify', 'one-scenario'), /complete matrix/);
assert.throws(() => assertGoldenInvocation('unknown', ''), /unknown golden mode/);
});
test('golden baseline inventory rejects orphan hashes and PNGs', () => {
assert.equal(goldenScenarioSetsMatch(['a', 'b'], ['b', 'a'], ['a', 'b']), true);
assert.equal(goldenScenarioSetsMatch(['a'], ['a', 'orphan'], ['a']), false);
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'], ['c'],
), null);
});
test('новая сцена требует своего объявления, иначе станет контрактом молча (#350)', () => {
// Прежнее правило (#334) пропускало её без вопросов: эталона, которому
// противоречить, у неё нет. Половина обоснования верна — параллельность среды
// новая сцена доказать не может. Но правило молчало про второй вопрос: пустой
// или обрезанный кадр закрепляется так же надёжно, как испорченный старый.
// Так три эталона каталога устройств уехали в контракт без единого взгляда.
const refusal = goldenAcceptanceRefusal(results([['new', 'missing-baseline']]), []);
assert.match(refusal, /эталона ещё нет, и они станут контрактом: new/);
assert.match(refusal, /--expect-new/);
assert.equal(goldenAcceptanceRefusal(results([['new', 'missing-baseline']]), [], ['new']), null);
});
test('флаги не взаимозаменяемы: имя в чужом останавливает приёмку (#350)', () => {
// Путаница означает, что ревьюер думал об одной сцене, а утверждал про другую.
assert.match(
goldenAcceptanceRefusal(results([['new', 'missing-baseline']]), ['new'], []),
/эталона ещё нет, их место в --expect-new: new/,
);
assert.match(
goldenAcceptanceRefusal(results([['old', 'different']]), [], ['old']),
/эталон уже есть, их место в --expect-change: old/,
);
});
test('новая сцена не отменяет разбора изменившихся, и наоборот (#350)', () => {
const list = results([['old', 'different'], ['new', 'missing-baseline'], ['same', 'passed']]);
assert.match(goldenAcceptanceRefusal(list, [], ['new']), /^съёмка разошлась/);
assert.match(goldenAcceptanceRefusal(list, ['old'], []), /станут контрактом/);
assert.equal(goldenAcceptanceRefusal(list, ['old'], ['new']), 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, /которых нет в отчёте: опечатка/);
assert.match(
goldenAcceptanceRefusal(results([['a', 'passed']]), [], ['опечатка']),
/которых нет в отчёте: опечатка/,
);
});
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']), []);
});
// #351. `passed` означает «в пределах порога», а не «байт в байт». Прежняя
// приёмка копировала кандидата поверх каждого эталона, поэтому подпороговый
// дрейф уезжал в контракт молча и накапливался. Так 1e341c60 заменил 22
// картинки, объявив четыре.
test('необъявленная сцена сохраняет свой эталон и свой хеш (#351)', () => {
const plan = goldenAcceptancePlan({
scenarioIds: ['declared', 'drifted', 'fresh'],
results: [
{ id: 'declared', status: 'different', actualSha256: 'new-declared' },
// Байты кандидата другие, но расхождение подпороговое — и именно поэтому
// сцена не имеет права попасть в эталоны без объявления.
{ id: 'drifted', status: 'passed', actualSha256: 'new-drifted' },
{ id: 'fresh', status: 'missing-baseline', actualSha256: 'new-fresh' },
],
previousHashes: { declared: 'old-declared', drifted: 'old-drifted' },
declared: ['declared'],
declaredNew: ['fresh'],
});
assert.deepEqual(plan.replace.sort(), ['declared', 'fresh']);
assert.deepEqual(plan.keep, ['drifted']);
assert.deepEqual(plan.hashes, {
declared: 'new-declared',
drifted: 'old-drifted',
fresh: 'new-fresh',
});
});
test('индекс перезаписывается на полный набор сцен, а не на заменённые (#351)', () => {
// Полнота манифеста — инвариант goldenScenarioSetsMatch: сирота в индексе или
// пропавшая запись делают весь манифест недействительным.
const plan = goldenAcceptancePlan({
scenarioIds: ['a', 'b', 'c'],
results: [{ id: 'a', status: 'different', actualSha256: 'na' }],
previousHashes: { a: 'oa', b: 'ob', c: 'oc' },
declared: ['a'],
});
assert.deepEqual(Object.keys(plan.hashes).sort(), ['a', 'b', 'c']);
});
test('необъявленная сцена без прежнего эталона — ошибка, а не тихий кандидат (#351)', () => {
assert.throws(() => goldenAcceptancePlan({
scenarioIds: ['orphan'],
results: [{ id: 'orphan', status: 'missing-baseline', actualSha256: 'x' }],
previousHashes: {},
}), /без прежнего эталона: orphan/);
});
test('объявленная сцена без хеша кандидата — ошибка (#351)', () => {
assert.throws(() => goldenAcceptancePlan({
scenarioIds: ['a'],
results: [{ id: 'a', status: 'different' }],
previousHashes: { a: 'oa' },
declared: ['a'],
}), /без хеша кандидата: a/);
});