mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
- 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
219 lines
12 KiB
JavaScript
219 lines
12 KiB
JavaScript
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)));
|
||
});
|