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
252 lines
14 KiB
JavaScript
252 lines
14 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/);
|
|
});
|
|
|
|
// #355. Floor свидетелей: перечислив всю матрицу в --expect-change, принять
|
|
// чужую съёмку больше нельзя — среду доказывают только необъявленные сцены,
|
|
// совпавшие с эталоном байт-в-байт.
|
|
test('приёмка без свидетелей отказывает и называет дефицит (#355)', async () => {
|
|
const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs');
|
|
const all = Array.from({ length: 20 }, (_, index) => ({
|
|
id: `scene-${index}`, status: 'different', actualSha256: `new-${index}`,
|
|
}));
|
|
const { refusal, floor } = goldenWitnessRefusal({
|
|
results: all,
|
|
declared: all.map((result) => result.id),
|
|
previousHashes: Object.fromEntries(all.map((result) => [result.id, `old-${result.id}`])),
|
|
});
|
|
assert.equal(floor, 2, '10% от 20 эталонных сцен');
|
|
assert.match(refusal, /0 из необходимых 2/);
|
|
assert.match(refusal, /--no-witnesses/);
|
|
});
|
|
|
|
test('обычная приёмка с достаточным числом свидетелей проходит (#355)', async () => {
|
|
const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs');
|
|
const scenes = [
|
|
{ id: 'edited', status: 'different', actualSha256: 'changed' },
|
|
...Array.from({ length: 30 }, (_, index) => ({
|
|
id: `same-${index}`, status: 'passed', actualSha256: `sha-${index}`,
|
|
})),
|
|
];
|
|
const previousHashes = Object.fromEntries(
|
|
scenes.filter((scene) => scene.id !== 'edited')
|
|
.map((scene) => [scene.id, scene.actualSha256]),
|
|
);
|
|
const { refusal, witnesses, floor } = goldenWitnessRefusal({
|
|
results: scenes, declared: ['edited'], previousHashes,
|
|
});
|
|
assert.equal(refusal, null);
|
|
assert.equal(floor, 4, '10% от 31 эталонной сцены');
|
|
assert.equal(witnesses.length, 30);
|
|
});
|
|
|
|
test('passed в пределах порога — не свидетель: среду доказывает байт-в-байт (#355)', async () => {
|
|
const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs');
|
|
const scenes = Array.from({ length: 10 }, (_, index) => ({
|
|
id: `drifted-${index}`, status: 'passed', actualSha256: `candidate-${index}`,
|
|
}));
|
|
const previousHashes = Object.fromEntries(
|
|
scenes.map((scene) => [scene.id, `baseline-${scene.id}`]),
|
|
);
|
|
const { refusal, witnesses } = goldenWitnessRefusal({
|
|
results: scenes, declared: [], previousHashes,
|
|
});
|
|
assert.equal(witnesses.length, 0, 'подпороговый дрейф не доказывает среду');
|
|
assert.match(refusal, /0 из необходимых 1/);
|
|
});
|
|
|
|
test('--no-witnesses требует причину и с ней пропускает floor (#355)', async () => {
|
|
const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs');
|
|
const bare = goldenWitnessRefusal({ results: [], skipWitnesses: true });
|
|
assert.match(bare.refusal, /--reason/);
|
|
const reasoned = goldenWitnessRefusal({
|
|
results: [], skipWitnesses: true, skipReason: 'полная перерисовка матрицы v49',
|
|
});
|
|
assert.equal(reasoned.refusal, null);
|
|
});
|
|
|
|
test('первичная съёмка без единого эталона свидетелей не требует (#355)', async () => {
|
|
const { goldenWitnessRefusal, goldenWitnessFloor } = await import('../scripts/golden-acceptance.mjs');
|
|
assert.equal(goldenWitnessFloor(0), 0);
|
|
const { refusal } = goldenWitnessRefusal({
|
|
results: [{ id: 'first', status: 'missing-baseline', actualSha256: 'x' }],
|
|
declaredNew: ['first'],
|
|
});
|
|
assert.equal(refusal, null, 'каждый кадр и так объявлен в --expect-new');
|
|
});
|