Files
houseplan-card/test/no-new-private-writes.test.mjs
Claudeandclaude[bot] fa319e37b3 test(harness): тестовый фасад window.__hpTest и гейт no-new-private-writes (#629)
- scripts/no-new-private-writes.mjs: смоки и demo/helpers/** не добавляют
  записей в приватное состояние карточки (присваивание, ++/--, delete по
  цепочке с сегментом _x; от this — нет) и вызовов _setMode/_openRoomEdit/
  _openMarkerDialog/_openSpaceDialog. Зачёт правки по полю, перенос блока —
  movedLinesByFile из no-new-any; исключение // private-ok: <причина>.
  --count — остаток на HEAD. Подключён в gate:small и в шаг frontend рядом с
  no-new-any, с той же базой.
- demo/helpers/hp-test.mjs: 10 операций через контрактные хуки и события
  фикстуры (setMode, setTool, switchSpace, openRoomEdit, openMarkerDialog,
  openSpaceDialog, setServerConfig, setLayout, input, close); ставится
  launch*() из demo/serve.mjs. В бандле фасада нет.
- demo/srv/demo.html: доставка houseplan_config_updated/_layout_updated,
  __pushServerConfig/__pushServerLayout; после доставки запись со старым
  expected_rev — conflict, как у настоящего сервера.
- HP_SMOKE_CHECKS=1 печатает имена проверок в finish().
- smoke_area_relocation, smoke_glow, smoke_grid_snap переведены на фасад без
  потери утверждений; новый smoke_test_facade доказывает каждую операцию.
- 7 мутантов, docs/TESTING.md (раздел + правило №6), PROCESS.md §2.7, AGENTS.md.

Issue: #629
User-Visible: no
2026-09-25 01:25:20 +00:00

219 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { MOVED_BLOCK_MIN } from '../scripts/no-new-any.mjs';
import {
COVERED_CALLS, countWrites, creditsByFile, findNewPrivateWriteViolations, formatViolation,
isGatedPath, parsePrivateOk, privateWriteSites,
} from '../scripts/no-new-private-writes.mjs';
// #629. Смоки писали в приватное состояние карточки больше двух тысяч раз и
// поэтому были слепы к обработчикам ввода и путям закрытия. Гейт держит
// приращение на нуле: судятся добавленные строки, как у no-new-any (#342).
const SCRIPT = fileURLToPath(new URL('../scripts/no-new-private-writes.mjs', import.meta.url));
const file = (text, addedLines, path = 'demo/smoke_probe.mjs', movedLines) => ({
path, text, addedLines: new Set(addedLines), movedLines: movedLines && new Set(movedLines),
});
const run = (args, input) => spawnSync(process.execPath, [SCRIPT, ...args], { encoding: 'utf8', input });
/** Унифицированный дифф с нулевым контекстом для одного файла. */
const patch = (path, removed, added, { from = path, start = 1 } = {}) => [
`diff --git a/${from} b/${path}`,
`--- ${from === null ? '/dev/null' : `a/${from}`}`,
`+++ b/${path}`,
`@@ -${start},${removed.length} +${start},${added.length} @@`,
...removed.map((line) => `-${line}`),
...added.map((line) => `+${line}`),
].join('\n');
test('запись в приватное поле во всех формах — нарушение (#629 AC1, G1)', () => {
const text = [
"c._tool = 'draw';",
'c._serverCfg.model_version = 7;',
'c._cfgEpoch++;',
'--c._cfgEpoch;',
'delete c._x;',
"c['_space'] = 'g1';",
'c._frame ??= null;',
'[a, c._layout] = pair;',
'window.__card._mode = "plan";',
].join('\n');
const violations = findNewPrivateWriteViolations({ files: [file(text, [1, 2, 3, 4, 5, 6, 7, 8, 9])] });
assert.deepEqual(violations.map((v) => [v.line, v.field]), [
[1, '_tool'], [2, '_serverCfg'], [3, '_cfgEpoch'], [4, '_cfgEpoch'], [5, '_x'],
[6, '_space'], [7, '_frame'], [8, '_layout'], [9, '_mode'],
]);
});
test('не запись в карточку: this, window.__x, обычные поля, чтения, сравнения (#629 AC1, G1)', () => {
const text = [
'this._x = 1;',
'this._state.count++;',
'window.__card = card;',
'o.ok = c._tool === "draw";',
'const t = c._tool;',
'if (c._mode == "plan") o.mode = true;',
'// c._tool = "draw" в комментарии',
'const s = "c._tool = 1";',
'c._serverCfg.spaces.push(space);',
].join('\n');
assert.deepEqual(findNewPrivateWriteViolations({ files: [file(text, [1, 2, 3, 4, 5, 6, 7, 8, 9])] }), []);
});
test('старая запись на нетронутой строке гейт не блокирует (#629 AC1, G2)', () => {
const text = "c._tool = 'draw';\nconst fresh = 1;";
assert.deepEqual(findNewPrivateWriteViolations({ files: [file(text, [2])] }), []);
});
test('вложенная цепочка называет поле, ближайшее к корню (#629 G1)', () => {
const [site] = privateWriteSites('demo/smoke_probe.mjs',
'c._serverCfg.spaces[0]._meta.title = "x";');
assert.deepEqual(site, { line: 1, field: '_serverCfg', kind: 'write' });
// многострочное присваивание судится по строке, где начинается цель
const sites = privateWriteSites('demo/smoke_probe.mjs', 'const a = 1;\nc._drag = {\n id: 1,\n};');
assert.deepEqual(sites, [{ line: 2, field: '_drag', kind: 'write' }]);
});
test('правка зачитывается только по тому же полю того же файла (#629 AC2, G3)', () => {
const path = 'demo/smoke_probe.mjs';
const judge = (removed, added, otherPath = path) => findNewPrivateWriteViolations({
files: [file(added.join('\n'), added.map((_, i) => i + 1), otherPath)],
credits: creditsByFile(new Map([[path, removed]])),
});
// та же строка, другое значение — правка, а не новая запись
assert.deepEqual(judge(["c._tool = 'draw';"], ["c._tool = 'select';"]), []);
// удалённая запись в другое поле новую не оплачивает
assert.equal(judge(["c._tool = 'draw';"], ["c._mode = 'plan';"]).length, 1);
// удалённая запись в другом файле — тоже
assert.equal(judge(["c._tool = 'draw';"], ["c._tool = 'select';"], 'demo/smoke_other.mjs').length, 1);
// одна удалённая оплачивает одну добавленную
assert.equal(judge(["c._tool = 'draw';"], ["c._tool = 'select';", "c._tool = 'erase';"]).length, 1);
});
test('перенос блока ≥ MOVED_BLOCK_MIN проходит, одиночная совпавшая строка — нет (#629 AC2, G3)', () => {
const block = Array.from({ length: MOVED_BLOCK_MIN }, (_, i) => `c._f${i} = ${i};`);
const moved = [
patch('demo/smoke_a.mjs', block, []),
patch('demo/smoke_b.mjs', [], block, { from: null }),
].join('\n');
const one = [
patch('demo/smoke_a.mjs', ['c._f0 = 0;'], []),
patch('demo/smoke_b.mjs', [], ['c._f0 = 0;'], { from: null }),
].join('\n');
const fs = { 'demo/smoke_b.mjs': block.join('\n') };
// CLI в режиме --diff читает файлы с диска, поэтому здесь — чистая функция
// над тем же разбором, что делает main()
const judge = (diff, text) => {
const cli = spawnSync(process.execPath, ['--input-type=module', '-e', `
import { addedLinesByFile, movedLinesByFile } from ${JSON.stringify(new URL('../scripts/no-new-any.mjs', import.meta.url).href)};
import { creditsByFile, findNewPrivateWriteViolations } from ${JSON.stringify(new URL('../scripts/no-new-private-writes.mjs', import.meta.url).href)};
const diff = ${JSON.stringify(diff)};
const details = {};
const moved = movedLinesByFile(diff, { details });
const credits = creditsByFile(details.removedByFile, details.spent);
const added = addedLinesByFile(diff).get('demo/smoke_b.mjs');
const v = findNewPrivateWriteViolations({ files: [{ path: 'demo/smoke_b.mjs', text: ${JSON.stringify(text)},
addedLines: added, movedLines: moved.get('demo/smoke_b.mjs') || new Set() }], credits });
process.stdout.write(JSON.stringify(v.map((x) => x.line)));
`], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr);
return JSON.parse(cli.stdout);
};
assert.deepEqual(judge(moved, fs['demo/smoke_b.mjs']), []);
assert.deepEqual(judge(one, 'c._f0 = 0;'), [1]);
});
test('удалённые строки, оплатившие перенос, правку второй раз не оплачивают (#629 G3)', () => {
const block = Array.from({ length: MOVED_BLOCK_MIN }, () => "c._tool = 'draw';");
// из a блок уехал в b; в a же добавлена новая запись в то же поле
const spent = new Map([['demo/smoke_a.mjs', new Set(block.map((_, i) => i))]]);
const credits = creditsByFile(new Map([['demo/smoke_a.mjs', block]]), spent);
assert.equal(credits.has('demo/smoke_a.mjs'), false);
const violations = findNewPrivateWriteViolations({
files: [file("c._tool = 'select';", [1], 'demo/smoke_a.mjs')], credits,
});
assert.equal(violations.length, 1);
});
test('private-ok проходит только с конкретной причиной (#629 AC3, G4)', () => {
const lines = [
"c._tool = 'draw'; // private-ok",
"c._tool = 'draw'; // private-ok: todo",
"c._tool = 'draw'; // private-ok: потом",
"c._tool = 'draw'; // private-ok: #700 продукт не перестраивает устройства после принятия конфига",
].join('\n');
const violations = findNewPrivateWriteViolations({ files: [file(lines, [1, 2, 3, 4])] });
assert.deepEqual(violations.map((v) => v.line), [1, 2, 3]);
for (const violation of violations) assert.match(violation.reason, /private-ok без конкретной причины/);
assert.equal(parsePrivateOk('c._x = 1;'), null);
assert.equal(parsePrivateOk('// private-ok').ok, false);
// маркер соседнего гейта здесь не действует
assert.equal(parsePrivateOk('// any-ok: форма события HA не типизирована'), null);
});
test('вызов, покрытый фасадом, — нарушение с подсказкой операции (#629 AC4, G5)', () => {
const text = [
"c._setMode('plan');",
"await c._openRoomEdit(room);",
'c._openMarkerDialog(dev);',
"window.__card._openSpaceDialog('edit', 'f1');",
"c._setMode('view'); // private-ok: #700 режим без вкладок в киоске, фасаду нечего нажать",
"this._setMode('plan');",
'c._openAlignDialog();',
].join('\n');
const violations = findNewPrivateWriteViolations({ files: [file(text, [1, 2, 3, 4, 5, 6, 7])] });
assert.deepEqual(violations.map((v) => [v.line, v.field, v.kind]), [
[1, '_setMode', 'call'], [2, '_openRoomEdit', 'call'],
[3, '_openMarkerDialog', 'call'], [4, '_openSpaceDialog', 'call'],
]);
for (const v of violations) assert.ok(v.reason.includes(`__hpTest.${COVERED_CALLS[v.field]}`), v.reason);
// правка вызова зачитывается, как правка записи
const credits = creditsByFile(new Map([['demo/smoke_probe.mjs', ["c._setMode('devices');"]]]));
assert.deepEqual(findNewPrivateWriteViolations({ files: [file("c._setMode('plan');", [1])], credits }), []);
});
test('область гейта — смоки и хелперы харнесса (#629 G2)', () => {
assert.equal(isGatedPath('demo/smoke_glow.mjs'), true);
assert.equal(isGatedPath('demo/helpers/hp-test.mjs'), true);
assert.equal(isGatedPath('demo/helpers/sub/x.mjs'), true);
assert.equal(isGatedPath('demo/benchmark_glow.mjs'), false);
assert.equal(isGatedPath('demo/golden/run.mjs'), false);
assert.equal(isGatedPath('demo/shot_x.mjs'), false);
assert.equal(isGatedPath('src/probe.ts'), false);
});
test('отчёт называет путь, строку, поле, подсказку и текст (#629 G6)', () => {
const [v] = findNewPrivateWriteViolations({ files: [file(" c._layout = {};", [1])] });
const text = formatViolation(v);
assert.match(text, /demo\/smoke_probe\.mjs:1 \[_layout\]/);
assert.match(text, /__hpTest\.setLayout/);
assert.match(text, /c\._layout = \{\};/);
});
test('CLI: --diff красный на добавленной записи и зелёный без неё; --count информационный (#629 G6)', () => {
// В режиме --diff текст файла читается из дерева, поэтому проба берёт живой
// смок, который эта задача не переводит, и его первую строку с записью.
const path = 'demo/smoke_decor.mjs';
const text = readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
const site = privateWriteSites(path, text).find((s) => s.kind === 'write');
assert.ok(site, 'в пробном смоке должна быть запись');
const lineText = text.split('\n')[site.line - 1];
const red = run(['--diff', '-'], patch(path, [], [lineText], { start: site.line }));
assert.equal(red.status, 1, red.stdout);
assert.ok(red.stderr.includes(`${path}:${site.line} [${site.field}]`), red.stderr);
// та же строка как правка записи в то же поле — зачёт
const edit = run(['--diff', '-'], patch(path, [lineText], [lineText], { start: site.line }));
assert.equal(edit.status, 0, edit.stderr);
const commentLine = text.split('\n').findIndex((l) => l.startsWith('//')) + 1;
const green = run(['--diff', '-'], patch(path, [], ['// x'], { start: commentLine }));
assert.equal(green.status, 0, green.stderr);
const count = run(['--count']);
assert.equal(count.status, 0, count.stderr);
assert.match(count.stdout, /Итого: записей \d+/);
const rows = countWrites();
assert.ok(rows.length > 0 && rows.every((r) => isGatedPath(r.path)));
});