mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
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, #7245f8e8ca7), +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:d5bdfde9selects 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:
+29
-30
@@ -25,14 +25,33 @@ const restore = () => page.evaluate((s) => {
|
||||
return c.updateComplete && true;
|
||||
}, snap);
|
||||
|
||||
const mode = async (m) => {
|
||||
await page.evaluate((m) => {
|
||||
const c = window.__card;
|
||||
c._setMode(m); c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, m);
|
||||
await page.waitForTimeout(220); // editor chrome transition owns stage geometry
|
||||
};
|
||||
// A mode switch paints the stage and the paper with interpolated colours for
|
||||
// 220 ms plus a measurement frame, driven by animation frames: under load that
|
||||
// outlasts any fixed pause (#715). A mode is entered through the facade, and a
|
||||
// probe belongs to the settled mode: the stage carries `mode-<mode>` and no
|
||||
// longer `mode-transition`, and neither the stage nor the paper runs an
|
||||
// animation of its own. One page helper for `mode()` and section 6b (#754).
|
||||
await page.evaluate(() => {
|
||||
window.__backdropEnterMode = async (mode) => {
|
||||
const hp = window.__hpTest;
|
||||
const sr = () => window.__card.shadowRoot || window.__card.renderRoot;
|
||||
await hp.setMode(mode);
|
||||
const deadline = performance.now() + 5000;
|
||||
for (;;) {
|
||||
const stage = sr().querySelector('.stage');
|
||||
const paper = sr().querySelector('.stage svg .hp-paper');
|
||||
const running = [stage, paper].flatMap((node) => node?.getAnimations() ?? [])
|
||||
.filter((animation) => animation.playState === 'running');
|
||||
if (stage?.classList.contains(`mode-${mode}`) && !stage.classList.contains('mode-transition')
|
||||
&& !running.length) return true;
|
||||
if (performance.now() > deadline) {
|
||||
throw new Error(`smoke_backdrop: переход в ${mode} не завершился (.stage: ${stage?.className})`);
|
||||
}
|
||||
await hp.settled();
|
||||
}
|
||||
};
|
||||
});
|
||||
const mode = (m) => page.evaluate((m) => window.__backdropEnterMode(m), m);
|
||||
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
const q = (sel) => page.evaluate((s) => window.__card.renderRoot.querySelectorAll(s).length, sel);
|
||||
const spaceCfg = () => page.evaluate(() => {
|
||||
@@ -283,29 +302,9 @@ check('picture_is_below_the_walls', layers.iImage < layers.iRoom, true);
|
||||
// ---------- 6b) editors keep a WHITE sheet under the grid with a backdrop --
|
||||
const editorWhite = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const hp = window.__hpTest;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// A mode switch paints the stage and the paper with interpolated colours
|
||||
// for 220 ms plus a measurement frame, driven by animation frames: under
|
||||
// load that outlasts any fixed pause (#715). A probe belongs to the settled
|
||||
// mode: the stage carries `mode-<mode>` and no longer `mode-transition`,
|
||||
// and neither the stage nor the paper runs an animation of its own.
|
||||
const enter = async (mode) => {
|
||||
await hp.setMode(mode);
|
||||
const deadline = performance.now() + 5000;
|
||||
for (;;) {
|
||||
const stage = sr().querySelector('.stage');
|
||||
const paper = sr().querySelector('.stage svg .hp-paper');
|
||||
const running = [stage, paper].flatMap((node) => node?.getAnimations() ?? [])
|
||||
.filter((animation) => animation.playState === 'running');
|
||||
if (stage?.classList.contains(`mode-${mode}`) && !stage.classList.contains('mode-transition')
|
||||
&& !running.length) return;
|
||||
if (performance.now() > deadline) {
|
||||
throw new Error(`smoke_backdrop: переход в ${mode} не завершился (.stage: ${stage?.className})`);
|
||||
}
|
||||
await hp.settled();
|
||||
}
|
||||
};
|
||||
// The settled-mode wait shared with `mode()` (#715, #754).
|
||||
const enter = window.__backdropEnterMode;
|
||||
const probe = async (mode) => {
|
||||
await enter(mode);
|
||||
const stage = sr().querySelector('.stage');
|
||||
|
||||
+8
-5
@@ -311,11 +311,14 @@ node scripts/pre-push-gate.mjs --max-smokes=3
|
||||
|
||||
Три вида ответа `smoke-select` различаются и здесь: дифф без исполняемого кода —
|
||||
«смоки не требуются»; прямое совпадение или зарегистрированная связь —
|
||||
прогоняется; **связь не доказана** — отдельная громкая строка, потому что это не
|
||||
«проверять нечего»: молчание стоило #234 бета-блокирующего регресса. Вместе с ней
|
||||
печатается визуальный минимум (`VISUAL_MINIMUM` в `scripts/smoke-select.mjs`):
|
||||
восемь смоков режимов, слоёв и отрисовки, которые `--smokes` гоняет вместе с
|
||||
выборкой (#690). CSS-правка #687 без него не выбрала `smoke_modes`.
|
||||
прогоняется (правка аргументов многострочного вызова приписывается вызываемой
|
||||
функции, если её имя в пределах трёх строк контекста; в отчёте такой символ
|
||||
помечен «(вызов)», #754); **связь не доказана** — отдельная громкая строка,
|
||||
потому что это не «проверять нечего»: молчание стоило #234 бета-блокирующего
|
||||
регресса. Вместе с ней печатается визуальный минимум (`VISUAL_MINIMUM` в
|
||||
`scripts/smoke-select.mjs`): восемь смоков режимов, слоёв и отрисовки, которые
|
||||
`--smokes` гоняет вместе с выборкой (#690). CSS-правка #687 без него не выбрала
|
||||
`smoke_modes`.
|
||||
|
||||
### В хуке — по умолчанию для веток задач (#633)
|
||||
|
||||
|
||||
+104
-10
@@ -105,6 +105,59 @@ const GENERIC_SYMBOLS = new Set(['_cellCm', '_gridPitch', '_wallKeyPitch']);
|
||||
|
||||
const IDENTIFIER = /[A-Za-z_$][\w$]{2,}/g;
|
||||
|
||||
/**
|
||||
* Строк контекста в диффе выборки (#754). Правка аргумента многострочного
|
||||
* вызова — `runtime.resolveIsoOverlayFitEnvelope({` строкой выше, изменён
|
||||
* только аргумент (#741) — не называет функцию, контракт которой меняет: с
|
||||
* `--unified=0` символа в выборке нет, и ответ — «неопределённость». Контекст
|
||||
* нужен лишь для поиска вызова; прямые символы по-прежнему берутся только с
|
||||
* изменённых строк. Имя дальше трёх строк остаётся неопределённостью.
|
||||
*/
|
||||
export const CALL_CONTEXT_LINES = 3;
|
||||
|
||||
/** Строки и комментарии без содержимого: скобки внутри них не считаются. */
|
||||
const scrubLine = (text) => text
|
||||
.replace(/'(?:\\.|[^'\\])*'/g, "''")
|
||||
.replace(/"(?:\\.|[^"\\])*"/g, '""')
|
||||
.replace(/`(?:\\.|[^`\\])*`/g, '``')
|
||||
.replace(/\/\*.*?\*\//g, '')
|
||||
.replace(/\/\/.*$/, '');
|
||||
|
||||
const CALLEE_NAME = /([A-Za-z_$][\w$]*)\s*(?:\?\.)?$/;
|
||||
|
||||
/**
|
||||
* Имя функции, внутри аргументов которой начинается строка `index` (#754):
|
||||
* ближайшая незакрытая `(` выше в пределах ханка. Литерал-аргумент — `{` или
|
||||
* `[` после `(`, `,` или `[` — проходится насквозь; `;` на нулевой глубине и
|
||||
* любая другая незакрытая `{`/`[` (тело блока, функции, класса, присваивание
|
||||
* литерала) останавливают поиск. `lines` уже вычищены `scrubLine`.
|
||||
*/
|
||||
function enclosingCallee(lines, index) {
|
||||
const depth = { ')': 0, ']': 0, '}': 0 };
|
||||
const opener = { '(': ')', '[': ']', '{': '}' };
|
||||
/** Последний значимый символ перед позицией `column` строки `row` — в ней или выше. */
|
||||
const previous = (row, column) => {
|
||||
for (let k = row; k >= 0; k--) {
|
||||
const trimmed = (k === row ? lines[k].slice(0, column) : lines[k]).trimEnd();
|
||||
if (trimmed) return trimmed.at(-1);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
for (let row = index - 1; row >= 0; row--) {
|
||||
const text = lines[row];
|
||||
for (let column = text.length - 1; column >= 0; column--) {
|
||||
const char = text[column];
|
||||
if (char === ';' && !depth[')'] && !depth[']'] && !depth['}']) return null;
|
||||
if (char in depth) { depth[char]++; continue; }
|
||||
if (!(char in opener)) continue;
|
||||
if (depth[opener[char]] > 0) { depth[opener[char]]--; continue; }
|
||||
if (char === '(') return CALLEE_NAME.exec(text.slice(0, column).trimEnd())?.[1] ?? null;
|
||||
if (!['(', ',', '['].includes(previous(row, column))) return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function symbolTable(root = repoRoot) {
|
||||
const table = new Set();
|
||||
const walk = (dir) => {
|
||||
@@ -128,16 +181,35 @@ export function symbolTable(root = repoRoot) {
|
||||
return table;
|
||||
}
|
||||
|
||||
/** Изменённые файлы и символы на изменённых строках. */
|
||||
/**
|
||||
* Изменённые файлы и символы на изменённых строках. С #754 к символам
|
||||
* добавляется вызываемая функция, внутри аргументов которой стоит изменённая
|
||||
* строка (`callees` — те, что найдены только так). Строки контекста ханка ищут
|
||||
* лишь вызов: прямых символов они не дают.
|
||||
*/
|
||||
export function parseDiff(diffText, table) {
|
||||
const files = new Set();
|
||||
const executable = new Set();
|
||||
const symbols = new Set();
|
||||
const called = new Set();
|
||||
let current = null;
|
||||
let currentExecutable = false;
|
||||
// Обе стороны текущего ханка: контекст — в обеих, `-` — в старой, `+` — в новой.
|
||||
let hunk = null;
|
||||
const closeHunk = () => {
|
||||
for (const side of hunk ? [hunk.old, hunk.new] : []) {
|
||||
const lines = side.map((entry) => scrubLine(entry.text));
|
||||
side.forEach((entry, index) => {
|
||||
const callee = entry.changed && enclosingCallee(lines, index);
|
||||
if (callee && table.has(callee)) called.add(callee);
|
||||
});
|
||||
}
|
||||
hunk = null;
|
||||
};
|
||||
for (const line of diffText.split('\n')) {
|
||||
const header = /^\+\+\+ b\/(.+)$/.exec(line) || /^diff --git a\/\S+ b\/(.+)$/.exec(line);
|
||||
if (header) {
|
||||
closeHunk();
|
||||
current = header[1] === '/dev/null' ? null : header[1];
|
||||
currentExecutable = !!current && isExecutableFrontend(current);
|
||||
if (current) {
|
||||
@@ -147,17 +219,30 @@ export function parseDiff(diffText, table) {
|
||||
continue;
|
||||
}
|
||||
if (!currentExecutable) continue;
|
||||
if (line.startsWith('@@')) { closeHunk(); hunk = { old: [], new: [] }; continue; }
|
||||
// Ханк кончается на первой строке, которая не строка ханка: `commit …` в
|
||||
// выводе `git log -p`, `index …`, сообщение коммита после него.
|
||||
if (hunk && line !== '' && !/^[ +\-\\]/.test(line)) closeHunk();
|
||||
if (hunk && (line === '' || line.startsWith(' '))) {
|
||||
hunk.old.push({ text: line.slice(1) });
|
||||
hunk.new.push({ text: line.slice(1) });
|
||||
continue;
|
||||
}
|
||||
if (!/^[+-]/.test(line) || /^(\+\+\+|---)/.test(line)) continue;
|
||||
hunk?.[line[0] === '+' ? 'new' : 'old'].push({ text: line.slice(1), changed: true });
|
||||
IDENTIFIER.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = IDENTIFIER.exec(line))) {
|
||||
if (table.has(match[0])) symbols.add(match[0]);
|
||||
}
|
||||
}
|
||||
closeHunk();
|
||||
const callees = [...called].filter((symbol) => !symbols.has(symbol)).sort();
|
||||
return {
|
||||
files: [...files].sort(),
|
||||
executable: [...executable].sort(),
|
||||
symbols: [...symbols].sort(),
|
||||
symbols: [...new Set([...symbols, ...callees])].sort(),
|
||||
callees,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -223,6 +308,9 @@ export function selectSmokes(diffText, { root = repoRoot, table, corpus } = {})
|
||||
files: parsed.files,
|
||||
executable: parsed.executable,
|
||||
symbols: parsed.symbols,
|
||||
// #754: символы, приписанные правке по вызову, внутри аргументов которого
|
||||
// она стоит; на самих изменённых строках их нет.
|
||||
callees: parsed.callees,
|
||||
broad,
|
||||
unseen,
|
||||
direct,
|
||||
@@ -240,12 +328,13 @@ export function selectSmokes(diffText, { root = repoRoot, table, corpus } = {})
|
||||
}
|
||||
|
||||
function gitDiff(base, head) {
|
||||
const result = spawnSync('git', ['-C', repoRoot, 'diff', '--unified=0', `${base}...${head}`],
|
||||
const unified = `--unified=${CALL_CONTEXT_LINES}`;
|
||||
const result = spawnSync('git', ['-C', repoRoot, 'diff', unified, `${base}...${head}`],
|
||||
{ encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
||||
if (result.status !== 0) {
|
||||
// `A...B` не работает без общего предка — тогда честнее прямой диапазон,
|
||||
// чем молча вернуть пустоту и «смоки не нужны».
|
||||
const plain = spawnSync('git', ['-C', repoRoot, 'diff', '--unified=0', base, head],
|
||||
const plain = spawnSync('git', ['-C', repoRoot, 'diff', unified, base, head],
|
||||
{ encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
||||
if (plain.status !== 0) throw new Error(`git diff: ${plain.stderr || result.stderr}`);
|
||||
return plain.stdout;
|
||||
@@ -255,6 +344,10 @@ function gitDiff(base, head) {
|
||||
|
||||
function report(selection) {
|
||||
const lines = [];
|
||||
// #754: символ, приписанный по вызову, назван как вызов — на изменённых
|
||||
// строках его нет, и ревьюер должен видеть, откуда он взялся.
|
||||
const callees = new Set(selection.callees || []);
|
||||
const named = (symbols) => symbols.map((symbol) => (callees.has(symbol) ? `${symbol} (вызов)` : symbol)).join(', ');
|
||||
if (selection.noExecutableDiff) {
|
||||
lines.push('Исполняемого frontend-диффа нет (src/**/*.ts не тронут).');
|
||||
lines.push('Browser-smoke этим диффом не выбираются — это не «пропустить проверки»,');
|
||||
@@ -263,7 +356,8 @@ function report(selection) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
lines.push(`Изменено файлов src/**: ${selection.executable.length}`
|
||||
+ ` · символов проекта на изменённых строках: ${selection.symbols.length}`);
|
||||
+ ` · символов проекта на изменённых строках: ${selection.symbols.length}`
|
||||
+ (callees.size ? ` (из них по вызову: ${callees.size})` : ''));
|
||||
lines.push(`Матрица: ${selection.smokeCount} смоков · порог «широкого» символа:`
|
||||
+ ` больше ${selection.broadLimit} смоков`);
|
||||
lines.push('');
|
||||
@@ -273,7 +367,7 @@ function report(selection) {
|
||||
const listing = (entries) => {
|
||||
for (const entry of entries) {
|
||||
lines.push(` demo/${entry.smoke}`);
|
||||
lines.push(` ← ${entry.symbols.slice(0, 6).join(', ')}`
|
||||
lines.push(` ← ${named(entry.symbols.slice(0, 6))}`
|
||||
+ (entry.symbols.length > 6 ? ` и ещё ${entry.symbols.length - 6}` : ''));
|
||||
}
|
||||
};
|
||||
@@ -293,7 +387,7 @@ function report(selection) {
|
||||
lines.push(`Зарегистрированная связь (${selection.registered.length}):`);
|
||||
for (const entry of selection.registered) {
|
||||
lines.push(` demo/${entry.smoke}`);
|
||||
lines.push(` ← ${entry.symbols.join(', ')}`);
|
||||
lines.push(` ← ${named(entry.symbols)}`);
|
||||
for (const because of entry.because) lines.push(` ${because}`);
|
||||
}
|
||||
lines.push('');
|
||||
@@ -309,18 +403,18 @@ function report(selection) {
|
||||
for (const smoke of selection.visualMinimum) lines.push(` demo/${smoke}`);
|
||||
if (selection.broad.length) {
|
||||
lines.push(`Широкие символы (есть почти везде, ничего не различают):`
|
||||
+ ` ${selection.broad.slice(0, 10).join(', ')}`);
|
||||
+ ` ${named(selection.broad.slice(0, 10))}`);
|
||||
}
|
||||
if (selection.unseen.length) {
|
||||
lines.push('Символы, которых нет ни в одном смоке:'
|
||||
+ ` ${selection.unseen.slice(0, 10).join(', ')}`
|
||||
+ ` ${named(selection.unseen.slice(0, 10))}`
|
||||
+ (selection.unseen.length > 10 ? ` и ещё ${selection.unseen.length - 10}` : ''));
|
||||
lines.push('Если один из них — новый контракт, ему нужен новый смок либо'
|
||||
+ ' запись в scripts/smoke-links.mjs.');
|
||||
}
|
||||
lines.push('');
|
||||
} else if (selection.broad.length) {
|
||||
lines.push(`Не учитывались как широкие: ${selection.broad.slice(0, 10).join(', ')}`);
|
||||
lines.push(`Не учитывались как широкие: ${named(selection.broad.slice(0, 10))}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { classify } from './process-gate.mjs';
|
||||
import {
|
||||
classifyRisk, cycleLimit, hasTrackLabel, rebaseBeforeReview, riskClassLine, trackFromLabels, trackOrigin,
|
||||
} from './process-track.mjs';
|
||||
import { selectSmokes } from './smoke-select.mjs';
|
||||
import { CALL_CONTEXT_LINES, selectSmokes } from './smoke-select.mjs';
|
||||
|
||||
export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
|
||||
|
||||
@@ -516,7 +516,9 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr
|
||||
const merge = behind > 0 ? readMergeState({ cwd, onto: 'origin/dev', ref }) : { clean: true, conflicts: [] };
|
||||
let smokes = null;
|
||||
try {
|
||||
const selection = selectSmokes(diff);
|
||||
// #754: выборке нужен контекст, чтобы приписать правку аргументов вызову;
|
||||
// риск по участкам (`diff`) остаётся на `--unified=0`.
|
||||
const selection = selectSmokes(sh('git', ['-c', 'core.quotePath=false', 'diff', `--unified=${CALL_CONTEXT_LINES}`, '-M', '--no-color', '--no-ext-diff', '--no-textconv', `${base}..${ref}`], { cwd }));
|
||||
smokes = {
|
||||
direct: selection.direct.filter((entry) => entry.strong).map(({ smoke, symbols }) => ({ smoke, symbols })),
|
||||
registered: selection.registered.map(({ smoke, symbols }) => ({ smoke, symbols })),
|
||||
|
||||
+110
@@ -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', () => {
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
Reference in New Issue
Block a user