diff --git a/demo/smoke_backdrop.mjs b/demo/smoke_backdrop.mjs index d353b770..61032283 100644 --- a/demo/smoke_backdrop.mjs +++ b/demo/smoke_backdrop.mjs @@ -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-` 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-` 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'); diff --git a/docs/TESTING.md b/docs/TESTING.md index 798b1e2a..6aaf53ca 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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) diff --git a/scripts/smoke-select.mjs b/scripts/smoke-select.mjs index 3f3cf2b8..6a17618c 100644 --- a/scripts/smoke-select.mjs +++ b/scripts/smoke-select.mjs @@ -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(''); } diff --git a/scripts/task-packet.mjs b/scripts/task-packet.mjs index eb1c1703..e03e59f5 100644 --- a/scripts/task-packet.mjs +++ b/scripts/task-packet.mjs @@ -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 })), diff --git a/test/fixtures/smoke-select/741-call-arguments.diff b/test/fixtures/smoke-select/741-call-arguments.diff new file mode 100644 index 00000000..6f35a118 --- /dev/null +++ b/test/fixtures/smoke-select/741-call-arguments.diff @@ -0,0 +1,110 @@ +commit d5bdfde9196bc7ecf7a4cfefea04f4dd69559b0d +Author: Claude +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 + 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', () => { diff --git a/test/iso-overlay-fixture-types.test.mjs b/test/iso-overlay-fixture-types.test.mjs index 24182a66..6eea78d9 100644 --- a/test/iso-overlay-fixture-types.test.mjs +++ b/test/iso-overlay-fixture-types.test.mjs @@ -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'); }); diff --git a/test/iso-scene-render.test.mjs b/test/iso-scene-render.test.mjs index eecd468f..751349dd 100644 --- a/test/iso-scene-render.test.mjs +++ b/test/iso-scene-render.test.mjs @@ -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) => { diff --git a/test/smoke-select.test.mjs b/test/smoke-select.test.mjs index d5a52ef0..4d0cce9b 100644 --- a/test/smoke-select.test.mjs +++ b/test/smoke-select.test.mjs @@ -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']) {