mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
Lit reuses list nodes by position. The opening list and the device markers had no keys, so on a space switch the leaf that held a slot kept its DOM node and only changed values — and `.op-leaf` (transform) and `.op-arc` (stroke-dashoffset) carry a 0.6 s transition, so the browser animated a door that never moved: the new floor's leaf drove in from the previous floor's opening angle. Measured on two spaces with a door in the same place and opposite contact states: the node is reused, transform goes `rotate(-90deg) → rotate(0deg)`, dash offset `0 → 125.66`, both transitions `running`. The marker shell adds two more with its `box-shadow`. Both lists are now rendered through `repeat(…, (item) => item.id, …)`, the same lesson `glow-scene.ts` already learned for the Glow spots. The trap is written where it starts — above the two transitions in `plan.styles.ts` — because that is the file someone edits when adding the next animated property. `houseplan-card.ts` is at its line ceiling, and the note would have cost the budget a dozen lines for nothing: the swap itself is line-for-line. The witness walks the shadow tree per element. `document.getAnimations()` is empty here EVEN ON THE BROKEN CODE — the card lives in a shadow root and the document-level call does not reach into it, and the issue proposed exactly that call. The smoke also builds its own fixture: the demo home has no openings at all, so two doors in two spaces are prepared in the smoke, and it asserts the other half of the contract as well — a real contact change inside one space still animates the leaf. On `origin/dev` the smoke fails on five facts, naming the offenders: `op-arc:stroke-dashoffset`, `op-leaf:transform`, `device-shell-frame:box-shadow` twice. Mutants `openings-rendered-without-keys` and `device-markers-rendered-without-keys` put each `map` back. Perf, 7 samples against `19e421b3`: spaceSwitchMs 524.8 (limit 769.35, base 512.9), switchCycleMs 1293.9 (1696.28, 1256.5), firstStableRenderMs 2533.8 (3000, 2529.2), modelReadyMs 732.7 (944.97, 726.9), longTask.maxSingleMs 663 (910, 660) — `benchmark:compare` green in full. The initial View graph grows 241 B gzip: `repeat` enters it for the first time. The #438 ceiling is recentred 300 400 → 300 700 with the usual dated note; measured 300 059 B keeps 641 B above and 1 359 B below the band. The 301 066 B budget is untouched, but only 366 B now separate the ceiling from it — the #367 headroom debt has stopped being theoretical. Issue: #525 User-Visible: yes
168 lines
12 KiB
JavaScript
168 lines
12 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync, readdirSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
// #407. Счётчик исключений внутри карточки живёт в demo/serve.mjs, и до этой
|
||
// задачи его читала одна функция — finish(). Шесть смоков её не вызывали вовсе:
|
||
// у каждого была своя развязка, и ни одна не спрашивала про исключения. То есть
|
||
// необработанное исключение во время этих шести проходило незамеченным всегда —
|
||
// в лог печаталось `EXC`, а прогон оставался зелёным.
|
||
//
|
||
// Проверка статическая и грубая: она не доказывает, что вердикт вынесен
|
||
// правильно, а доказывает, что его вообще запрашивают. Этого достаточно, потому
|
||
// что пропуск здесь молчаливый — а молчаливый пропуск и есть то, что стоит
|
||
// ловить механизмом.
|
||
|
||
const DEMO = fileURLToPath(new URL('../demo/', import.meta.url));
|
||
const smokes = () => readdirSync(DEMO)
|
||
.filter((name) => name.startsWith('smoke_') && name.endsWith('.mjs'));
|
||
const read = (name) => readFileSync(new URL(name, `file://${DEMO}`), 'utf8');
|
||
const pageBenchmarkIsGuarded = (text) => (
|
||
/watchPage\(await [\s\S]*?\.newPage\(\)\)/.test(text)
|
||
&& /if \(await reportPageErrors\(\)\)\s*\{?[\s\S]*?process\.exit\(1\)/.test(text)
|
||
);
|
||
|
||
test('каждый смок спрашивает вердикт по исключениям в карточке (#407)', () => {
|
||
const silent = smokes().filter((name) => {
|
||
const text = read(name);
|
||
return !/\bfinish\s*\(/.test(text) && !/\breportPageErrors\s*\(/.test(text);
|
||
});
|
||
assert.deepEqual(silent, [],
|
||
'смок обязан либо вызвать finish(), либо спросить reportPageErrors():'
|
||
+ ' иначе исключение внутри карточки во время него не увидит никто,'
|
||
+ ' а `EXC` в логе прогон зелёным быть не мешает (#407)');
|
||
});
|
||
|
||
test('вердикт по исключениям запрашивается после прогона, а не до (#407)', () => {
|
||
// Вызов раньше самих действий бессмысленен: счётчик к тому моменту нулевой.
|
||
// Проверяется дешёвым признаком — вердикт не может быть первым обращением к
|
||
// странице в файле.
|
||
for (const name of smokes()) {
|
||
const text = read(name);
|
||
const verdict = Math.max(text.search(/\breportPageErrors\s*\(/), text.search(/\bawait finish\s*\(/));
|
||
if (verdict < 0) continue;
|
||
const firstAction = text.search(/\b(page|card)\.|\blaunch\s*\(/);
|
||
if (firstAction < 0) continue;
|
||
assert.ok(verdict > firstAction,
|
||
`${name}: вердикт по исключениям запрашивается прежде, чем что-то произошло`);
|
||
}
|
||
});
|
||
|
||
test('вердикт не только выставляет код, но и останавливает смок (#407)', () => {
|
||
// Первый заход этой задачи выставлял только process.exitCode, и отрицательный
|
||
// прогон напечатал «FAILED: 1 uncaught exception» и следом «OK deep-link: …».
|
||
// Код возврата был верным, а вывод — противоречивым; читают же вывод.
|
||
for (const name of smokes()) {
|
||
const text = read(name);
|
||
if (!/\breportPageErrors\s*\(/.test(text)) continue;
|
||
assert.match(text, /if \(await reportPageErrors\(\)\)\s*(process\.exit\(1\)|throw )/,
|
||
`${name}: вердикт по исключениям обязан останавливать смок, а не только`
|
||
+ ' помечать его — иначе после «FAILED» печатается строка успеха');
|
||
}
|
||
});
|
||
|
||
test('serve.mjs остаётся единственным владельцем счётчика (#407)', () => {
|
||
// Смок, который завёл бы свой счётчик и свою проверку, вернул бы ровно ту
|
||
// ситуацию, которую задача чинит: у smoke_entry_stale собственный pageErrors
|
||
// существует законно (он и есть предмет проверки), но вердикт всё равно
|
||
// выносит finish().
|
||
const serve = readFileSync(new URL('../demo/serve.mjs', import.meta.url), 'utf8');
|
||
assert.match(serve, /export async function reportPageErrors\(\)/,
|
||
'вердикт стал асинхронным в #404: он ждёт доставки событий страницей');
|
||
assert.equal((serve.match(/_pageErrors\+\+/g) || []).length, 1,
|
||
'счётчик инкрементируется в одном месте — внутри watchPage (#404)');
|
||
const entryStale = read('smoke_entry_stale.mjs');
|
||
assert.match(entryStale, /await finish\(/, 'smoke_entry_stale обязан выносить вердикт');
|
||
});
|
||
|
||
test('#421 dedicated guard probe reaches reportPageErrors without finish', () => {
|
||
const probe = readFileSync(
|
||
new URL('../demo/guard/guard_report_page_errors.mjs', import.meta.url), 'utf8',
|
||
);
|
||
assert.match(probe, /await reportPageErrors\(\)/,
|
||
'отрицательная проба обязана пройти через отдельный verdict path');
|
||
assert.doesNotMatch(probe, /\bawait\s+finish\s*\(/,
|
||
'finish() замаскирует сломанный round-trip внутри reportPageErrors()');
|
||
});
|
||
|
||
test('#423 каждый page-benchmark обязан иметь вердикт и динамическую пробу (#430)', () => {
|
||
// Что здесь проверяется, а что — нет.
|
||
//
|
||
// Этот тест — ОБНАРУЖЕНИЕ: он находит benchmark, открывающий страницу
|
||
// Playwright, и требует от него формы гарда и наличия отрицательной пробы.
|
||
// Поведение гарда в рантайме он не доказывает и не может: браузера в job
|
||
// «Фронтенд» нет. Доказывает запуск — `demo/guard/verify-guard.mjs`.
|
||
//
|
||
// До #430 здесь стояли две «отрицательные проверки»: результат
|
||
// `pageBenchmarkIsGuarded(source.replace('watchPage(', '('))` обязан быть
|
||
// false. Но сама функция буквально ищет подстроку `watchPage(` — вырезав её,
|
||
// мы спрашивали регулярку, находит ли она то, что мы только что удалили.
|
||
// Доказано было, что регулярка не пуста. Ровно тот вид проверки, против
|
||
// которого заведён мутационный гейт, и он же — единственная находка аудита
|
||
// v1.71.0-beta.1, где тест был циклическим, а не просто слабым.
|
||
const benchmarks = readdirSync(DEMO)
|
||
.filter((name) => name.startsWith('benchmark_') && name.endsWith('.mjs'))
|
||
.filter((name) => /\.newPage\(/.test(read(name)));
|
||
assert.deepEqual(benchmarks, ['benchmark_backdrop_decode.mjs']);
|
||
const verifier = readFileSync(
|
||
new URL('../demo/guard/verify-guard.mjs', import.meta.url), 'utf8',
|
||
);
|
||
for (const name of benchmarks) {
|
||
const source = read(name);
|
||
assert.equal(pageBenchmarkIsGuarded(source), true, `${name}: missing pageerror guard`);
|
||
assert.match(source, /process\.argv\.includes\('--guard-probe'\)/,
|
||
`${name}: нужен режим отрицательной пробы — без него гард не проверить запуском`);
|
||
assert.match(source, /setTimeout\(\(\) => \{ throw new Error\(/,
|
||
`${name}: проба обязана бросать исключение ВНУТРИ страницы, в хвосте замера`);
|
||
assert.ok(
|
||
new RegExp(`file: '\\.\\./${name}',\\s*\n\\s*args: \\['--guard-probe'\\]`).test(verifier),
|
||
`${name}: нет записи в demo/guard/verify-guard.mjs — проба существует и не вызывается,`
|
||
+ ' а это ровно то состояние, в котором #423 прожил до #430',
|
||
);
|
||
}
|
||
});
|
||
|
||
// #521. Свидетель направляющих выравнивания был зелёным на сломанном коде,
|
||
// потому что фабриковал состояние жеста: `c._deviceDrag = {…, moved: true}` и
|
||
// `c._decorDraft = {…}` присваивались целиком. Присвоение с `oldValue == null`
|
||
// живой путь не включает, поэтому проверялась осевая отрисовка — то есть путь,
|
||
// которого при настоящем жесте не бывает. Проверка статическая и узкая: она
|
||
// стоит на одном файле и на двух именах, зато закрывает ровно тот способ
|
||
// обмануть её, который однажды уже сработал.
|
||
test('свидетель направляющих ведёт жест, а не подставляет его состояние (#521)', () => {
|
||
const source = read('smoke_align_guides.mjs');
|
||
for (const state of ['_deviceDrag', '_decorDraft']) {
|
||
assert.doesNotMatch(source, new RegExp(`\\b${state}\\s*=[^=]`),
|
||
`smoke_align_guides.mjs: ${state} присваивается напрямую — жест обязан`
|
||
+ ' создавать его сам, иначе смок снова проверит осевой путь (#521)');
|
||
}
|
||
assert.match(source, /dispatchEvent\(pe\('pointerdown'/,
|
||
'жест начинается настоящим pointerdown по элементу');
|
||
assert.match(source, /dispatchEvent\(pe\('pointermove'/,
|
||
'жест продолжается настоящими pointermove');
|
||
// Тишина перед жестом и нулевой счёт осевых кадров — часть свидетеля, а не
|
||
// украшение: без них сломанный код зеленеет на кадрах оседания `_hdrH`.
|
||
assert.match(source, /const quiet = async \(\) =>/);
|
||
assert.match(source, /ZeroSettledRenders/);
|
||
});
|
||
|
||
// #525. Карточка живёт в shadow root, и `document.getAnimations()` в неё не
|
||
// заходит: на сломанном коде документный вызов возвращал пустой список, пока
|
||
// створка двери честно доигрывала чужой переход. Свидетель обязан считать
|
||
// поэлементно, обходя вложенные теневые деревья, — иначе он зелен всегда.
|
||
test('свидетель переходов при переключении меряет поэлементно (#525)', () => {
|
||
const source = read('smoke_space_switch_transitions.mjs');
|
||
// Ловушка названа в шапке смока словами — упоминание в комментарии это не
|
||
// вызов; проверяется код, а не текст о нём.
|
||
const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||
assert.doesNotMatch(code, /document\.getAnimations\(\)/,
|
||
'smoke_space_switch_transitions.mjs: документный getAnimations() пуст даже на'
|
||
+ ' сломанном коде — считать надо по узлам внутри renderRoot (#525)');
|
||
assert.match(code, /element\.getAnimations\(\)/);
|
||
assert.match(code, /element\.shadowRoot\) walk\(element\.shadowRoot\)/,
|
||
'обход обязан заходить во вложенные shadow root');
|
||
assert.match(code, /_pickSpace/, 'переключение делается штатным путём');
|
||
});
|
||
|