mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
ci: prove the screenshot environment instead of trusting the place
Правило приёмки скриншотов было про место: снимать только в CI. Обоснование измерено — съёмка в другом окружении переписывает файлы без содержательных изменений, в #231 два из девяти на 7–8 байт, набор с беты все девять. Но держалось правило на комментарии, а не на механизме: кандидат проверялся на самосогласованность и принимался целиком, ни разу не сравниваясь с тем, что лежит в репозитории. Цена видна на #390: правка типов, которая физически не может сдвинуть пиксель, потребовала прогона workflow, а затем правки одиннадцати полей манифеста руками. Теперь правило про доказательство, и оно то же, что у golden с #334: среда доказана, если каждый кадр, который менять не собирались, совпал с закоммиченным байт-в-байт. Расхождение растеризации спрятать нельзя — оно задевает все кадры с текстом сразу. Снимать можно где угодно, включая WSL; принять получится только оттуда, где кадры воспроизводятся, и перестанет получаться в тот день, когда обновятся шрифты. Остальное следует из того же правила: намерение объявляется --expect-change, необъявленное расхождение останавливает приёмку, объявленное без расхождения — тоже (ложная декларация обесценивает список), заменяются ровно объявленные файлы, а тотальная перерисовка требует --no-witnesses --reason, и причина уезжает в манифест. Частый случай закрылся сам: ничего не объявлено, все кадры совпали — принимается один манифест, руками ничего писать не надо. Проверено шестью сквозными прогонами на подделанном артефакте, не только юнитами: идентичный кандидат, необъявленное расхождение, объявленное, молчаливая декларация, тотальная перерисовка без причины и с ней. Issue: #401 User-Visible: no
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Правило допустимости съёмки скриншотов документации (issue #401).
|
||||
*
|
||||
* До этого правило было про МЕСТО: снимать только в CI, потому что растеризация
|
||||
* шрифтов на другой машине отличается. Обоснование измерено — пересъёмка в #231
|
||||
* изменила два файла из девяти на 7–8 байт, а набор с беты все девять. Но
|
||||
* держалось оно на комментарии в шапке `docs-accept.mjs`, а не на механизме:
|
||||
* кандидат проверялся на самосогласованность и принимался целиком, без единого
|
||||
* сравнения с тем, что лежит в репозитории.
|
||||
*
|
||||
* Цена такого правила видна на #390. Правка типов, которая физически не может
|
||||
* сдвинуть пиксель, потребовала прогона workflow, а затем правки одиннадцати
|
||||
* полей манифеста руками. Гейт, заставляющий писать хеши руками, работает
|
||||
* против себя.
|
||||
*
|
||||
* Здесь правило заменено на проверяемое, и оно ровно то же, что у golden с
|
||||
* #334: среда съёмки доказана, если КАЖДЫЙ кадр, который менять не собирались,
|
||||
* совпал с закоммиченным БАЙТ-В-БАЙТ. Расхождение растеризации спрятать нельзя
|
||||
* — оно задевает все кадры с текстом, а не только правленные. Поэтому автор
|
||||
* объявляет намерение списком `--expect-change`, и всё, что разошлось помимо
|
||||
* списка, приёмку запрещает: либо это незамеченная регрессия рендера, либо
|
||||
* среда не та.
|
||||
*
|
||||
* То же правило ловит второе, независимо от среды: «принять всё, чтобы гейт
|
||||
* позеленел». Так скриншот документации перестаёт показывать продукт — молча,
|
||||
* одной командой, без единого названного намерения.
|
||||
*
|
||||
* Отличие от golden только в строгости, и оно в пользу скриншотов: сцен десять,
|
||||
* а не сто сорок три, и сравнение точное, без порога. Поэтому «совпал» здесь
|
||||
* значит буквально совпал, а не «в пределах допуска».
|
||||
*/
|
||||
|
||||
/**
|
||||
* Порог свидетелей. Формула та же, что у golden (`goldenWitnessFloor`), и это
|
||||
* намеренно: два набора картинок в одном репозитории не должны требовать от
|
||||
* человека помнить два разных правила.
|
||||
*
|
||||
* Для десяти сцен порог равен одной. Этого достаточно, потому что основную
|
||||
* работу делает не порог, а требование «все необъявленные совпали»: расхождение
|
||||
* среды не бывает точечным. Порог закрывает единственную оставшуюся щель —
|
||||
* попытку объявить изменёнными все кадры разом, когда свидетелей не остаётся
|
||||
* вовсе и подтвердить среду становится нечем.
|
||||
*/
|
||||
export const docsWitnessFloor = (committedCount) => (committedCount > 0
|
||||
? Math.min(10, Math.ceil(committedCount * 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; либо съёмка велась в другой среде, и тогда её кадры'
|
||||
+ ' принимать нельзя: растеризация задевает все кадры с текстом сразу' };
|
||||
}
|
||||
|
||||
const withCommitted = ids.filter((id) => committed[id]);
|
||||
const floor = docsWitnessFloor(withCommitted.length);
|
||||
const witnesses = withCommitted.filter((id) => !announced.has(id)
|
||||
&& committed[id] === candidate[id]);
|
||||
if (!skipWitnesses && witnesses.length < floor) {
|
||||
return { ...empty, refusal: 'кадров-свидетелей недостаточно:'
|
||||
+ ` ${witnesses.length} из необходимых ${floor}.`
|
||||
+ ' Свидетель — необъявленный кадр, совпавший с закоммиченным байт-в-байт;'
|
||||
+ ' только он доказывает, что среда съёмки та же. Если перерисовка'
|
||||
+ ' действительно тотальная и осознанная — --no-witnesses --reason="…"'
|
||||
+ ' оставит причину в манифесте' };
|
||||
}
|
||||
|
||||
const replace = ids.filter((id) => announced.has(id));
|
||||
return {
|
||||
refusal: null,
|
||||
replace,
|
||||
keep: ids.filter((id) => !announced.has(id)),
|
||||
witnesses,
|
||||
floor,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user