mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Тот же дефект, что #408 у golden, и в моём же коде из #401. Порог свидетелей считался от числа кадров, уцелевших на диске: rm docs/images/*.png плюс объявить все десять через --expect-change — уцелевших ноль, порог ноль, причины никто не спрашивает, а в манифесте остаётся {"witnesses":0,"floor":0} без единого слова о произошедшем. Щель была описана в комментарии над самой функцией и оставлена открытой. Порог теперь от набора сценариев, свидетели — из тех, с кем есть что сравнить. Разделение принципиальное: удаление кадров лишает доказательств, но не должно снижать планку. Первичная съёмка идёт через --no-witnesses --reason, как теперь и в golden. Отдельного параметра размера, как в golden, здесь не нужно, и это не небрежность: `ids` и есть набор — docs-accept.mjs передаёт DOC_SCREENSHOTS, а verifyDocsCandidate до того отказывает, если набор сцен в кандидате не совпал с ожидаемым. Пустой ids — отказ, а не ноль. Попутно Low из #405: повторная приёмка неизменённого набора затирала acceptance.declared пустым списком. След приёмки отвечает на вопрос «когда эти пиксели приняли и что тогда объявляли», а обновление отпечатка пикселей не меняет — значит и стирать ответ не должно. Прежний след сохраняется и помечается lastWriteWasFingerprintOnly. Заодно отказ по свидетелям теперь возвращает сами числа: вызывающий печатает свой вердикт, и сочинять их заново ему не из чего. Issue: #409 User-Visible: no
156 lines
11 KiB
JavaScript
156 lines
11 KiB
JavaScript
/**
|
||
* Правило допустимости съёмки скриншотов документации (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,
|
||
};
|
||
}
|