Files
houseplan-card/test/smoke-select.test.mjs
T
Claude 871a689ebf 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
2026-10-01 13:54:23 +03:00

226 lines
13 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { existsSync, readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { selectSmokes, parseDiff, symbolTable, VISUAL_MINIMUM } from '../scripts/smoke-select.mjs';
import { smokesToRun } from '../scripts/gate-small.mjs';
import { SMOKE_LINKS, registeredSmokes } from '../scripts/smoke-links.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const fixture = (name) =>
readFileSync(join(repoRoot, 'test/fixtures/smoke-select', name), 'utf8');
// Выборка считается по настоящему корпусу смоков и настоящей таблице символов:
// фикстура — только дифф. Тест поэтому краснеет и когда ломается инструмент, и
// когда контракт переименовали, не обновив связи. Второе — тоже дефект.
const names = (selection) => selection.direct.map((entry) => entry.smoke);
const strongNames = (selection) => selection.direct
.filter((entry) => entry.strong).map((entry) => entry.smoke);
test('#234: выборка находит все четыре смока контрольного случая (#241)', () => {
const selection = selectSmokes(fixture('234-chain-thickness.diff'), { root: repoRoot });
const recommended = new Set([
...strongNames(selection),
...selection.registered.map((entry) => entry.smoke),
]);
for (const smoke of [
'smoke_wall_chain_thickness.mjs',
'smoke_draw_wall_thickness.mjs',
'smoke_wall_thickness_transition.mjs',
'smoke_wall_junctions.mjs',
]) {
assert.ok(recommended.has(smoke), `${smoke} не попал в выборку по диффу #234`);
}
// Именно тот смок, на котором #234 потерял регресс, и именно по имени поля:
// если связь начнёт находиться «вообще как-нибудь», проверка обесценится.
const junctions = selection.direct.find((e) => e.smoke === 'smoke_wall_junctions.mjs');
assert.ok(junctions.symbols.includes('_wallChainSegmentCms'));
// И не превращается в полный прогон: смысл выборки в том, что она меньше матрицы.
assert.ok(recommended.size < selection.smokeCount / 4,
`выборка ${recommended.size} из ${selection.smokeCount} — это уже полная матрица`);
});
test('#234: переход толщин держится на зарегистрированной связи, а не на совпадении', () => {
const selection = selectSmokes(fixture('234-chain-thickness.diff'), { root: repoRoot });
// Смок не называет ни одного изменённого символа — если он вдруг окажется в
// прямых совпадениях, значит реестр перестал быть нагруженным, и мутант его
// удаления ничего не докажет.
assert.ok(!names(selection).includes('smoke_wall_thickness_transition.mjs'),
'смок перехода нашёлся по совпадению — реестр надо пересобрать заново');
const entry = selection.registered.find(
(candidate) => candidate.smoke === 'smoke_wall_thickness_transition.mjs',
);
assert.ok(entry, 'зарегистрированная связь #234 пропала');
assert.ok(entry.symbols.includes('chainSegmentCms'));
assert.ok(entry.because.join(' ').length > 40, 'связь без объяснения — суеверие');
});
test('только документация: выборка пуста и говорит почему (#241)', () => {
const selection = selectSmokes(fixture('docs-only.diff'), { root: repoRoot });
assert.equal(selection.noExecutableDiff, true);
assert.deepEqual(selection.direct, []);
assert.deepEqual(selection.registered, []);
// «Нечего выбирать» и «неопределённость» — разные ответы, и путать их нельзя.
assert.equal(selection.unproven, false);
assert.ok(selection.files.length > 0, 'файлы в диффе всё же были');
});
test('связь не доказана — это неопределённость, а не «проверок не нужно» (#241)', () => {
const selection = selectSmokes(fixture('unproven.diff'), { root: repoRoot });
assert.equal(selection.noExecutableDiff, false);
assert.equal(selection.unproven, true);
assert.equal(strongNames(selection).length, 0);
assert.ok(selection.unseen.includes('openingInnerFaceOffsetFromIndex'),
'символ без смока обязан быть назван');
});
test('#690 п.1′: неопределённость выдаёт визуальный минимум, доказанная связь и docs — нет', () => {
const unproven = selectSmokes(fixture('unproven.diff'), { root: repoRoot });
assert.deepEqual(unproven.visualMinimum, [...VISUAL_MINIMUM]);
for (const smoke of VISUAL_MINIMUM) {
assert.ok(smokesToRun(unproven).includes(smoke), `gate:small -- --smokes не гоняет ${smoke}`);
}
assert.deepEqual(selectSmokes(fixture('234-chain-thickness.diff'), { root: repoRoot }).visualMinimum, [],
'при доказанной связи минимум не нужен — выборка остаётся меньше матрицы');
const docs = selectSmokes(fixture('docs-only.diff'), { root: repoRoot });
assert.deepEqual(docs.visualMinimum, []);
assert.deepEqual(smokesToRun(docs), [], 'без исполняемого диффа смоков нет');
});
test('#690 п.1′: визуальный минимум — 5–8 существующих смоков, и CLI его печатает', () => {
assert.ok(VISUAL_MINIMUM.length >= 5 && VISUAL_MINIMUM.length <= 8, `в минимуме ${VISUAL_MINIMUM.length}`);
for (const smoke of VISUAL_MINIMUM) assert.ok(existsSync(join(repoRoot, 'demo', smoke)), `нет demo/${smoke}`);
assert.ok(VISUAL_MINIMUM.includes('smoke_modes.mjs'), 'смок, которого не хватило #687');
const cli = spawnSync(process.execPath, ['scripts/smoke-select.mjs', '--diff', 'test/fixtures/smoke-select/unproven.diff'],
{ cwd: repoRoot, encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr);
assert.match(cli.stdout, /Визуальный минимум \(\d\) — прогнать до S7/);
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']) {
assert.ok(!table.has(noise), `«${noise}» попал в таблицу символов и вернёт шум`);
}
for (const real of ['chainSegmentCms', '_wallChainSegmentCms', 'innerEdgeSpan']) {
assert.ok(table.has(real), `${real} не распознан как символ проекта`);
}
});
test('parseDiff читает только исполняемый frontend (#241)', () => {
const table = new Set(['chainSegmentCms']);
const parsed = parseDiff([
'diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md',
'+chainSegmentCms упомянут в документации',
'diff --git a/src/wall-face-graph.ts b/src/wall-face-graph.ts',
'+export function chainSegmentCms(',
].join('\n'), table);
assert.deepEqual(parsed.executable, ['src/wall-face-graph.ts']);
assert.deepEqual(parsed.symbols, ['chainSegmentCms'], 'упоминание в docs не символ диффа');
});
test('каждая запись реестра объясняет себя и указывает на существующий смок (#241)', () => {
for (const link of SMOKE_LINKS) {
assert.ok(link.symbols.length, 'связь без символов не сработает никогда');
assert.ok(link.because && link.because.length > 40, 'связь без объяснения — суеверие');
for (const smoke of link.smokes) {
assert.match(smoke, /^smoke_.*\.mjs$/);
assert.ok(
readFileSync(join(repoRoot, 'demo', smoke), 'utf8').length > 0,
`${smoke} в реестре, но файла нет`,
);
}
}
// Пустой набор изменённых символов не должен давать связей.
assert.deepEqual(registeredSmokes([]), []);
});