mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
test: golden acceptance keeps environment witnesses (#355)
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
This commit is contained in:
@@ -8,6 +8,7 @@ import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
|
||||
import { GOLDEN_BASELINE_MANIFEST } from './policy.mjs';
|
||||
import {
|
||||
goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations,
|
||||
goldenWitnessRefusal,
|
||||
} from '../../scripts/golden-acceptance.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
@@ -21,6 +22,9 @@ const list = (name) => {
|
||||
};
|
||||
const declared = list('expect-change');
|
||||
const declaredNew = list('expect-new');
|
||||
const skipWitnesses = process.argv.includes('--no-witnesses');
|
||||
const reasonArg = process.argv.find((arg) => arg.startsWith('--reason='));
|
||||
const skipReason = reasonArg ? reasonArg.slice('--reason='.length) : '';
|
||||
if (!reviewed) throw new Error('refusing to replace baselines without explicit --reviewed');
|
||||
|
||||
const reportPath = resolve(from, 'golden-report.json');
|
||||
@@ -55,6 +59,17 @@ const manifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
|
||||
const previous = existsSync(manifestPath)
|
||||
? JSON.parse(readFileSync(manifestPath, 'utf8')).scenarios || {}
|
||||
: {};
|
||||
// #355: floor свидетелей — необъявленные сцены, совпавшие с эталоном
|
||||
// байт-в-байт, доказывают, что среда съёмки та же, что у принятого эталона.
|
||||
const witnessCheck = goldenWitnessRefusal({
|
||||
results: report.results,
|
||||
declared,
|
||||
declaredNew,
|
||||
previousHashes: previous,
|
||||
skipWitnesses,
|
||||
skipReason,
|
||||
});
|
||||
if (witnessCheck.refusal) throw new Error(witnessCheck.refusal);
|
||||
// Кандидат проверяется целиком, до всякого решения о замене: сломанный отчёт
|
||||
// не имеет права оставить каталог эталонов половинным.
|
||||
for (const scenario of GOLDEN_SCENARIOS) {
|
||||
@@ -84,6 +99,10 @@ writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify
|
||||
acceptedAt: new Date().toISOString(),
|
||||
sourceFingerprint: report.buildFingerprint,
|
||||
chromium: report.chromium,
|
||||
// #355: след приёмки в артефакте, не только в истории shell.
|
||||
witnesses: skipWitnesses
|
||||
? { skipped: true, reason: skipReason }
|
||||
: { count: witnessCheck.witnesses.length, floor: witnessCheck.floor },
|
||||
scenarios: hashes,
|
||||
}, null, 2)}\n`, 'utf8');
|
||||
const silent = goldenSilentDeclarations(report.results, declared);
|
||||
@@ -94,3 +113,8 @@ console.log(`Заменено эталонов: ${plan.replace.length}`
|
||||
+ `${plan.replace.length ? ` (${[...plan.replace].sort().join(', ')})` : ''}.`);
|
||||
console.log(`Сохранено без изменений: ${plan.keep.length}.`);
|
||||
console.log(`Индекс перезаписан на ${GOLDEN_SCENARIOS.length} сцен.`);
|
||||
if (skipWitnesses) {
|
||||
console.log(`Свидетели пропущены осознанно (--no-witnesses): ${skipReason}`);
|
||||
} else {
|
||||
console.log(`Свидетелей среды: ${witnessCheck.witnesses.length} (floor ${witnessCheck.floor}).`);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
*
|
||||
* 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=… --no-witnesses --reason="…"
|
||||
*
|
||||
* Floor свидетелей (#355): после вычета объявленных сцен должно остаться
|
||||
* достаточно необъявленных, совпавших с эталоном байт-в-байт, — они доказывают,
|
||||
* что среда съёмки та же, что у принятого эталона. Тотальная перерисовка
|
||||
* обходится только явным `--no-witnesses --reason="…"`; причина уезжает в
|
||||
* манифест эталонов.
|
||||
*
|
||||
* Обёртка появилась потому, что до #344 любой `.mjs` из `demo/golden` входил в
|
||||
* корпус отпечатка, и правка инструмента приёмки объявляла устаревшими бандл и
|
||||
|
||||
@@ -141,3 +141,65 @@ export const goldenAcceptancePlan = ({
|
||||
}
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -176,3 +176,76 @@ test('объявленная сцена без хеша кандидата —
|
||||
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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user