mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
ci: исполнять TS/Python parity на чистом runner (#548)
Issue: #548 User-Visible: no
This commit is contained in:
@@ -122,7 +122,7 @@ test('замыкание останавливается на копиях бан
|
||||
});
|
||||
|
||||
test('каждая проверка объявлена, у тяжёлых job включён реюз, у остальных нет', () => {
|
||||
assert.deepEqual(REUSE_JOBS, ['smoke', 'golden', 'performance_smoke', 'backend']);
|
||||
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);
|
||||
@@ -132,6 +132,16 @@ test('каждая проверка объявлена, у тяжёлых job в
|
||||
|
||||
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'],
|
||||
@@ -216,6 +226,10 @@ test('§8.1 обратная проба (AC6): UI — не вход backend, bac
|
||||
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', () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ const names = {
|
||||
smokeDone: 'Смоки: все шарды зелёные',
|
||||
golden: 'Golden-кадры против принятых эталонов',
|
||||
performance: 'Перф-смок: бюджет времени кадра',
|
||||
geometryParity: 'Геометрия: TS/Python parity исполнена',
|
||||
backend: 'Бэкенд: pytest в Home Assistant',
|
||||
};
|
||||
|
||||
@@ -33,7 +34,7 @@ const smokeJobs = () => Array.from({ length: 3 }, (_, index) => (
|
||||
|
||||
export function proofFixture({
|
||||
id = 10, attempt = 2, full = true, mutants = true,
|
||||
frontend = true, backend = true, integration = true, conclusion = 'success',
|
||||
frontend = true, geometryParity = true, backend = true, integration = true, conclusion = 'success',
|
||||
} = {}) {
|
||||
const needs = {
|
||||
preflight: { result: 'success' },
|
||||
@@ -41,7 +42,8 @@ export function proofFixture({
|
||||
result: 'success',
|
||||
outputs: {
|
||||
heavy: String(full), mutants_requested: String(mutants),
|
||||
frontend: String(frontend), backend: String(backend), integration: String(integration),
|
||||
frontend: String(frontend), geometry_parity: String(geometryParity),
|
||||
backend: String(backend), integration: String(integration),
|
||||
},
|
||||
},
|
||||
reuse: { result: 'success', outputs: {} },
|
||||
@@ -53,6 +55,7 @@ export function proofFixture({
|
||||
smoke_done: { result: full ? 'success' : 'skipped' },
|
||||
golden: { result: full ? 'success' : 'skipped' },
|
||||
performance_smoke: { result: full ? 'success' : 'skipped' },
|
||||
geometry_parity: { result: geometryParity ? 'success' : 'skipped' },
|
||||
backend: { result: backend ? 'success' : 'skipped' },
|
||||
};
|
||||
const proof = buildCiProof({
|
||||
@@ -64,6 +67,7 @@ export function proofFixture({
|
||||
if (integration) jobs.push(success(names.hacs), success(names.hassfest));
|
||||
if (mutants) jobs.push(...mutantJobs());
|
||||
if (full) jobs.push(...smokeJobs(), success(names.smokeDone), success(names.golden), success(names.performance));
|
||||
if (geometryParity) jobs.push(success(names.geometryParity));
|
||||
if (backend) jobs.push(success(names.backend));
|
||||
const run = {
|
||||
databaseId: id, attempt, status: 'completed', conclusion,
|
||||
@@ -80,7 +84,7 @@ test('#541: proof records candidate identity, request and exact required check s
|
||||
});
|
||||
assert.deepEqual(fixture.proof.requiredChecks, [
|
||||
'preflight', 'changes', 'reuse', 'frontend', 'integration', 'mutants',
|
||||
'smoke', 'golden', 'performance_smoke', 'backend',
|
||||
'smoke', 'golden', 'performance_smoke', 'geometry_parity', 'backend',
|
||||
]);
|
||||
assert.deepEqual(fixture.proof.requiredChecks, requiredCheckIds(fixture.proof));
|
||||
});
|
||||
|
||||
@@ -68,6 +68,20 @@ test('#492 §1.2: relay, converter, schema и pyproject запускают backe
|
||||
}
|
||||
});
|
||||
|
||||
test('#548: parity выбирается по обоим зеркалам, fixture и toolchain, но не по чужому UI', () => {
|
||||
for (const file of ['src/junction-limits.ts', 'src/space-geometry.ts',
|
||||
'custom_components/houseplan/junction_limits.py',
|
||||
'custom_components/houseplan/wall_segment_model.py',
|
||||
'tests_backend/junction_parity.py', 'test/fixtures/junction-limits-parity.json',
|
||||
'tsconfig.junction-parity.json', 'package-lock.json', '.nvmrc', '.python-version']) {
|
||||
const out = classify([file]);
|
||||
assert.equal(out.geometry_parity, 'true', file);
|
||||
assert.deepEqual(out.unknown, [], file);
|
||||
}
|
||||
assert.equal(classify(['src/houseplan-card.ts']).geometry_parity, 'false');
|
||||
assert.equal(classify(['custom_components/houseplan/websocket_api.py']).geometry_parity, 'false');
|
||||
});
|
||||
|
||||
test('#492 AC6: правка UI не классифицируется как backend', () => {
|
||||
const out = classify(['src/houseplan-card.ts', 'src/houseplan-editor-runtime.ts']);
|
||||
assert.equal(out.backend, 'false');
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"cell_cm": 5,
|
||||
"cases": [
|
||||
{
|
||||
"name": "angle-14",
|
||||
"segments": [
|
||||
{ "ray_degrees": 0, "length_cm": 100, "thickness_cm": 15 },
|
||||
{ "ray_degrees": 14, "length_cm": 100, "thickness_cm": 15 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "angle-15",
|
||||
"segments": [
|
||||
{ "ray_degrees": 0, "length_cm": 100, "thickness_cm": 15 },
|
||||
{ "ray_degrees": 15, "length_cm": 100, "thickness_cm": 15 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "valence-7",
|
||||
"segments": [
|
||||
{ "ray_degrees": 0, "length_cm": 100, "thickness_cm": 15 },
|
||||
{ "ray_degrees": 51, "length_cm": 100, "thickness_cm": 15 },
|
||||
{ "ray_degrees": 102, "length_cm": 100, "thickness_cm": 15 },
|
||||
{ "ray_degrees": 153, "length_cm": 100, "thickness_cm": 15 },
|
||||
{ "ray_degrees": 204, "length_cm": 100, "thickness_cm": 15 },
|
||||
{ "ray_degrees": 255, "length_cm": 100, "thickness_cm": 15 },
|
||||
{ "ray_degrees": 306, "length_cm": 100, "thickness_cm": 15 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "length-19",
|
||||
"segments": [
|
||||
{ "a": { "cm": [0, 0] }, "b": { "cm": [19, 0] }, "thickness_cm": 15 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "length-run",
|
||||
"segments": [
|
||||
{ "a": { "cm": [0, 0] }, "b": { "cm": [10, 0] }, "thickness_cm": 15 },
|
||||
{ "a": { "cm": [10, 0] }, "b": { "cm": [20, 0] }, "thickness_cm": 15 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "length-thickness",
|
||||
"segments": [
|
||||
{ "a": { "cm": [0, 0] }, "b": { "cm": [25, 0] }, "thickness_cm": 30 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "distance-4",
|
||||
"segments": [
|
||||
{ "a": { "cm": [0, 0] }, "b": { "cm": [300, 0] }, "thickness_cm": 15 },
|
||||
{ "a": { "cm": [0, 4] }, "b": { "cm": [300, 4] }, "thickness_cm": 15 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tee",
|
||||
"segments": [
|
||||
{ "a": { "cm": [0, 0] }, "b": { "cm": [300, 0] }, "thickness_cm": 15 },
|
||||
{ "a": { "cm": [150, 0] }, "b": { "cm": [150, 300] }, "thickness_cm": 15 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "angle-15-exact",
|
||||
"segments": [
|
||||
{ "ray_degrees": 0, "length_cm": 100, "thickness_cm": 15 },
|
||||
{ "ray_degrees": 15, "length_cm": 100, "thickness_cm": 15 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "length-20-exact",
|
||||
"segments": [
|
||||
{ "a": { "cm": [0, 0] }, "b": { "cm": [20, 0] }, "thickness_cm": 15 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "filler-run",
|
||||
"segments": [
|
||||
{ "a": { "cm": [0, 0] }, "b": { "cm": [349, 0] }, "thickness_cm": 30 },
|
||||
{ "a": { "cm": [349, 0] }, "b": { "cm": [354, 0] }, "thickness_cm": 30 },
|
||||
{ "a": { "cm": [354, 0] }, "b": { "cm": [554, 0] }, "thickness_cm": 20 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "distance-5-exact",
|
||||
"segments": [
|
||||
{ "a": { "cm": [0, 0] }, "b": { "cm": [300, 0] }, "thickness_cm": 15 },
|
||||
{ "a": { "cm": [0, 5] }, "b": { "cm": [300, 5] }, "thickness_cm": 15 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "debris-node",
|
||||
"segments": [
|
||||
{ "a": { "units": [-1e-8, 0] }, "b": { "cm": [300, 0] }, "thickness_cm": 15 },
|
||||
{ "a": { "cm": [0, 0] }, "b": { "cm": [0, 300] }, "thickness_cm": 15 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "duplicate-wall",
|
||||
"segments": [
|
||||
{ "a": { "cm": [0, 0] }, "b": { "cm": [300, 0] }, "thickness_cm": 15 },
|
||||
{ "a": { "cm": [0, 0] }, "b": { "cm": [300, 0] }, "thickness_cm": 15 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "collinear-fork",
|
||||
"segments": [
|
||||
{ "a": { "cm": [0, 0] }, "b": { "cm": [100, 0] }, "thickness_cm": 15 },
|
||||
{ "a": { "cm": [100, 0] }, "b": { "cm": [160, 0] }, "thickness_cm": 15 },
|
||||
{ "a": { "cm": [100, 0] }, "b": { "cm": [220, 0], "units_offset": [0, 1e-9] }, "thickness_cm": 15 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -27,13 +27,19 @@ const makeTree = () => {
|
||||
put('package-lock.json', '{"lockfileVersion":3}\n');
|
||||
put('rollup.config.mjs', 'export default {};\n');
|
||||
put('tsconfig.json', '{}\n');
|
||||
put('tsconfig.junction-parity.json', '{}\n');
|
||||
put('.nvmrc', '22\n');
|
||||
put('.python-version', '3.14\n');
|
||||
put('.github/workflows/validate.yml', 'name: Validate\n');
|
||||
put('scripts/source-fingerprint.mjs', '// pinned by the real repo copy\n');
|
||||
put('scripts/gate-reuse.mjs', '// reuse protocol\n');
|
||||
put('scripts/check-inputs.mjs', '// manifest\n');
|
||||
put('scripts/fix-test-build.mjs', '// esm import fixer\n');
|
||||
put('src/card.ts', "export const CARD_VERSION = '1.0.0';\n");
|
||||
put('src/plan-optimizer.ts', 'export const PLAN_MODEL_VERSION = 1;\n');
|
||||
put('src/logic.ts', 'export const logic = 1;\n');
|
||||
put('src/junction-limits.ts', "import './space-geometry';\nexport const MIN = 5;\n");
|
||||
put('src/space-geometry.ts', 'export const GRID_STEP_N = 1 / 240;\n');
|
||||
put('demo/serve.mjs', "import './bundle-freshness.mjs';\n");
|
||||
put('demo/bundle-freshness.mjs', 'export const fresh = 1;\n');
|
||||
put('demo/srv/demo.html', '<div id="host"></div>\n');
|
||||
@@ -51,7 +57,12 @@ const makeTree = () => {
|
||||
put('demo/performance/compare.mjs', 'export const cmp = 1;\n');
|
||||
put('demo/performance/budgets-glow-smoke.json', '{"hardMaxMs":1}\n');
|
||||
put('tests_backend/test_pure.py', 'from custom_components.houseplan.store import VERSION\n\ndef test_x():\n assert True\n');
|
||||
put('tests_backend/junction_parity.py', 'from pure_imports import load_pure\n');
|
||||
put('tests_backend/pure_imports.py', 'def load_pure():\n pass\n');
|
||||
put('tests_backend/requirements.txt', 'pytest\n');
|
||||
put('test/fixtures/junction-limits-parity.json', '{"schema_version":1}\n');
|
||||
put('custom_components/houseplan/junction_limits.py', 'from .wall_segment_model import VERSION\n');
|
||||
put('custom_components/houseplan/wall_segment_model.py', 'VERSION = 1\n');
|
||||
put('custom_components/houseplan/store.py', 'VERSION = 1\n');
|
||||
put('custom_components/houseplan/manifest.json', '{"domain":"houseplan","version":"1.0.0"}\n');
|
||||
put('custom_components/houseplan/frontend/houseplan-card.js', 'built bundle\n');
|
||||
@@ -71,7 +82,7 @@ const keys = (dir) => Object.fromEntries(JOBS.map((job) => [job, reuseKey(dir, j
|
||||
test('every heavy job has non-empty inputs and its own key', () => {
|
||||
const { dir } = makeTree();
|
||||
try {
|
||||
assert.deepEqual(JOBS, ['smoke', 'golden', 'performance_smoke', 'backend']);
|
||||
assert.deepEqual(JOBS, ['smoke', 'golden', 'performance_smoke', 'geometry_parity', 'backend']);
|
||||
const k = keys(dir);
|
||||
for (const job of JOBS) assert.ok(harnessFiles(dir, job).length > 0, job);
|
||||
assert.equal(new Set(Object.values(k)).size, JOBS.length, 'ключи job обязаны различаться');
|
||||
@@ -152,6 +163,7 @@ test('harness edits are isolated to their own job (#208)', () => {
|
||||
only(['golden'])(() => put('demo/golden/baselines/one.png',
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x02])));
|
||||
only(['performance_smoke'])(() => put('demo/performance/compare.mjs', 'export const cmp = 2;\n'));
|
||||
only(['geometry_parity'])(() => put('test/fixtures/junction-limits-parity.json', '{"schema_version":1,"changed":true}\n'));
|
||||
only(['backend'])(() => put('custom_components/houseplan/store.py', 'VERSION = 2\n'));
|
||||
// Протокол браузерного харнеса общий для трёх job (#492 §5.1 protocol).
|
||||
only(['smoke', 'golden', 'performance_smoke'])(() => put('demo/serve.mjs', "import './bundle-freshness.mjs';\n// harness\n"));
|
||||
|
||||
@@ -91,7 +91,7 @@ test('джобы с полной историей качают её без бл
|
||||
// job `changes`, поэтому имя job ищется только с начала строки.
|
||||
const start = workflow.indexOf(`\n ${job}:\n`);
|
||||
assert.ok(start > 0, `нет job ${job}`);
|
||||
const chunk = workflow.slice(start, start + 1400);
|
||||
const chunk = workflow.slice(start, start + 1800);
|
||||
assert.match(chunk, /fetch-depth: 0, filter: 'blob:none'/,
|
||||
`${job} обязана качать историю без блобов`);
|
||||
}
|
||||
@@ -193,7 +193,7 @@ test('перф-смок добавляет профиль ровно при св
|
||||
assert.ok(!changes.includes("printf 'frontend=true"), 'ручной список выходов в fallback-е запрещён');
|
||||
|
||||
const start = workflow.indexOf('\n performance_smoke:\n');
|
||||
const job = workflow.slice(start, workflow.indexOf('\n backend:\n', start));
|
||||
const job = workflow.slice(start, workflow.indexOf('\n geometry_parity:\n', start));
|
||||
assert.match(job, /needs: \[changes, frontend, reuse\]/);
|
||||
const iso = job.slice(job.indexOf('Изометрический профиль по диффу'), job.indexOf('Профиль взаимодействия по диффу'));
|
||||
assert.match(iso, /if: needs\.changes\.outputs\.perf_iso == 'true'/);
|
||||
@@ -208,6 +208,34 @@ test('перф-смок добавляет профиль ровно при св
|
||||
assert.ok(!/\n\s+if:/.test(glow), 'glow-профили гоняются всегда');
|
||||
});
|
||||
|
||||
test('#548: TS/Python parity — отдельная clean-runner job без тихого skip', () => {
|
||||
const workflow = read('validate.yml');
|
||||
const start = workflow.indexOf('\n geometry_parity:\n');
|
||||
const job = workflow.slice(start, workflow.indexOf('\n backend:\n', start));
|
||||
assert.ok(start > 0, 'нет job geometry_parity');
|
||||
assert.match(job, /needs: \[changes, reuse\]/);
|
||||
assert.match(job, /if: needs\.changes\.outputs\.geometry_parity == 'true'/);
|
||||
assert.match(job, /node-version-file: \.nvmrc/);
|
||||
assert.match(job, /python-version-file: \.python-version/);
|
||||
assert.match(job, /npx --no-install tsc -p tsconfig\.junction-parity\.json/);
|
||||
assert.match(job, /python tests_backend\/junction_parity\.py --build-dir=test-build\/junction-parity/);
|
||||
assert.match(job, /reuse-geometry_parity-\$\{\{ needs\.reuse\.outputs\.geometry_parity_key \}\}/);
|
||||
assert.ok(!/pytest\.skip|continue-on-error:[^\n]*\n[^]*junction_parity\.py/.test(job),
|
||||
'паритет не может становиться зелёным через skip/continue-on-error');
|
||||
|
||||
const changes = workflow.slice(workflow.indexOf('\n changes:\n'), workflow.indexOf('\n reuse:\n'));
|
||||
assert.match(changes, /geometry_parity: \$\{\{ steps\.classify\.outputs\.geometry_parity \}\}/);
|
||||
const reuse = workflow.slice(workflow.indexOf('\n reuse:\n'), workflow.indexOf('\n hacs:\n'));
|
||||
assert.match(reuse, /geometry_parity_key: \$\{\{ steps\.keys\.outputs\.geometry_parity \}\}/);
|
||||
assert.match(reuse, /waive geometry_parity "\$GEOMETRY_PARITY"/);
|
||||
|
||||
const oldPytestHome = readFileSync(
|
||||
new URL('../tests_backend/test_junction_limits.py', import.meta.url), 'utf8',
|
||||
);
|
||||
assert.doesNotMatch(oldPytestHome, /pytest\.skip\([^\n]*test-build/,
|
||||
'старый optional parity pytest не должен возвращаться рядом с обязательной job');
|
||||
});
|
||||
|
||||
test('ключ reuse перф-смока различает наборы профилей (#473 AC5)', () => {
|
||||
const workflow = read('validate.yml');
|
||||
const reuse = workflow.slice(workflow.indexOf('\n reuse:\n'), workflow.indexOf('\n hacs:\n'));
|
||||
@@ -468,7 +496,7 @@ test('#541: Validate всегда публикует proof точной попы
|
||||
assert.match(proof, /if: always\(\)/, 'proof создаётся и на красном прогоне');
|
||||
for (const dependency of [
|
||||
'preflight', 'changes', 'reuse', 'hacs', 'hassfest', 'changed_mutants',
|
||||
'frontend', 'smoke', 'smoke_done', 'golden', 'performance_smoke', 'backend',
|
||||
'frontend', 'smoke', 'smoke_done', 'golden', 'performance_smoke', 'geometry_parity', 'backend',
|
||||
]) assert.match(proof, new RegExp(`needs: \\[[^\\n]*\\b${dependency}\\b`), dependency);
|
||||
assert.match(proof, /CANDIDATE_SHA: \$\{\{ github\.sha \}\}/);
|
||||
assert.match(proof, /CANDIDATE_TREE: \$\{\{ steps\.candidate\.outputs\.tree \}\}/);
|
||||
@@ -480,12 +508,12 @@ test('#541: Validate всегда публикует proof точной попы
|
||||
assert.match(proof, /name: ci-proof-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/);
|
||||
|
||||
const reuse = workflow.slice(workflow.indexOf('\n reuse:\n'), workflow.indexOf('\n hacs:\n'));
|
||||
for (const id of ['smoke', 'golden', 'performance_smoke', 'backend']) {
|
||||
for (const id of ['smoke', 'golden', 'performance_smoke', 'geometry_parity', 'backend']) {
|
||||
assert.match(reuse, new RegExp(`${id}_source_run:`), `${id}: source run output`);
|
||||
assert.match(reuse, new RegExp(`${id}_source_attempt:`), `${id}: source attempt output`);
|
||||
assert.match(reuse, new RegExp(`${id}_source_sha:`), `${id}: source SHA output`);
|
||||
}
|
||||
assert.equal((reuse.match(/node scripts\/ci-proof\.mjs --marker=\.reuse-marker/g) || []).length, 4);
|
||||
assert.equal((reuse.match(/node scripts\/ci-proof\.mjs --marker=\.reuse-marker/g) || []).length, 5);
|
||||
assert.equal(reuse.includes('lookup-only: true'), false,
|
||||
'marker contents must be restored and verified, not reduced to a cache-hit bit');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user