mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: the witness floor comes from the matrix, not from surviving baselines
Порог свидетелей считался от числа сцен со статусом не 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
This commit is contained in:
@@ -63,6 +63,9 @@ const previous = existsSync(manifestPath)
|
|||||||
// байт-в-байт, доказывают, что среда съёмки та же, что у принятого эталона.
|
// байт-в-байт, доказывают, что среда съёмки та же, что у принятого эталона.
|
||||||
const witnessCheck = goldenWitnessRefusal({
|
const witnessCheck = goldenWitnessRefusal({
|
||||||
results: report.results,
|
results: report.results,
|
||||||
|
// #408: от размера матрицы, а не от числа уцелевших эталонов — иначе порог
|
||||||
|
// обходится удалением каталога эталонов.
|
||||||
|
sceneCount: GOLDEN_SCENARIOS.length,
|
||||||
declared,
|
declared,
|
||||||
declaredNew,
|
declaredNew,
|
||||||
previousHashes: previous,
|
previousHashes: previous,
|
||||||
|
|||||||
@@ -155,21 +155,36 @@ export const goldenAcceptancePlan = ({
|
|||||||
*
|
*
|
||||||
* Свидетель — необъявленная сцена, чей кандидат совпал с принятым эталоном
|
* Свидетель — необъявленная сцена, чей кандидат совпал с принятым эталоном
|
||||||
* БАЙТ-В-БАЙТ: `passed` означает лишь «в пределах порога» (#351), а среду
|
* БАЙТ-В-БАЙТ: `passed` означает лишь «в пределах порога» (#351), а среду
|
||||||
* доказывает только точное совпадение. Floor — 10 свидетелей или 10% сцен с
|
* доказывает только точное совпадение. Floor — 10 свидетелей или 10% сцен
|
||||||
* эталонами, что меньше; матрица без эталонов (первичная съёмка) свидетелей
|
* МАТРИЦЫ, что меньше.
|
||||||
* требовать не может — там каждый кадр и так объявляется в `--expect-new`.
|
|
||||||
*
|
*
|
||||||
* Осознанный обход для действительно тотальных перерисовок — `--no-witnesses`
|
* Именно матрицы, а не уцелевших на диске эталонов (#408). Прежняя редакция
|
||||||
* с обязательной причиной: она уезжает в манифест эталонов, то есть в артефакт
|
* считала floor от числа сцен со статусом не `missing-baseline`, и у неё был
|
||||||
* и его git-историю, а не только в историю shell.
|
* обход в одну команду: `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);
|
: 0);
|
||||||
|
|
||||||
export const goldenWitnessRefusal = ({
|
export const goldenWitnessRefusal = ({
|
||||||
results, declared = [], declaredNew = [], previousHashes = {},
|
results, declared = [], declaredNew = [], previousHashes = {},
|
||||||
skipWitnesses = false, skipReason = '',
|
skipWitnesses = false, skipReason = '', sceneCount,
|
||||||
}) => {
|
}) => {
|
||||||
if (skipWitnesses) {
|
if (skipWitnesses) {
|
||||||
if (typeof skipReason !== 'string' || !skipReason.trim()) {
|
if (typeof skipReason !== 'string' || !skipReason.trim()) {
|
||||||
@@ -178,9 +193,24 @@ export const goldenWitnessRefusal = ({
|
|||||||
}
|
}
|
||||||
return { refusal: null, witnesses: [], floor: 0 };
|
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 accepted = new Set([...declared, ...declaredNew].filter(Boolean));
|
||||||
|
// Свидетелем может быть только сцена с эталоном: сравнивать не с чем. Но
|
||||||
|
// ПОРОГ от этого не зависит — иначе удаление эталонов снижало бы планку.
|
||||||
const withBaseline = results.filter((result) => result.status !== 'missing-baseline');
|
const withBaseline = results.filter((result) => result.status !== 'missing-baseline');
|
||||||
const floor = goldenWitnessFloor(withBaseline.length);
|
const floor = goldenWitnessFloor(sceneCount);
|
||||||
const witnesses = withBaseline
|
const witnesses = withBaseline
|
||||||
.filter((result) => !accepted.has(result.id)
|
.filter((result) => !accepted.has(result.id)
|
||||||
&& result.status === 'passed'
|
&& result.status === 'passed'
|
||||||
@@ -192,7 +222,8 @@ export const goldenWitnessRefusal = ({
|
|||||||
return {
|
return {
|
||||||
refusal: 'сцен-свидетелей среды недостаточно:'
|
refusal: 'сцен-свидетелей среды недостаточно:'
|
||||||
+ ` ${witnesses.length} из необходимых ${floor}`
|
+ ` ${witnesses.length} из необходимых ${floor}`
|
||||||
+ ` (эталонных сцен ${withBaseline.length}, объявлено ${accepted.size}).`
|
+ ` (сцен в матрице ${sceneCount}, с эталонами ${withBaseline.length},`
|
||||||
|
+ ` объявлено ${accepted.size}).`
|
||||||
+ ' Свидетель — необъявленная сцена, совпавшая с эталоном байт-в-байт;'
|
+ ' Свидетель — необъявленная сцена, совпавшая с эталоном байт-в-байт;'
|
||||||
+ ' именно они доказывают, что среда съёмки та же, что у принятого'
|
+ ' именно они доказывают, что среда съёмки та же, что у принятого'
|
||||||
+ ' эталона. Если перерисовка действительно тотальная и осознанная —'
|
+ ' эталона. Если перерисовка действительно тотальная и осознанная —'
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ test('приёмка без свидетелей отказывает и наз
|
|||||||
}));
|
}));
|
||||||
const { refusal, floor } = goldenWitnessRefusal({
|
const { refusal, floor } = goldenWitnessRefusal({
|
||||||
results: all,
|
results: all,
|
||||||
|
sceneCount: all.length,
|
||||||
declared: all.map((result) => result.id),
|
declared: all.map((result) => result.id),
|
||||||
previousHashes: Object.fromEntries(all.map((result) => [result.id, `old-${result.id}`])),
|
previousHashes: Object.fromEntries(all.map((result) => [result.id, `old-${result.id}`])),
|
||||||
});
|
});
|
||||||
@@ -208,7 +209,7 @@ test('обычная приёмка с достаточным числом св
|
|||||||
.map((scene) => [scene.id, scene.actualSha256]),
|
.map((scene) => [scene.id, scene.actualSha256]),
|
||||||
);
|
);
|
||||||
const { refusal, witnesses, floor } = goldenWitnessRefusal({
|
const { refusal, witnesses, floor } = goldenWitnessRefusal({
|
||||||
results: scenes, declared: ['edited'], previousHashes,
|
results: scenes, sceneCount: scenes.length, declared: ['edited'], previousHashes,
|
||||||
});
|
});
|
||||||
assert.equal(refusal, null);
|
assert.equal(refusal, null);
|
||||||
assert.equal(floor, 4, '10% от 31 эталонной сцены');
|
assert.equal(floor, 4, '10% от 31 эталонной сцены');
|
||||||
@@ -224,7 +225,7 @@ test('passed в пределах порога — не свидетель: ср
|
|||||||
scenes.map((scene) => [scene.id, `baseline-${scene.id}`]),
|
scenes.map((scene) => [scene.id, `baseline-${scene.id}`]),
|
||||||
);
|
);
|
||||||
const { refusal, witnesses } = goldenWitnessRefusal({
|
const { refusal, witnesses } = goldenWitnessRefusal({
|
||||||
results: scenes, declared: [], previousHashes,
|
results: scenes, sceneCount: scenes.length, declared: [], previousHashes,
|
||||||
});
|
});
|
||||||
assert.equal(witnesses.length, 0, 'подпороговый дрейф не доказывает среду');
|
assert.equal(witnesses.length, 0, 'подпороговый дрейф не доказывает среду');
|
||||||
assert.match(refusal, /0 из необходимых 1/);
|
assert.match(refusal, /0 из необходимых 1/);
|
||||||
@@ -240,12 +241,71 @@ test('--no-witnesses требует причину и с ней пропуска
|
|||||||
assert.equal(reasoned.refusal, null);
|
assert.equal(reasoned.refusal, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('первичная съёмка без единого эталона свидетелей не требует (#355)', async () => {
|
test('первичная съёмка требует названной причины, а не молчания (#408)', async () => {
|
||||||
const { goldenWitnessRefusal, goldenWitnessFloor } = await import('../scripts/golden-acceptance.mjs');
|
const { goldenWitnessRefusal, goldenWitnessFloor } = await import('../scripts/golden-acceptance.mjs');
|
||||||
assert.equal(goldenWitnessFloor(0), 0);
|
// До #408 здесь стоял отказ от требования: матрица без эталонов свидетелей
|
||||||
const { refusal } = goldenWitnessRefusal({
|
// иметь не может, значит и floor ноль. Верно по факту, неверно по выводу —
|
||||||
results: [{ id: 'first', status: 'missing-baseline', actualSha256: 'x' }],
|
// именно этим ноль и обходился: `git rm baselines/*.png` превращал любую
|
||||||
declaredNew: ['first'],
|
// приёмку в первичную съёмку.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user