test(harness): close three smoke-select and fixture blind spots (#754)

Three independent blind spots in the test harness.

1. smoke-select read symbols only from changed lines of a --unified=0
   diff. An edit to the arguments of a multi-line call names nothing:
   #741 (d5bdfde9) changed only the arguments of
   runtime.resolveIsoOverlayFitEnvelope({ on the line above, and the
   selection answered "unproven" plus the visual minimum, although the
   callee is registered in smoke-links for smoke_iso_flat_parity and
   smoke_isometric_contract - the two smokes the #741 author ran by hand.
   The selection diff now carries CALL_CONTEXT_LINES = 3 lines of
   context; for each changed line parseDiff looks for the nearest
   unclosed "(" above it within the hunk, walking through a literal
   argument ({ or [ after "(", "," or "["), stopping at ";" on depth zero
   or any other unclosed brace. A callee from the symbol table joins
   symbols and the new callees field and is marked "(вызов)" in the
   report. Context lines never give direct symbols. task-packet takes a
   separate context diff for selectSmokes; change-risk keeps --unified=0.
   Over the last 80 src commits of dev: 16 commits gain a callee, 2 move
   from unproven to a proven link (#741, #724 5f8e8ca7), +15 smokes in
   total, at most 4 per commit, none lost.

2. The #732 dead-field check judged only scene-builder calls. The four
   resolveIsoOverlayFitEnvelope({...}) literals in iso-scene-render tests
   went straight into the test-build function, so stageSize: null (the
   field #741 removed) stayed green. They now go through overlayFit typed
   with OverlayFitFixture (keys of IsoOverlayFitEnvelopeInput); the check
   judges overlayFit/resolveIsoOverlayFitEnvelope calls like the scene
   builders, and its probe asserts that OverlayFitFixture rejects
   stageSize, so the type resolved to the real input and not to any.

3. smoke_backdrop's mode() called the private _setMode and slept 220 ms.
   It now enters a mode through __hpTest.setMode and waits for the end of
   the transition by the same markers as section 6b (#715): one page
   helper used by both. Oracles and the 59 check names are unchanged.

Witnesses: d5bdfde9 selects both iso smokes with no "unproven"; the same
fixture without context lines is unproven again; attribution disabled
reds both AC1 units. stageSize: null in an overlayFit call reds the first
#732 test; a direct resolveIsoOverlayFitEnvelope({...}) reds the third.
smoke_backdrop is green normally and with animation frames slowed to 60
and 150 ms; a stage animation that never ends fails with a named error.

Issue: #754
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-01 12:24:59 +00:00
committed by claude[bot]
parent da8edb1f0e
commit d8e09cd1a0
8 changed files with 387 additions and 63 deletions
+110
View File
@@ -0,0 +1,110 @@
commit d5bdfde9196bc7ecf7a4cfefea04f4dd69559b0d
Author: Claude <noreply@anthropic.com>
Date: Thu Oct 1 08:25:09 2026 +0300
refactor(iso): drop the unused stageSize from the overlay fit input (#741)
Since #713 the overlay fit envelope reserves no nudge budget, and since
#725 _isoScene passes `stageSize: null` while resolveIsoOverlayFitEnvelope
never reads the field. The room focus still built a { width, height }
object from the stage for nothing. The optional field is removed from
IsoOverlayFitEnvelopeInput together with both call-site arguments.
The #725 AC3 unit compared bounds with stageSize null and 1000x500, which
is now meaningless; it checks instead that the fit bounds follow only
scene.frame and the tiles: the same bounds for every stage aspect, a moved
frame moves them, an enclosing frame is returned as is.
Issue: #741
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index b1421819..4ccce6a5 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -6004,7 +6004,7 @@ export class HouseplanCard extends LitElement {
disp, runtime.resolveIsoDecorationLayers(disp), scene, iconPct, effectiveDeviceBaseSize(iconPct),
disp.showLqi ?? this._config?.show_signal ?? true);
const envelope = overlays && runtime.resolveIsoOverlayFitEnvelope({
- baseBounds: scene.frame, entries: overlays.entries, stageSize: null, targetView: (bounds) =>
+ baseBounds: scene.frame, entries: overlays.entries, targetView: (bounds) =>
fitView([bounds.x, bounds.y, bounds.w, bounds.h], aspect) });
return envelope ? { ...scene, frame: envelope.bounds, overlayFitEntries: overlays.entries } : scene;
}
@@ -6192,8 +6192,7 @@ export class HouseplanCard extends LitElement {
const runtime = this._isoSceneRuntime, entries = this._renderIsoScene?.overlayFitEntries;
if (target && this._effectiveProjection() === 'iso' && runtime && entries?.length) {
const envelope = runtime.resolveIsoOverlayFitEnvelope({
- baseBounds: bounds, entries, ownerId: room.id, stageSize: {
- width: stage.clientWidth, height: stage.clientHeight },
+ baseBounds: bounds, entries, ownerId: room.id,
targetView: (fitBounds) => targetOf(fitBounds)?.viewBox ?? null });
if (envelope) { bounds = envelope.bounds; target = targetOf(bounds); }
}
diff --git a/src/iso-scene-render.ts b/src/iso-scene-render.ts
index cf61222b..a617fbb6 100644
--- a/src/iso-scene-render.ts
+++ b/src/iso-scene-render.ts
@@ -648,7 +648,6 @@ export function isoOverlaySceneBounds(
export interface IsoOverlayFitEnvelopeInput {
baseBounds: Rect;
entries: readonly IsoOverlayRenderEntry[];
- stageSize?: { width: number; height: number } | null;
targetView(bounds: Rect): Rect | null;
ownerId?: string;
}
diff --git a/test/iso-scene-render.test.mjs b/test/iso-scene-render.test.mjs
index e514aa8f..eecd468f 100644
--- a/test/iso-scene-render.test.mjs
+++ b/test/iso-scene-render.test.mjs
@@ -195,7 +195,7 @@ test('#713 K8: overlay fit is the structure plus visible tiles, no #651 nudge re
assert.deepEqual(otherRoom.bounds, { x: 0, y: 0, w: 100, h: 100 });
});
-test('#725 AC3: the overlay fit frame does not depend on the stage aspect or size', () => {
+test('#725 AC3/#741: the overlay fit bounds depend only on scene.frame and the tiles, never on the stage', () => {
/** @type {OverlayEntryFixture[]} */
const entries = [
{
@@ -213,25 +213,25 @@ test('#725 AC3: the overlay fit frame does not depend on the stage aspect or siz
},
},
];
- const baseBounds = { x: 0, y: 0, w: 100, h: 100 };
+ const frame = { x: 0, y: 0, w: 100, h: 100 };
// The card's targetView: a contain-fit of the frame at the stage aspect.
const fitAt = (aspect) => (bounds) => fitView([bounds.x, bounds.y, bounds.w, bounds.h], aspect);
- const results = [];
- for (const aspect of [0.5, 2]) {
- for (const stageSize of [null, { width: 1000, height: 500 }]) {
- const fitted = resolveIsoOverlayFitEnvelope({ baseBounds, entries, stageSize, targetView: fitAt(aspect) });
- assert.ok(fitted, `aspect ${aspect}, stage ${JSON.stringify(stageSize)}: an envelope`);
- results.push(fitted);
- }
- }
+ const fit = (baseBounds, aspect) => resolveIsoOverlayFitEnvelope({ baseBounds, entries, targetView: fitAt(aspect) });
+ // Portrait, square, a 1000×500 and a 320×180 stage, the frame's own aspect (`_isoScene`).
+ const aspects = [0.5, 1, 1000 / 500, 320 / 180, frame.w / frame.h];
+ const results = aspects.map((aspect) => {
+ const fitted = fit(frame, aspect);
+ assert.ok(fitted, `aspect ${aspect}: an envelope`);
+ assert.deepEqual(fitted.view, fitAt(aspect)(fitted.bounds), `aspect ${aspect}: the view fits the bounds`);
+ return fitted;
+ });
assert.deepEqual(results[0].bounds, { x: 0, y: 0, w: 105, h: 110 }, 'the structure plus both tiles');
for (const fitted of results) assert.deepEqual(fitted.bounds, results[0].bounds);
assert.notDeepEqual(results[0].view, results[2].view, 'only the view follows the aspect');
- // `_isoScene` passes the frame's own aspect: any positive finite value gives the same bounds.
- const ownAspect = resolveIsoOverlayFitEnvelope({
- baseBounds, entries, stageSize: null, targetView: fitAt(baseBounds.w / baseBounds.h),
- });
- assert.deepEqual(ownAspect.bounds, results[0].bounds);
+ // The bounds follow scene.frame: a moved frame moves them, an enclosing frame is the answer as is.
+ assert.deepEqual(fit({ x: 50, y: 0, w: 100, h: 100 }, 2).bounds, { x: 34, y: 0, w: 116, h: 110 });
+ const enclosing = { x: -50, y: -50, w: 300, h: 300 };
+ for (const aspect of aspects) assert.deepEqual(fit(enclosing, aspect).bounds, enclosing);
});
test('one painter queue paints a nearer wall after an unrelated rear opening', () => {
+37 -11
View File
@@ -16,6 +16,11 @@ import ts from 'typescript';
// тесту, но поле, которого у типа нет, — ошибка «лишнее свойство». Прочие
// диагностики этих файлов не судятся: сами тесты исполняют `test-build`, и
// частичность значений — их право.
//
// #754: так же судится вход окна оверлеев `resolveIsoOverlayFitEnvelope` —
// тип `OverlayFitFixture`, хелпер `overlayFit`. До этого литерал шёл прямо в
// функцию из `test-build`, и возвращённый `stageSize: null` (поле удалила #741)
// оставлял проверку зелёной.
const ROOT = fileURLToPath(new URL('../', import.meta.url));
const TEST_DIR = join(ROOT, 'test');
@@ -26,7 +31,15 @@ const EXCESS_PROPERTY = new Set([2353, 2561]);
/** Входы сцены и поле записи, которых у боевых типов нет (#714, #724, #732). */
const DEAD_SCENE_FIELDS = ['view', 'referenceView', 'stageSize', 'layers', 'selectedDeviceId'];
const DEAD_ENTRY_FIELDS = ['groundRadius'];
const SCENE_BUILDERS = new Set(['buildIsoOverlayRenderScene', 'overlayScene']);
/** Поле входа окна оверлеев, которого у боевого типа нет (#741). */
const DEAD_FIT_FIELDS = ['stageSize'];
/** Вызов → тип, которым обязан проверяться его аргумент-фикстура. */
const CHECKED_CALLS = new Map([
['buildIsoOverlayRenderScene', 'OverlaySceneFixture'],
['overlayScene', 'OverlaySceneFixture'],
['resolveIsoOverlayFitEnvelope', 'OverlayFitFixture'],
['overlayFit', 'OverlayFitFixture'],
]);
const tagOf = (file) => basename(file).replace(/\W/g, '_');
/** Зонд: каждое мёртвое поле — отдельный литерал (TypeScript называет одно лишнее поле на литерал). */
@@ -42,6 +55,9 @@ function probeSource() {
const entry = "import('./iso-scene-render.test.mjs').OverlayEntryFixture";
typed(entry, 'entry_known', 'id: null, placement: null, screenHalfSize: null');
DEAD_ENTRY_FIELDS.forEach((field, index) => typed(entry, `entry${index}`, `${field}: null`));
const fit = "import('./iso-scene-render.test.mjs').OverlayFitFixture";
typed(fit, 'fit_known', 'baseBounds: null, entries: null, targetView: null, ownerId: null');
DEAD_FIT_FIELDS.forEach((field, index) => typed(fit, `fit${index}`, `${field}: null`));
return `${lines.join('\n')}\n`;
}
@@ -107,24 +123,32 @@ test('#732 AC2: тип фикстуры — ключи боевого входа
DEAD_ENTRY_FIELDS.forEach((field, index) => {
assert.match(rejected.get(`entry${index}`) || '', new RegExp(`'${field}' does not exist in type`));
});
// #754: вход окна оверлеев — тот же приём; размер сцены у него не вход с #741.
assert.ok(!rejected.has('fit_known'), 'поля входа окна оверлеев принимаются');
DEAD_FIT_FIELDS.forEach((field, index) => {
assert.match(rejected.get(`fit${index}`) || '', new RegExp(`'${field}' does not exist in type 'OverlayFitFixture'`),
`OverlayFitFixture отвергает ${field} — тип разрешился в боевой вход, а не в any`);
});
});
test('#732 AC2: каждая фикстура сцены доходит до построителя через проверяемый тип', () => {
test('#732 AC2: каждая фикстура сцены и окна оверлеев доходит до своей функции через проверяемый тип', () => {
const aliasOf = (type) => type?.aliasSymbol?.name ?? null;
const unchecked = [];
let calls = 0;
const calls = new Map();
for (const file of FIXTURE_FILES) {
const source = program.getSourceFile(file);
const visit = (node) => {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && SCENE_BUILDERS.has(node.expression.text)) {
calls += 1;
const expected = ts.isCallExpression(node) && ts.isIdentifier(node.expression)
&& CHECKED_CALLS.get(node.expression.text);
if (expected) {
calls.set(expected, (calls.get(expected) || 0) + 1);
const [arg] = node.arguments;
// Литерал проверяется типом параметра (`overlayScene`), прочее — своим
// объявленным типом; литерал прямо в построитель из test-build не
// проверяется ничем.
// Литерал проверяется типом параметра (`overlayScene`, `overlayFit`),
// прочее — своим объявленным типом; литерал прямо в функцию из
// test-build не проверяется ничем.
const type = arg && ts.isObjectLiteralExpression(arg)
? checker.getContextualType(arg) : arg && checker.getTypeAtLocation(arg);
if (aliasOf(type) !== 'OverlaySceneFixture') {
if (aliasOf(type) !== expected) {
const { line } = source.getLineAndCharacterOfPosition(node.getStart());
unchecked.push(`${file.slice(ROOT.length)}:${line + 1} ${node.getText().slice(0, 80)}`);
}
@@ -133,6 +157,8 @@ test('#732 AC2: каждая фикстура сцены доходит до п
};
visit(source);
}
assert.ok(calls >= 20, `нашлись вызовы построителя сцены (${calls})`);
assert.deepEqual(unchecked, [], 'фикстура сцены — литерал в overlayScene или объявление типа OverlaySceneFixture');
assert.ok(calls.get('OverlaySceneFixture') >= 20, `нашлись вызовы построителя сцены (${calls.get('OverlaySceneFixture')})`);
assert.ok(calls.get('OverlayFitFixture') >= 4, `нашлись вызовы окна оверлеев (${calls.get('OverlayFitFixture')})`);
assert.deepEqual(unchecked, [], 'фикстура сцены — литерал в overlayScene или объявление OverlaySceneFixture, '
+ 'фикстура окна оверлеев — литерал в overlayFit или объявление OverlayFitFixture');
});
+10 -5
View File
@@ -50,14 +50,19 @@ const wallRect = (x0, y0, x1, y1) => [[[[x0, y0], [x1, y0], [x1, y1], [x0, y1]]]
* typechecks this file: a field nothing reads — a zoom view, a stage size,
* decoration layers, a selection, a ground radius — fails there instead of
* pretending to be an input. Every scene fixture reaches the builder through
* `overlayScene` or a declaration of this type.
* `overlayScene` or a declaration of this type. #754: the overlay fit input
* goes the same way — every `resolveIsoOverlayFitEnvelope` fixture through
* `overlayFit` (`OverlayFitFixture`); #741 removed its `stageSize`.
*
* @typedef {{ [K in keyof import('../src/iso-scene-render.js').IsoOverlaySceneInput]?: unknown }} OverlaySceneFixture
* @typedef {{ [K in keyof import('../src/iso-scene-render.js').IsoOverlayRenderEntry]?: unknown }} OverlayEntryFixture
* @typedef {{ [K in keyof import('../src/iso-scene-render.js').IsoOverlayFitEnvelopeInput]?: unknown }} OverlayFitFixture
*/
/** @param {OverlaySceneFixture} input */
const overlayScene = (input) => buildIsoOverlayRenderScene(input);
/** @param {OverlayFitFixture} input */
const overlayFit = (input) => resolveIsoOverlayFitEnvelope(input);
const room = (id, x0, y0, x1, y1) => ({
id,
@@ -177,18 +182,18 @@ test('#713 K8: overlay fit is the structure plus visible tiles, no #651 nudge re
const w = bounds.h * aspect;
return { x: bounds.x - (w - bounds.w) / 2, y: bounds.y, w, h: bounds.h };
};
const fitted = resolveIsoOverlayFitEnvelope({
const fitted = overlayFit({
baseBounds: { x: 0, y: 0, w: 100, h: 100 }, entries: [entry], targetView,
});
assert.ok(fitted);
assert.deepEqual(fitted.bounds, { x: 0, y: 0, w: 105, h: 100 },
'the tile edge at x=105 is the only growth: no 48 CSS px reserve around it');
assert.deepEqual(fitted.view, targetView(fitted.bounds));
const repeated = resolveIsoOverlayFitEnvelope({
const repeated = overlayFit({
baseBounds: { x: 0, y: 0, w: 100, h: 100 }, entries: [entry], targetView,
});
assert.deepEqual(repeated, fitted, 'the canonical envelope is deterministic');
const otherRoom = resolveIsoOverlayFitEnvelope({
const otherRoom = overlayFit({
baseBounds: { x: 0, y: 0, w: 100, h: 100 }, entries: [entry],
targetView, ownerId: 'other-room',
});
@@ -216,7 +221,7 @@ test('#725 AC3/#741: the overlay fit bounds depend only on scene.frame and the t
const frame = { x: 0, y: 0, w: 100, h: 100 };
// The card's targetView: a contain-fit of the frame at the stage aspect.
const fitAt = (aspect) => (bounds) => fitView([bounds.x, bounds.y, bounds.w, bounds.h], aspect);
const fit = (baseBounds, aspect) => resolveIsoOverlayFitEnvelope({ baseBounds, entries, targetView: fitAt(aspect) });
const fit = (baseBounds, aspect) => overlayFit({ baseBounds, entries, targetView: fitAt(aspect) });
// Portrait, square, a 1000×500 and a 320×180 stage, the frame's own aspect (`_isoScene`).
const aspects = [0.5, 1, 1000 / 500, 320 / 180, frame.w / frame.h];
const results = aspects.map((aspect) => {
+85
View File
@@ -101,6 +101,91 @@ test('#690 п.1′: визуальный минимум — 5–8 существ
for (const smoke of VISUAL_MINIMUM) assert.ok(cli.stdout.includes(`demo/${smoke}`), smoke);
});
test('#754 AC1: правка аргументов многострочного вызова выбирает смоки вызываемой функции', () => {
// Дифф #741 с контекстом 3: обе изменённые строки карточки —
// аргументы `runtime.resolveIsoOverlayFitEnvelope({` строкой выше. Имя
// вызываемой есть только в контексте, а за ним в реестре стоят ровно те два
// смока, которые автор #741 гонял вручную.
const selection = selectSmokes(fixture('741-call-arguments.diff'), { root: repoRoot });
assert.deepEqual(selection.callees, ['resolveIsoOverlayFitEnvelope']);
assert.ok(selection.symbols.includes('resolveIsoOverlayFitEnvelope'));
const recommended = new Set([...strongNames(selection), ...selection.registered.map((entry) => entry.smoke)]);
for (const smoke of ['smoke_iso_flat_parity.mjs', 'smoke_isometric_contract.mjs']) {
assert.ok(recommended.has(smoke), `${smoke} не выбран по вызову resolveIsoOverlayFitEnvelope`);
}
assert.equal(selection.unproven, false);
assert.deepEqual(selection.visualMinimum, []);
const cli = spawnSync(process.execPath, ['scripts/smoke-select.mjs', '--diff', 'test/fixtures/smoke-select/741-call-arguments.diff'],
{ cwd: repoRoot, encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr);
assert.match(cli.stdout, /← resolveIsoOverlayFitEnvelope \(вызов\)/, 'символ по вызову назван как вызов');
});
test('#754 AC1: без строк контекста та же правка — неопределённость, как до #754', () => {
// Защита снята: тот же дифф без контекста (`--unified=0`, как выборка брала
// его раньше) не видит вызова, и ответ возвращается к визуальному минимуму.
const bare = fixture('741-call-arguments.diff').split('\n').filter((line) => !line.startsWith(' ')).join('\n');
const selection = selectSmokes(bare, { root: repoRoot });
assert.deepEqual(selection.callees, []);
assert.equal(selection.unproven, true);
assert.deepEqual(selection.visualMinimum, [...VISUAL_MINIMUM]);
});
test('#754 AC1: вызов ищется сквозь литерал-аргумент, но не за `;` и не из тела блока', () => {
const table = new Set(['resolveThing', 'otherThing']);
const hunk = (...lines) => parseDiff(['diff --git a/src/a.ts b/src/a.ts', '--- a/src/a.ts', '+++ b/src/a.ts',
'@@ -1,5 +1,5 @@', ...lines].join('\n'), table);
const argument = hunk(
" const v = host.resolveThing(first, 'a (b', {",
' keep: 1,',
'- drop: 2,',
'+ drop: 3,',
' });',
);
assert.deepEqual(argument.callees, ['resolveThing'], 'аргумент-литерал приписан вызову');
assert.deepEqual(argument.symbols, ['resolveThing']);
const nested = hunk(
' resolveThing([',
' [1,',
'- 2],',
'+ 3],',
' ]);',
);
assert.deepEqual(nested.callees, ['resolveThing'], 'массив в массиве-аргументе — тоже аргумент');
const closed = hunk(
' resolveThing({',
' a: 1,',
' });',
'- next = 1;',
'+ next = 2;',
);
assert.deepEqual(closed.callees, [], 'строка после закрытого `;` вызова — не его аргумент');
assert.deepEqual(closed.symbols, []);
const block = hunk(
' resolveThing(items, () => {',
'- count = 1;',
'+ count = 2;',
' });',
);
assert.deepEqual(block.callees, [], 'тело функции-аргумента — блок, а не аргумент');
const assigned = hunk(
' otherThing(a);',
' const options = {',
'- a: 1,',
'+ a: 2,',
' };',
);
assert.deepEqual(assigned.callees, [], 'литерал в присваивании — не аргумент вызова');
const named = hunk(
' resolveThing({',
'- a: 1,',
'+ a: resolveThing.default,',
' });',
);
assert.deepEqual(named.callees, [], 'символ на изменённой строке — прямой, не вызов');
assert.deepEqual(named.symbols, ['resolveThing']);
});
test('таблица символов не берёт одиночные английские слова (#241)', () => {
const table = symbolTable(repoRoot);
for (const noise of ['floor', 'value', 'index', 'return', 'length', 'edit']) {