From cd0ecf4db26fa5e7fdd2a9c8cdef7e61b7f40697 Mon Sep 17 00:00:00 2001 From: Matysh Date: Tue, 1 Sep 2026 17:25:54 +0300 Subject: [PATCH] fix: the witness floor comes from the matrix, not from surviving baselines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Порог свидетелей считался от числа сцен со статусом не missing-baseline, то есть от эталонов, уцелевших на диске. Обход в одну команду: git rm demo/golden/baselines/*.png — все сцены становятся missing-baseline, порог обращается в ноль, свидетелей никто не требует, и чужая съёмка всей матрицы принимается без единого следа причины в манифесте. Отказ goldenAcceptanceRefusal этого не ловит: он требует объявить каждую новую сцену в --expect-new, а объявить их все ничто не мешает. Прежняя редакция объясняла ноль тем, что первичная съёмка свидетелей иметь не может. Верно по факту и неверно по выводу: невозможность доказать среду не отменяет требования, она требует сказать это вслух. Теперь и первичная съёмка идёт через --no-witnesses --reason, а причина уезжает в манифест эталонов. Размер матрицы стал обязательным параметром, а не выводится из отчёта: у частичного прогона (run.mjs --only=…) results короче матрицы, и порог просел бы молча — тот же дефект в другой одежде. Отсутствие параметра — отказ. Формула не менялась: она общая с docsWitnessFloor и обязана такой остаться. Менялся источник счётчика. На обычной приёмке ничего не меняется: при 143 эталонах порог был и остался 10. Issue: #408 User-Visible: no --- demo/golden/accept.mjs | 3 ++ scripts/golden-acceptance.mjs | 53 +++++++++++++++++++----- test/golden-policy.test.mjs | 76 +++++++++++++++++++++++++++++++---- 3 files changed, 113 insertions(+), 19 deletions(-) diff --git a/demo/golden/accept.mjs b/demo/golden/accept.mjs index 1b8a5f65..95b8c6b0 100644 --- a/demo/golden/accept.mjs +++ b/demo/golden/accept.mjs @@ -63,6 +63,9 @@ const previous = existsSync(manifestPath) // байт-в-байт, доказывают, что среда съёмки та же, что у принятого эталона. const witnessCheck = goldenWitnessRefusal({ results: report.results, + // #408: от размера матрицы, а не от числа уцелевших эталонов — иначе порог + // обходится удалением каталога эталонов. + sceneCount: GOLDEN_SCENARIOS.length, declared, declaredNew, previousHashes: previous, diff --git a/scripts/golden-acceptance.mjs b/scripts/golden-acceptance.mjs index 69c86565..30bdd312 100644 --- a/scripts/golden-acceptance.mjs +++ b/scripts/golden-acceptance.mjs @@ -155,21 +155,36 @@ export const goldenAcceptancePlan = ({ * * Свидетель — необъявленная сцена, чей кандидат совпал с принятым эталоном * БАЙТ-В-БАЙТ: `passed` означает лишь «в пределах порога» (#351), а среду - * доказывает только точное совпадение. Floor — 10 свидетелей или 10% сцен с - * эталонами, что меньше; матрица без эталонов (первичная съёмка) свидетелей - * требовать не может — там каждый кадр и так объявляется в `--expect-new`. + * доказывает только точное совпадение. Floor — 10 свидетелей или 10% сцен + * МАТРИЦЫ, что меньше. * - * Осознанный обход для действительно тотальных перерисовок — `--no-witnesses` - * с обязательной причиной: она уезжает в манифест эталонов, то есть в артефакт - * и его git-историю, а не только в историю shell. + * Именно матрицы, а не уцелевших на диске эталонов (#408). Прежняя редакция + * считала floor от числа сцен со статусом не `missing-baseline`, и у неё был + * обход в одну команду: `git rm demo/golden/baselines/*.png` — все сцены + * становятся `missing-baseline`, floor обращается в ноль, свидетелей никто не + * требует, и чужая съёмка всей матрицы принимается без единого следа причины. + * Отказ `goldenAcceptanceRefusal` этого не ловит: он требует, чтобы каждая + * новая сцена была объявлена в `--expect-new`, а объявить их все ничто не + * мешает. Прежняя редакция объясняла ноль тем, что первичная съёмка свидетелей + * иметь не может — верно по факту и неверно по выводу: невозможность + * доказательства не отменяет требования, она требует признать это вслух. + * + * Осознанный обход для действительно тотальных перерисовок и для первичной + * съёмки — `--no-witnesses` с обязательной причиной: она уезжает в манифест + * эталонов, то есть в артефакт и его git-историю, а не только в историю shell. */ -export const goldenWitnessFloor = (baselineCount) => (baselineCount > 0 - ? Math.min(10, Math.ceil(baselineCount * 0.1)) +/** + * Порог по размеру МАТРИЦЫ сцен. Формула та же, что у `docsWitnessFloor` + * (комментарий `docs-acceptance.mjs` требует, чтобы они совпадали); менялся + * в #408 только источник счётчика. + */ +export const goldenWitnessFloor = (sceneCount) => (sceneCount > 0 + ? Math.min(10, Math.ceil(sceneCount * 0.1)) : 0); export const goldenWitnessRefusal = ({ results, declared = [], declaredNew = [], previousHashes = {}, - skipWitnesses = false, skipReason = '', + skipWitnesses = false, skipReason = '', sceneCount, }) => { if (skipWitnesses) { if (typeof skipReason !== 'string' || !skipReason.trim()) { @@ -178,9 +193,24 @@ export const goldenWitnessRefusal = ({ } return { refusal: null, witnesses: [], floor: 0 }; } + // Размер матрицы обязателен и не выводится из отчёта: `results.length` у + // частичного прогона (`run.mjs --only=…`) меньше матрицы, и floor молча + // просел бы — тот же дефект #408 в другой одежде. Отсутствие параметра — + // отказ, а не догадка. + if (!Number.isInteger(sceneCount) || sceneCount < 0) { + return { + refusal: 'приёмка не знает размера матрицы сцен: floor свидетелей считать не от чего.' + + ' Передайте sceneCount (GOLDEN_SCENARIOS.length) — от числа уцелевших на диске' + + ' эталонов его считать нельзя, это обходится удалением эталонов (#408)', + 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 floor = goldenWitnessFloor(sceneCount); const witnesses = withBaseline .filter((result) => !accepted.has(result.id) && result.status === 'passed' @@ -192,7 +222,8 @@ export const goldenWitnessRefusal = ({ return { refusal: 'сцен-свидетелей среды недостаточно:' + ` ${witnesses.length} из необходимых ${floor}` - + ` (эталонных сцен ${withBaseline.length}, объявлено ${accepted.size}).` + + ` (сцен в матрице ${sceneCount}, с эталонами ${withBaseline.length},` + + ` объявлено ${accepted.size}).` + ' Свидетель — необъявленная сцена, совпавшая с эталоном байт-в-байт;' + ' именно они доказывают, что среда съёмки та же, что у принятого' + ' эталона. Если перерисовка действительно тотальная и осознанная —' diff --git a/test/golden-policy.test.mjs b/test/golden-policy.test.mjs index a5c53e3e..7dd5fab5 100644 --- a/test/golden-policy.test.mjs +++ b/test/golden-policy.test.mjs @@ -187,6 +187,7 @@ test('приёмка без свидетелей отказывает и наз })); const { refusal, floor } = goldenWitnessRefusal({ results: all, + sceneCount: all.length, declared: all.map((result) => result.id), previousHashes: Object.fromEntries(all.map((result) => [result.id, `old-${result.id}`])), }); @@ -208,7 +209,7 @@ test('обычная приёмка с достаточным числом св .map((scene) => [scene.id, scene.actualSha256]), ); const { refusal, witnesses, floor } = goldenWitnessRefusal({ - results: scenes, declared: ['edited'], previousHashes, + results: scenes, sceneCount: scenes.length, declared: ['edited'], previousHashes, }); assert.equal(refusal, null); assert.equal(floor, 4, '10% от 31 эталонной сцены'); @@ -224,7 +225,7 @@ test('passed в пределах порога — не свидетель: ср scenes.map((scene) => [scene.id, `baseline-${scene.id}`]), ); const { refusal, witnesses } = goldenWitnessRefusal({ - results: scenes, declared: [], previousHashes, + results: scenes, sceneCount: scenes.length, declared: [], previousHashes, }); assert.equal(witnesses.length, 0, 'подпороговый дрейф не доказывает среду'); assert.match(refusal, /0 из необходимых 1/); @@ -240,12 +241,71 @@ test('--no-witnesses требует причину и с ней пропуска assert.equal(reasoned.refusal, null); }); -test('первичная съёмка без единого эталона свидетелей не требует (#355)', async () => { +test('первичная съёмка требует названной причины, а не молчания (#408)', 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'], + // До #408 здесь стоял отказ от требования: матрица без эталонов свидетелей + // иметь не может, значит и floor ноль. Верно по факту, неверно по выводу — + // именно этим ноль и обходился: `git rm baselines/*.png` превращал любую + // приёмку в первичную съёмку. + assert.equal(goldenWitnessFloor(0), 0, 'пустая матрица — единственный законный ноль'); + const results = [{ id: 'first', status: 'missing-baseline', actualSha256: 'x' }]; + const silent = goldenWitnessRefusal({ results, sceneCount: 1, declaredNew: ['first'] }); + assert.match(silent.refusal, /свидетелей среды недостаточно/, + 'невозможность доказать среду не отменяет требования, а требует сказать это вслух'); + const named = goldenWitnessRefusal({ + results, sceneCount: 1, declaredNew: ['first'], + skipWitnesses: true, skipReason: 'первичная съёмка матрицы', }); - assert.equal(refusal, null, 'каждый кадр и так объявлен в --expect-new'); + assert.equal(named.refusal, null); }); + +test('удаление всех эталонов не снижает порог (#408)', async () => { + const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs'); + // Воспроизведение обхода из #408 целиком: удалить эталоны, объявить все сцены + // новыми, принять чужую съёмку без единого следа причины. + const wiped = Array.from({ length: 143 }, (_, index) => ({ + id: `scene-${index}`, status: 'missing-baseline', actualSha256: `foreign-${index}`, + })); + const { refusal, floor, witnesses } = goldenWitnessRefusal({ + results: wiped, + sceneCount: wiped.length, + declaredNew: wiped.map((result) => result.id), + }); + assert.equal(floor, 10, 'порог считается от матрицы, а не от нуля уцелевших'); + assert.equal(witnesses.length, 0); + assert.match(refusal, /0 из необходимых 10/); + assert.match(refusal, /сцен в матрице 143, с эталонами 0/); +}); + +test('порог держится и при частичной потере эталонов (#408)', async () => { + const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs'); + // Три уцелевших эталона дают три свидетеля — но планку задаёт матрица. + const kept = Array.from({ length: 3 }, (_, index) => ({ + id: `kept-${index}`, status: 'passed', actualSha256: `same-${index}`, + })); + const lost = Array.from({ length: 140 }, (_, index) => ({ + id: `lost-${index}`, status: 'missing-baseline', actualSha256: `new-${index}`, + })); + const { refusal, floor, witnesses } = goldenWitnessRefusal({ + results: [...kept, ...lost], + sceneCount: 143, + declaredNew: lost.map((result) => result.id), + previousHashes: Object.fromEntries(kept.map((result) => [result.id, result.actualSha256])), + }); + assert.equal(floor, 10); + assert.equal(witnesses.length, 3, 'свидетелем может быть только сцена с эталоном'); + assert.match(refusal, /3 из необходимых 10/); +}); + +test('размер матрицы обязателен: догадываться о нём нельзя (#408)', async () => { + const { goldenWitnessRefusal } = await import('../scripts/golden-acceptance.mjs'); + // Вывести его из отчёта соблазнительно и неверно: у частичного прогона + // (`run.mjs --only=…`) results короче матрицы, и порог просел бы молча. + const results = [{ id: 'one', status: 'different', actualSha256: 'x' }]; + for (const sceneCount of [undefined, null, -1, 1.5, '143']) { + const { refusal, floor } = goldenWitnessRefusal({ results, sceneCount, declared: ['one'] }); + assert.match(refusal, /не знает размера матрицы/, `sceneCount=${sceneCount}`); + assert.equal(floor, 0); + } +}); +