Files
houseplan-card/test/check-inputs.test.mjs
T

371 lines
23 KiB
JavaScript
Executable File
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 { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
BASELINE_OVERLAY, CHECKS, CHECK_NAMES, NOT_AN_INPUT, REUSE_JOBS, checksAffectedBy, closure, coverage,
GUARDED_DATA_ROOTS, globToRegExp, inputsOf, isBaselineOverlay, isDeclaredNotAnInput, isExecutableInput,
isGuardedInput, manifest, referencesOf,
stripComments,
} from '../scripts/check-inputs.mjs';
// Единый manifest входов (#492 §5). Две группы доказательств: чистая механика
// (glob, ссылки, замыкание) на виртуальном дереве и представительные входы
// §8.1 на РЕАЛЬНОМ репозитории — для каждой тяжёлой job и каждой категории
// названный файл обязан быть её входом, а UI — не входом backend (AC6).
const ROOT = process.cwd();
const MANIFEST = manifest(ROOT);
// Пути, которых в manifest быть не должно, собираются из кусков — литерал в
// этом файле сделал бы их входом frontend (тесты читают то, что называют).
const p = (...parts) => parts.join('/');
test('glob: ** — любой путь, * — сегмент, точка буквальна', () => {
assert.ok(globToRegExp('src/**').test('src/a/b.ts'));
assert.ok(globToRegExp('src/**').test('src/a.ts'));
assert.ok(!globToRegExp('src/**').test('srcx/a.ts'));
assert.ok(globToRegExp('demo/smoke_*.mjs').test('demo/smoke_alpha.mjs'));
assert.ok(!globToRegExp('demo/smoke_*.mjs').test('demo/guard/smoke_alpha.mjs'));
assert.ok(globToRegExp('custom_components/**/*.py').test('custom_components/houseplan/store.py'));
assert.ok(!globToRegExp('custom_components/**/*.py').test('custom_components/houseplan/frontend/x.js'));
assert.ok(!globToRegExp('package.json').test('packageXjson'));
});
test('ссылки: импорты JS — код, строковые пути — данные, комментарии — ничего', () => {
const text = `
import a from './a.mjs';
import { b } from '../lib/b.mjs';
export * from './c.mjs';
const d = await import('./d.mjs');
const e = require('./e.mjs');
// import x from './comment.mjs';
/* readFileSync('demo/fixtures/comment.json') */
const f = readFileSync('demo/fixtures/f.json');
const g = 'docs/g.md';
const h = spawnSync('node', ['demo/benchmark_h.mjs', '--guard-probe']);
const hint = 'run node demo/benchmark_hint.mjs by hand';
`;
const refs = referencesOf('test/x.test.mjs', text);
assert.deepEqual(refs.code.sort(), [
'demo/benchmark_h.mjs', 'lib/b.mjs', 'test/a.mjs', 'test/c.mjs', 'test/d.mjs', 'test/e.mjs',
]);
// путь внутри фразы-подсказки — не ссылка: строка обязана быть путём целиком
assert.deepEqual(refs.data.sort(), ['demo/fixtures/f.json', 'docs/g.md']);
assert.ok(!stripComments('x.mjs', text).includes('comment.mjs'));
});
test('ссылки: test-build/*.js — это src/*.ts, компилируемый tsconfig.test.json', () => {
const refs = referencesOf('test/x.test.mjs', "import { f } from '../test-build/space-geometry.js';\n");
assert.deepEqual(refs.code, ['src/space-geometry.ts']);
});
test('ссылки: Python — пакеты репозитория, относительные модули relay, Path-цепочки', () => {
const text = `
from custom_components.houseplan.validation import CONFIG_SCHEMA
import tests_backend.pure_imports
from hp_relay.app import main
""" from custom_components.houseplan.ghost import x """
GOLDEN = REPO / "scripts" / "sh3d-convert" / "golden"
schema = (REPO / "scripts" / "config-schema.json").read_text()
`;
const refs = referencesOf('scripts/support-relay/tests/test_relay.py', text);
assert.ok(refs.code.includes('custom_components/houseplan/validation.py'));
assert.ok(refs.code.includes('tests_backend/pure_imports.py'));
assert.ok(refs.code.includes('scripts/support-relay/hp_relay/app.py'));
assert.ok(!refs.code.some((f) => f.includes('ghost')), 'docstring — не импорт');
assert.ok(refs.data.includes('scripts/sh3d-convert/golden'));
assert.ok(refs.data.includes('scripts/config-schema.json'));
});
test('замыкание: код транзитивно, данные — листья, каталог — runtime-файлы под ним', () => {
const files = {
'demo/smoke_a.mjs': "import './serve.mjs';\nconst x = 'demo/fixtures';\n",
'demo/serve.mjs': "import './compat.mjs';\n",
'demo/compat.mjs': "import '../scripts/helper.mjs';\n",
'scripts/helper.mjs': "export const h = 1; // import './never.mjs'\n",
'scripts/never.mjs': '',
'demo/fixtures/one.mjs': "import '../deep.mjs';\n",
'demo/fixtures/two.json': '{}',
'demo/fixtures/README.md': 'documentation',
'demo/fixtures/pic.png': 'binary',
'demo/deep.mjs': '',
};
const tracked = Object.keys(files).sort();
const parents = new Map();
const reached = closure('/virtual', ['demo/smoke_a.mjs'], { tracked, read: (f) => files[f], parents });
assert.deepEqual(reached, [
'demo/compat.mjs', 'demo/fixtures/one.mjs', 'demo/fixtures/two.json', 'demo/serve.mjs',
'demo/smoke_a.mjs', 'scripts/helper.mjs',
]);
// фикстура — данные: её собственный импорт (deep.mjs) не читается,
// картинка под каталогом не берётся, комментарий не ссылка
assert.ok(!reached.includes('demo/deep.mjs'));
assert.ok(!reached.includes('demo/fixtures/pic.png'));
assert.ok(!reached.includes('demo/fixtures/README.md'));
assert.ok(!reached.includes('scripts/never.mjs'));
assert.equal(parents.get('scripts/helper.mjs'), 'demo/compat.mjs');
});
// #573: overlay принятых эталонов принадлежит только golden. Строка-каталог
// `demo/golden` в корпусе отпечатка раскрывалась во ВСЕ текстовые файлы под
// ним, и индекс эталонов становился входом smoke, perf и каждого гарда через
// serve.mjs — приёмка 13 кадров на beta.3 перегнала 22 минуты чужой работы.
test('#573: раскрытие каталога не выдаёт overlay эталонов; явный корень и явная ссылка — выдают', () => {
const files = {
'demo/smoke_a.mjs': "import './serve.mjs';\n",
'demo/serve.mjs': "import '../scripts/source-fingerprint.mjs';\n",
'scripts/source-fingerprint.mjs': "const corpus = ['demo/fixtures', 'demo/golden'];\n",
'demo/golden/run.mjs': "const baselineRoot = 'demo/golden/baselines';\n",
'demo/golden/matrix.mjs': 'export const GOLDEN_SCENARIOS = [];\n',
'demo/golden/baselines/baselines-index.json': '{"scenarios":{}}',
'demo/golden/baselines/scene.png': 'binary',
'demo/golden/baselines/.gitkeep': '',
'test/golden-index.test.mjs': "const index = 'demo/golden/baselines/baselines-index.json';\n",
};
const tracked = Object.keys(files).sort();
const viaSmoke = closure('/virtual', ['demo/smoke_a.mjs'], { tracked, read: (f) => files[f] });
assert.ok(viaSmoke.includes('demo/golden/matrix.mjs'), 'код под demo/golden — по-прежнему вход');
assert.ok(!viaSmoke.some(isBaselineOverlay), `overlay не течёт через каталог: ${viaSmoke.join(', ')}`);
// golden сама называет каталог эталонов строкой — и всё равно получает их не
// раскрытием, а явным корнем manifest (CHECKS.golden.roots)
const viaGolden = closure('/virtual', ['demo/golden/run.mjs'], { tracked, read: (f) => files[f] });
assert.ok(!viaGolden.some(isBaselineOverlay), 'каталог overlay по строке — тоже не раскрывается');
assert.ok(CHECKS.golden.roots.includes('demo/golden/baselines/**'), 'эталоны входят в golden корнем');
assert.ok(!CHECKS.golden.roots.includes('demo/golden/**'), 'весь каталог golden не должен захватывать README');
// явная ссылка на файл overlay — честная зависимость, она остаётся
const viaTest = closure('/virtual', ['test/golden-index.test.mjs'], { tracked, read: (f) => files[f] });
assert.ok(viaTest.includes('demo/golden/baselines/baselines-index.json'));
assert.deepEqual(BASELINE_OVERLAY, ['demo/golden/baselines/**']);
assert.equal(isBaselineOverlay('demo/golden/baselines/baselines-index.json'), true);
assert.equal(isBaselineOverlay('demo/golden/matrix.mjs'), false);
});
test('#672: документация каталога не раскрывается, но явно читаемый Markdown остаётся входом', () => {
const files = {
'demo/runner.mjs': "const root = 'demo/harness';\n",
'demo/harness/runtime.mjs': 'export const runtime = true;\n',
'demo/harness/config.json': '{}\n',
'demo/harness/README.md': 'not an implicit input\n',
'test/read-doc.test.mjs': "readFileSync(new URL('../demo/harness/README.md', import.meta.url), 'utf8');\n",
};
const tracked = Object.keys(files).sort();
const implicit = closure('/virtual', ['demo/runner.mjs'], { tracked, read: (f) => files[f] });
assert.ok(implicit.includes('demo/harness/runtime.mjs'));
assert.ok(implicit.includes('demo/harness/config.json'));
assert.ok(!implicit.includes('demo/harness/README.md'));
const explicit = closure('/virtual', ['test/read-doc.test.mjs'], { tracked, read: (f) => files[f] });
assert.ok(explicit.includes('demo/harness/README.md'));
});
test('#672: README каталогов харнесса не выбирают проверки; реально читаемый README выбирает', () => {
const implicitDocs = [
p('demo', 'golden', 'README.md'),
p('demo', 'guard', 'README.md'),
p('demo', 'srv', 'reference', 'device-icons', 'README.md'),
];
for (const file of implicitDocs) {
const { affected, unknown } = checksAffectedBy([file], ROOT, { manifest: MANIFEST });
assert.deepEqual(unknown, [], file);
assert.deepEqual([...affected], [], `${file}: ${[...affected].join(', ')}`);
}
const readByReleaseGate = p('demo', 'performance', 'README.md');
assert.ok(MANIFEST.frontend.has(readByReleaseGate), 'явное чтение в release-gate.test.mjs — вход frontend');
assert.ok(MANIFEST.performance_smoke.has(readByReleaseGate), 'performance README остаётся входом performance_smoke');
});
test('#573: на живом дереве индекс эталонов — вход golden и ничьей другой реюзной job', () => {
// путь собран из кусков: литерал сделал бы индекс входом frontend через этот тест
const index = p('demo', 'golden', 'baselines', 'baselines-index.json');
for (const check of ['smoke', 'performance_smoke', 'geometry_parity', 'backend']) {
assert.ok(!MANIFEST[check].has(index), `${check} не читает эталоны`);
}
assert.ok(MANIFEST.golden.has(index));
});
test('замыкание останавливается на копиях бандла (класс D)', () => {
const files = {
'demo/smoke_a.mjs': "import '../custom_components/houseplan/frontend/houseplan-card.js';\n",
'custom_components/houseplan/frontend/houseplan-card.js': "import './houseplan-assets/x.js';\n",
'custom_components/houseplan/frontend/houseplan-assets/x.js': '',
};
const tracked = Object.keys(files).sort();
const spec = { entries: ['demo/smoke_a.mjs'], roots: [] };
const saved = CHECKS.__virtual;
CHECKS.__virtual = spec;
try {
const inputs = inputsOf('__virtual', '/virtual', { tracked, read: (f) => files[f] });
assert.deepEqual(inputs, ['demo/smoke_a.mjs']);
} finally {
if (saved) CHECKS.__virtual = saved; else delete CHECKS.__virtual;
}
});
test('каждая проверка объявлена, у тяжёлых job включён реюз, у остальных нет', () => {
assert.deepEqual(REUSE_JOBS, ['smoke', 'golden', 'performance_smoke', 'geometry_parity', 'backend']);
for (const name of CHECK_NAMES) {
assert.ok(MANIFEST[name].size > 0, `${name}: пустой manifest`);
assert.ok(Array.isArray(CHECKS[name].entries) && Array.isArray(CHECKS[name].roots), name);
}
assert.throws(() => inputsOf('nope', ROOT), /неизвестная проверка/);
});
test('§8.1 представители: каждая категория каждой тяжёлой job — её вход', () => {
const expect = {
geometry_parity: {
source: ['src/junction-limits.ts', 'src/space-geometry.ts',
'custom_components/houseplan/junction_limits.py', 'custom_components/houseplan/wall_segment_model.py'],
tests: ['tests_backend/junction_parity.py'],
fixtures: ['test/fixtures/junction-limits-parity.json'],
config: ['tsconfig.junction-parity.json', 'tsconfig.json'],
toolchain: ['package.json', 'package-lock.json', '.nvmrc', '.python-version',
'.github/workflows/validate.yml'],
protocol: ['scripts/fix-test-build.mjs', 'scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs'],
},
backend: {
source: ['custom_components/houseplan/websocket_api.py', 'scripts/support-relay/relay.py',
'scripts/support-relay/hp_relay/app.py', 'scripts/sh3d-convert/convert.mjs', 'scripts/dump-config-schema.py'],
tests: ['tests_backend/test_ha_websocket.py', 'tests_backend/conftest.py', 'scripts/support-relay/tests/test_relay.py'],
fixtures: ['scripts/sh3d-convert/golden/two-levels.space-1.json', 'scripts/config-schema.json',
'test/fixtures/real-plan-first-floor.json'],
config: ['pyproject.toml', 'pytest.ini', 'scripts/backend-coverage-baseline.txt'],
toolchain: ['tests_backend/requirements.txt', 'custom_components/houseplan/manifest.json', '.github/workflows/validate.yml'],
protocol: ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs', 'scripts/ci-proof.mjs'],
},
smoke: {
source: ['src/houseplan-card.ts', 'src/logic.ts'],
tests: ['demo/smoke_infinite_canvas.mjs', 'demo/guard/verify-guard.mjs', 'demo/benchmark_glow.mjs'],
fixtures: ['demo/fixtures/large-house.mjs', 'demo/fixtures/wall-draw-click.mjs'],
config: ['rollup.config.mjs', 'tsconfig.json'],
toolchain: ['package.json', 'package-lock.json', '.github/workflows/validate.yml'],
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs',
'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'scripts/smoke-select.mjs',
'scripts/ci-proof.mjs'],
},
golden: {
source: ['src/houseplan-card.ts'],
tests: ['demo/golden/run.mjs', 'demo/golden/matrix.mjs', 'demo/golden/harness.mjs'],
fixtures: ['demo/golden/baselines/geometry-view-dark-fit.png', 'demo/golden/baselines/baselines-index.json',
'demo/fixtures/visual-matrix.mjs'],
config: ['rollup.config.mjs', 'tsconfig.json'],
toolchain: ['package.json', '.github/workflows/validate.yml'],
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs',
'demo/editor-runtime-compat.mjs', 'scripts/ci-proof.mjs'],
},
performance_smoke: {
source: ['src/houseplan-card.ts'],
tests: ['demo/benchmark_glow.mjs', 'demo/benchmark_large_house.mjs', 'demo/performance/compare.mjs'],
fixtures: ['demo/fixtures/large-house.mjs', 'demo/performance/budgets-glow-smoke.json',
'demo/performance/budgets-isometric-smoke.json', 'demo/performance/budgets-interaction-smoke.json'],
config: ['rollup.config.mjs'],
toolchain: ['package.json', '.github/workflows/validate.yml'],
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/editor-runtime-compat.mjs',
'demo/performance/evaluate.mjs', 'scripts/ci-proof.mjs'],
},
};
for (const [job, categories] of Object.entries(expect)) {
for (const [category, files] of Object.entries(categories)) {
for (const file of files) {
assert.ok(MANIFEST[job].has(file), `${job}/${category}: ${file} не вход`);
const { affected, unknown } = checksAffectedBy([file], ROOT, { manifest: MANIFEST });
assert.ok(affected.has(job), `${job}/${category}: ${file} не классифицируется`);
assert.deepEqual(unknown, [], file);
}
}
}
});
test('#542: каждый динамический cross-runtime input выбирает backend без глобального fallback', () => {
const cases = [
[p('src', 'plan-optimizer.ts'), p('tests_backend', 'test_validation.py')],
[p('src', 'logic.ts'), p('tests_backend', 'test_support_package.py')],
[p('demo', 'fixtures', 'large-house.mjs'), p('tests_backend', 'test_validation.py')],
[p('demo', 'fixtures', 'visual-matrix.mjs'), p('tests_backend', 'test_validation.py')],
];
for (const [input, consumer] of cases) {
assert.ok(MANIFEST.backend.has(consumer), `${consumer}: consumer не входит в backend`);
assert.ok(MANIFEST.backend.has(input), `${consumer} читает ${input}, но manifest его потерял`);
const { affected, unknown } = checksAffectedBy([input], ROOT, { manifest: MANIFEST });
assert.ok(affected.has('backend'), `${input}: backend не выбран`);
assert.ok(affected.size < CHECK_NAMES.length, `${input}: сработал глобальный fallback вместо точного owner`);
assert.deepEqual(unknown, [], input);
}
const unrelated = p('demo', 'fixtures', 'wall-draw-click.mjs');
assert.ok(!MANIFEST.backend.has(unrelated), `${unrelated}: точные roots нельзя расширять до всего каталога`);
assert.ok(!checksAffectedBy([unrelated], ROOT, { manifest: MANIFEST }).affected.has('backend'));
});
test('#671: весь пакет мебели — точный вход frontend, а шрифты исключены явно', () => {
const furniture = p('assets', 'furniture', 'houseplan-0.4.1', 'svg', 'menu', 'air_conditioner.svg');
const font = p('assets', 'fonts', 'Roboto-Regular.ttf');
assert.deepEqual(GUARDED_DATA_ROOTS, ['assets']);
assert.ok(isGuardedInput(furniture));
assert.ok(MANIFEST.frontend.has(furniture), `${furniture}: frontend потерял пакет`);
const { affected, unknown } = checksAffectedBy([furniture], ROOT, { manifest: MANIFEST });
assert.deepEqual([...affected], ['frontend']);
assert.deepEqual(unknown, []);
assert.ok(isDeclaredNotAnInput(font), `${font}: ручной генератор должен быть явным исключением`);
assert.equal(MANIFEST.frontend.has(font), false);
});
test('#671: новый assets/** без владельца не молчит, а расширяет прогон', () => {
const orphan = p('assets', 'future-pack', 'new.bin');
const { affected, unknown } = checksAffectedBy([orphan], ROOT, { manifest: MANIFEST });
assert.deepEqual(unknown, [orphan]);
assert.deepEqual([...affected].sort(), [...CHECK_NAMES].sort());
const synthetic = coverage('/virtual', {
tracked: [orphan],
manifest: Object.fromEntries(CHECK_NAMES.map((name) => [name, new Set()])),
});
assert.deepEqual(synthetic.unknown, [orphan]);
});
test('§8.1 обратная проба (AC6): UI — не вход backend, backend — не вход браузерных job без причины', () => {
for (const file of ['src/houseplan-card.ts', 'src/houseplan-editor-runtime.ts', 'src/iso-overlays.ts', 'package.json']) {
assert.ok(!MANIFEST.backend.has(file), `backend зависит от ${file}`);
assert.ok(!checksAffectedBy([file], ROOT, { manifest: MANIFEST }).affected.has('backend'), file);
}
// smoke_infinite_canvas запускает backend-валидацию как процесс — это
// честная зависимость; но tests_backend/** браузерным job не нужны
for (const job of ['smoke', 'golden', 'performance_smoke']) {
assert.ok(![...MANIFEST[job]].some((f) => f.startsWith('tests_backend/')), `${job} читает tests_backend`);
}
for (const file of ['src/houseplan-card.ts', 'src/houseplan-editor-runtime.ts',
'custom_components/houseplan/websocket_api.py']) {
assert.ok(!MANIFEST.geometry_parity.has(file), `geometry_parity зависит от ${file}`);
}
});
test('§5.5 лист покрытия: ни одного неизвестного исполняемого файла, ни одной лишней записи NOT_AN_INPUT', () => {
const { unknown, declaredButCovered } = coverage(ROOT, { manifest: MANIFEST });
assert.deepEqual(unknown, [], 'исполняемый файл без хозяина: впишите в CHECKS или в NOT_AN_INPUT с причиной');
assert.deepEqual(declaredButCovered, [], 'запись NOT_AN_INPUT лишняя — файл и так вход');
for (const [glob, reason] of NOT_AN_INPUT) assert.ok(reason.length > 10, `${glob}: нужна причина`);
});
test('неизвестный вход расширяет до всех проверок и называется; документация и не-входы — нет', () => {
const ghost = p('scripts', 'ghost-gate.mjs');
const { affected, unknown } = checksAffectedBy([ghost], ROOT, { manifest: MANIFEST });
assert.deepEqual(unknown, [ghost]);
assert.deepEqual([...affected].sort(), [...CHECK_NAMES].sort());
assert.ok(isExecutableInput(ghost));
assert.ok(!isExecutableInput(p('docs', 'ghost.md')));
assert.ok(isDeclaredNotAnInput(p('demo', 'shot_sun.mjs')));
const quiet = checksAffectedBy([p('docs', 'ghost.md'), p('demo', 'shot_sun.mjs')], ROOT, { manifest: MANIFEST });
assert.deepEqual(quiet.unknown, []);
assert.equal(quiet.affected.size, 0);
});
test('CLI: --check печатает входы, --why объясняет цепочку, --coverage зелёный на текущем дереве', () => {
// fileURLToPath, не URL.pathname (#496): на Windows pathname даёт «/C:/…», Node ищет C:\C:\… .
const script = fileURLToPath(new URL('../scripts/check-inputs.mjs', import.meta.url));
const backend = execFileSync('node', [script, '--check=backend'], { encoding: 'utf8' }).trim().split('\n');
assert.ok(backend.includes('scripts/support-relay/relay.py'));
const why = execFileSync('node', [script, '--check=backend', '--why=scripts/sh3d-convert/convert.mjs'], { encoding: 'utf8' });
assert.match(why, /^scripts\/sh3d-convert\/convert\.mjs\n/);
assert.match(why, /tests_backend\/test_sh3d_convert\.py/);
const cov = execFileSync('node', [script, '--coverage'], { encoding: 'utf8' });
assert.equal(cov.trim(), '');
});