/** * Правило допустимости съёмки скриншотов документации (issue #401). * * До этого правило было про МЕСТО: снимать только в CI, потому что растеризация * шрифтов на другой машине отличается. Обоснование измерено — пересъёмка в #231 * изменила два файла из девяти на 7–8 байт, а набор с беты все девять. Но * держалось оно на комментарии в шапке `docs-accept.mjs`, а не на механизме: * кандидат проверялся на самосогласованность и принимался целиком, без единого * сравнения с тем, что лежит в репозитории. * * Цена такого правила видна на #390. Правка типов, которая физически не может * сдвинуть пиксель, потребовала прогона workflow, а затем правки одиннадцати * полей манифеста руками. Гейт, заставляющий писать хеши руками, работает * против себя. * * Здесь правило заменено на проверяемое, и оно ровно то же, что у golden с * #334: среда съёмки доказана, если КАЖДЫЙ кадр, который менять не собирались, * совпал с закоммиченным БАЙТ-В-БАЙТ. Расхождение растеризации спрятать нельзя * — оно задевает все кадры с текстом, а не только правленные. Поэтому автор * объявляет намерение списком `--expect-change`, и всё, что разошлось помимо * списка, приёмку запрещает: либо это незамеченная регрессия рендера, либо * среда не та. * * То же правило ловит второе, независимо от среды: «принять всё, чтобы гейт * позеленел». Так скриншот документации перестаёт показывать продукт — молча, * одной командой, без единого названного намерения. * * Отличие от golden только в строгости, и оно в пользу скриншотов: сцен десять, * а не сто сорок три, и сравнение точное, без порога. Поэтому «совпал» здесь * значит буквально совпал, а не «в пределах допуска». */ /** * Порог свидетелей — от размера НАБОРА сценариев. Формула та же, что у golden * (`goldenWitnessFloor`), и это намеренно: два набора картинок в одном * репозитории не должны требовать от человека помнить два разных правила. * * Для десяти сцен порог равен одной. Этого достаточно, потому что основную * работу делает не порог, а требование «все необъявленные совпали»: расхождение * среды не бывает точечным. Порог закрывает единственную оставшуюся щель — * попытку объявить изменёнными все кадры разом, когда свидетелей не остаётся * вовсе и подтвердить среду становится нечем. * * Первая редакция (#401) считала порог от числа кадров, УЦЕЛЕВШИХ на диске, и * той самой щелью и обходилась: `rm docs/images/*.png` + объявить все десять * через `--expect-change` — уцелевших ноль, порог ноль, причины никто не * спрашивает, а в манифесте остаётся `{"witnesses":0,"floor":0}` без единого * слова о том, что произошло. Щель была описана в этом самом комментарии * строкой выше и оставлена открытой (#409, близнец golden-дефекта #408). * * Отсутствие закоммиченных кадров — не смягчающее обстоятельство. Свидетелем * такая сцена быть не может, это правда; но невозможность доказать среду не * отменяет требования, а требует сказать это вслух — `--no-witnesses * --reason="…"`, и причина уезжает в манифест. */ export const docsWitnessFloor = (sceneCount) => (sceneCount > 0 ? Math.min(10, Math.ceil(sceneCount * 0.1)) : 0); /** Объявленные имена, которых нет в наборе сценариев. */ export const docsUnknownDeclarations = (ids, declared = []) => declared .filter((id) => id && !ids.includes(id)) .sort(); /** * Объявленные кадры, которые в действительности не изменились. * * Это не придирка к аккуратности. Декларация — утверждение «я знаю, почему этот * кадр другой»; если он не другой, утверждение ложное, и вместе с ним теряет * смысл весь список. На практике так выглядит усталость: автор перечисляет * «всё, что покраснело», захватывая заодно то, что не краснело. */ export const docsSilentDeclarations = ({ committed = {}, candidate = {}, declared = [] }) => declared .filter((id) => committed[id] && candidate[id] && committed[id] === candidate[id]) .sort(); /** * План приёмки и причина отказа. * * @param ids все сценарии набора, в стабильном порядке * @param committed id → sha256 закоммиченного файла (отсутствует — значит нет файла) * @param candidate id → sha256 файла в артефакте * @param declared что автор объявил изменённым * @returns {{ refusal: string|null, replace: string[], keep: string[], * witnesses: string[], floor: number }} */ export function docsAcceptancePlan({ ids = [], committed = {}, candidate = {}, declared = [], skipWitnesses = false, skipReason = '', }) { const empty = { replace: [], keep: [...ids], witnesses: [], floor: 0 }; if (skipWitnesses && (typeof skipReason !== 'string' || !skipReason.trim())) { return { ...empty, refusal: '--no-witnesses требует --reason="…": причина обхода обязана' + ' остаться в манифесте, а не только в истории shell' }; } const unknown = docsUnknownDeclarations(ids, declared); if (unknown.length) { return { ...empty, refusal: `объявлены кадры, которых нет в наборе: ${unknown.join(', ')}` }; } const silent = docsSilentDeclarations({ committed, candidate, declared }); if (silent.length) { return { ...empty, refusal: `объявлены изменёнными, но не изменились: ${silent.join(', ')}.` + ' Декларация — утверждение «я знаю, почему этот кадр другой»; на неизменившемся' + ' кадре оно ложное и обесценивает весь список' }; } const announced = new Set(declared.filter(Boolean)); const strayed = ids.filter((id) => !announced.has(id) && committed[id] !== candidate[id]); if (strayed.length) { return { ...empty, refusal: `разошлись, но не объявлены: ${strayed.join(', ')}.` + ' Либо это незамеченное изменение продукта — тогда объявите его' + ' --expect-change; либо съёмка велась в другой среде, и тогда её кадры' + ' принимать нельзя: растеризация задевает все кадры с текстом сразу' }; } // Порог — от набора сценариев, свидетели — из тех, с кем есть что сравнить // (#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(ids.length); const witnesses = withCommitted.filter((id) => !announced.has(id) && committed[id] === candidate[id]); if (!skipWitnesses && witnesses.length < floor) { // Числа возвращаются и при отказе: вызывающий печатает свой вердикт, и // сочинять их заново ему не из чего. return { ...empty, witnesses, floor, refusal: 'кадров-свидетелей недостаточно:' + ` ${witnesses.length} из необходимых ${floor}` + ` (сцен в наборе ${ids.length}, с закоммиченным кадром ${withCommitted.length}).` + ' Свидетель — необъявленный кадр, совпавший с закоммиченным байт-в-байт;' + ' только он доказывает, что среда съёмки та же. Если перерисовка' + ' действительно тотальная и осознанная — --no-witnesses --reason="…"' + ' оставит причину в манифесте' }; } const replace = ids.filter((id) => announced.has(id)); return { refusal: null, replace, keep: ids.filter((id) => !announced.has(id)), witnesses, floor, }; }