diff --git a/scripts/docs-accept.mjs b/scripts/docs-accept.mjs index 31c7f780..dcf707d7 100644 --- a/scripts/docs-accept.mjs +++ b/scripts/docs-accept.mjs @@ -139,14 +139,22 @@ function main(argv) { const file = byId.get(id); copyFileSync(file.from, file.to); } + // След приёмки отвечает на вопрос «когда эти пиксели приняли и что тогда + // объявляли». Обновление отпечатка пикселей не меняет — значит и стирать + // ответ не должно (#409, Low из #405): прежняя редакция затирала `declared` + // пустым списком, и история терялась при первом же refresh. + const previous = JSON.parse(readFileSync(resolve(ROOT, 'docs/images/screenshots.json'), 'utf8')) + .acceptance; const accepted = { ...manifest, - acceptance: { - declared: [...decision.replace], - witnesses: decision.witnesses.length, - floor: decision.floor, - ...(skipWitnesses ? { witnessesSkippedBecause: skipReason } : {}), - }, + acceptance: decision.replace.length + ? { + declared: [...decision.replace], + witnesses: decision.witnesses.length, + floor: decision.floor, + ...(skipWitnesses ? { witnessesSkippedBecause: skipReason } : {}), + } + : { ...(previous || {}), lastWriteWasFingerprintOnly: true }, }; writeFileSync( resolve(ROOT, 'docs/images/screenshots.json'), diff --git a/scripts/docs-acceptance.mjs b/scripts/docs-acceptance.mjs index 47f777ec..2b6bdc6e 100644 --- a/scripts/docs-acceptance.mjs +++ b/scripts/docs-acceptance.mjs @@ -31,18 +31,30 @@ */ /** - * Порог свидетелей. Формула та же, что у golden (`goldenWitnessFloor`), и это - * намеренно: два набора картинок в одном репозитории не должны требовать от - * человека помнить два разных правила. + * Порог свидетелей — от размера НАБОРА сценариев. Формула та же, что у golden + * (`goldenWitnessFloor`), и это намеренно: два набора картинок в одном + * репозитории не должны требовать от человека помнить два разных правила. * * Для десяти сцен порог равен одной. Этого достаточно, потому что основную * работу делает не порог, а требование «все необъявленные совпали»: расхождение * среды не бывает точечным. Порог закрывает единственную оставшуюся щель — * попытку объявить изменёнными все кадры разом, когда свидетелей не остаётся * вовсе и подтвердить среду становится нечем. + * + * Первая редакция (#401) считала порог от числа кадров, УЦЕЛЕВШИХ на диске, и + * той самой щелью и обходилась: `rm docs/images/*.png` + объявить все десять + * через `--expect-change` — уцелевших ноль, порог ноль, причины никто не + * спрашивает, а в манифесте остаётся `{"witnesses":0,"floor":0}` без единого + * слова о том, что произошло. Щель была описана в этом самом комментарии + * строкой выше и оставлена открытой (#409, близнец golden-дефекта #408). + * + * Отсутствие закоммиченных кадров — не смягчающее обстоятельство. Свидетелем + * такая сцена быть не может, это правда; но невозможность доказать среду не + * отменяет требования, а требует сказать это вслух — `--no-witnesses + * --reason="…"`, и причина уезжает в манифест. */ -export const docsWitnessFloor = (committedCount) => (committedCount > 0 - ? Math.min(10, Math.ceil(committedCount * 0.1)) +export const docsWitnessFloor = (sceneCount) => (sceneCount > 0 + ? Math.min(10, Math.ceil(sceneCount * 0.1)) : 0); /** Объявленные имена, которых нет в наборе сценариев. */ @@ -104,13 +116,28 @@ export function docsAcceptancePlan({ + ' принимать нельзя: растеризация задевает все кадры с текстом сразу' }; } + // Порог — от набора сценариев, свидетели — из тех, с кем есть что сравнить + // (#409). Разделение принципиальное: удаление кадров лишает доказательств, но + // не должно снижать планку. + // + // `ids` здесь и есть набор: `docs-accept.mjs` передаёт DOC_SCREENSHOTS, а + // `verifyDocsCandidate` до этого отказывает, если набор сцен в кандидате не + // совпал с ожидаемым. Отдельный параметр размера (как в golden, где отчёт + // бывает частичным) поэтому не нужен — но пустой `ids` означает, что считать + // не от чего, и это отказ, а не ноль. + if (!ids.length) { + return { ...empty, refusal: 'набор сценариев пуст: порог свидетелей считать не от чего' }; + } const withCommitted = ids.filter((id) => committed[id]); - const floor = docsWitnessFloor(withCommitted.length); + const floor = docsWitnessFloor(ids.length); const witnesses = withCommitted.filter((id) => !announced.has(id) && committed[id] === candidate[id]); if (!skipWitnesses && witnesses.length < floor) { - return { ...empty, refusal: 'кадров-свидетелей недостаточно:' - + ` ${witnesses.length} из необходимых ${floor}.` + // Числа возвращаются и при отказе: вызывающий печатает свой вердикт, и + // сочинять их заново ему не из чего. + return { ...empty, witnesses, floor, refusal: 'кадров-свидетелей недостаточно:' + + ` ${witnesses.length} из необходимых ${floor}` + + ` (сцен в наборе ${ids.length}, с закоммиченным кадром ${withCommitted.length}).` + ' Свидетель — необъявленный кадр, совпавший с закоммиченным байт-в-байт;' + ' только он доказывает, что среда съёмки та же. Если перерисовка' + ' действительно тотальная и осознанная — --no-witnesses --reason="…"' diff --git a/test/docs-acceptance.test.mjs b/test/docs-acceptance.test.mjs index daa9fa5f..c816bc08 100644 --- a/test/docs-acceptance.test.mjs +++ b/test/docs-acceptance.test.mjs @@ -106,3 +106,62 @@ test('кадр без закоммиченной пары не может быт assert.deepEqual(plan.witnesses, ['alpha']); assert.equal(plan.floor, 1); }); + +// --- порог считается от набора, а не от уцелевших кадров (#409) ------------- + +test('удаление всех кадров не снижает порог (#409)', () => { + // Воспроизведение обхода целиком: rm docs/images/*.png, объявить все десять. + // Первая редакция (#401) считала порог от уцелевших — ноль уцелевших давал + // ноль порога, и чужая съёмка проходила без единого слова о причине. + const ids = Array.from({ length: 10 }, (_, index) => `scene-${index}`); + const candidate = Object.fromEntries(ids.map((id) => [id, `foreign-${id}`])); + const plan = docsAcceptancePlan({ ids, committed: {}, candidate, declared: ids }); + assert.equal(plan.floor, 1, 'порог от набора сценариев, а не от нуля уцелевших'); + assert.equal(plan.witnesses.length, 0); + assert.match(plan.refusal, /0 из необходимых 1/); + assert.match(plan.refusal, /сцен в наборе 10, с закоммиченным кадром 0/); + + const named = docsAcceptancePlan({ + ids, committed: {}, candidate, declared: ids, + skipWitnesses: true, skipReason: 'первичная съёмка набора', + }); + assert.equal(named.refusal, null, 'законный путь — назвать причину'); +}); + +test('порог держится и при частичной потере кадров (#409)', () => { + // Случай, где старая и новая формулы расходятся: 20 сцен в наборе, три кадра + // на диске. Было бы 1, стало 2 — и три свидетеля этого уже не хватает. + const ids = Array.from({ length: 20 }, (_, index) => `scene-${index}`); + const committed = Object.fromEntries(ids.slice(0, 3).map((id) => [id, `same-${id}`])); + const candidate = Object.fromEntries(ids.map((id) => [id, + committed[id] || `new-${id}`])); + const plan = docsAcceptancePlan({ + ids, committed, candidate, declared: ids.slice(3), + }); + assert.equal(plan.floor, 2); + assert.equal(plan.witnesses.length, 3, 'свидетелем может быть только кадр с парой'); + assert.equal(plan.refusal, null, 'три свидетеля при пороге два — достаточно'); + + const stricter = docsAcceptancePlan({ + ids, committed: { [ids[0]]: 'same-scene-0' }, + candidate: Object.fromEntries(ids.map((id) => [id, + id === ids[0] ? 'same-scene-0' : `new-${id}`])), + declared: ids.slice(1), + }); + assert.match(stricter.refusal, /1 из необходимых 2/, + 'один уцелевший кадр планку не опускает'); +}); + +test('пустой набор сценариев — отказ, а не ноль (#409)', () => { + const plan = docsAcceptancePlan({ ids: [], committed: {}, candidate: {} }); + assert.match(plan.refusal, /набор сценариев пуст/); +}); + +test('порог совпадает с golden при равном размере набора (#409)', async () => { + const { goldenWitnessFloor } = await import('../scripts/golden-acceptance.mjs'); + // Формулы обязаны совпадать: два набора картинок в одном репозитории не + // должны требовать помнить два разных правила. + for (const size of [0, 1, 10, 20, 143, 1000]) { + assert.equal(docsWitnessFloor(size), goldenWitnessFloor(size), `размер ${size}`); + } +});