mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 21:01:21 +00:00
Every large-house sample mounts a new card that has visited only floors 1 and 2 before the twelve-switch cycle, so the cycle's second step was always the first visit to floor 3: 20 new clean-floor entries, and in 2.5D one Iso geometry entry plus one structural build. In large-house-interaction-v1 the editor series also moves the config epoch that keys the clean-floor cache, so floor 1 was cold as well. That one cold step was about half of switchCycleMs, which the README and the cycle comment describe as warmed navigation, and a 35% warm regression drowned in it. The runner now visits every fixture floor once in cycle order after the settings dialog closes, outside every timed and Long Task window, and returns to floor 2, so the cycle still starts with 2 -> 1. A guard snapshots the hot caches and the 2.5D structural build counter around the window and fails the sample when anything grew. Caches an older base lacks read as 0 and its null counter is not judged, so a v1.78.0 base still passes. Budgets, hardMaxMs, metric names, the report schema, profiles and the workflow are unchanged. Base and candidate are both measured by the candidate runner, so the comparison is unaffected; the absolute switchCycleMs level steps down, which the README now explains. A unit anchor pins the warm-up position and the guard message. Issue: #735 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
232 lines
13 KiB
JavaScript
232 lines
13 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { makeLargeHouseFixture } from '../demo/fixtures/large-house.mjs';
|
|
import {
|
|
ISOMETRIC_STAGE3_DENSE_PROFILE,
|
|
makeIsometricStage3DenseFixture,
|
|
} from '../demo/performance/isometric-stage3-dense-fixture.mjs';
|
|
|
|
const readWorkflow = (name) => readFileSync(
|
|
new URL(`../.github/workflows/${name}`, import.meta.url), 'utf8',
|
|
);
|
|
const readJson = (path) => JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8'));
|
|
|
|
test('ordinary Validate keeps only the bounded candidate performance smoke', () => {
|
|
const workflow = readWorkflow('validate.yml');
|
|
for (const contract of [
|
|
'performance_smoke:',
|
|
'--variants=60 --samples=3 --warmups=1',
|
|
'--absolute-only',
|
|
'budgets-glow-smoke.json',
|
|
'budgets-space-glow-smoke.json',
|
|
'cancel-in-progress: true',
|
|
]) assert.ok(workflow.includes(contract), `missing fast-gate contract: ${contract}`);
|
|
assert.ok(!workflow.includes('Capture base and candidate profiles'));
|
|
assert.ok(!workflow.includes('schedule:'));
|
|
});
|
|
|
|
test('full performance is isolated to stable, scheduled and manual entry points', () => {
|
|
const workflow = readWorkflow('performance.yml');
|
|
for (const contract of [
|
|
'name: Полные бенчмарки производительности',
|
|
'branches:',
|
|
'- main',
|
|
'paths-ignore:',
|
|
'- ".github/workflows/**"',
|
|
'- "docs/**"',
|
|
'schedule:',
|
|
'workflow_dispatch:',
|
|
'Capture base and candidate profile',
|
|
'profile:',
|
|
'- large-house',
|
|
'- isometric',
|
|
'- isometric-stage3',
|
|
'- plan-snap',
|
|
'- interaction',
|
|
'- blend',
|
|
'- overlay',
|
|
'- space-default',
|
|
'- space-glow',
|
|
'PROFILE: ${{ matrix.profile }}',
|
|
'name: full-performance-${{ matrix.profile }}',
|
|
'--samples=7 --warmups=1',
|
|
]) assert.ok(workflow.includes(contract), `missing full-gate contract: ${contract}`);
|
|
|
|
assert.ok(workflow.includes('if [ -f baseline/scripts/bundle-sync.mjs ]; then'));
|
|
assert.equal((workflow.match(/--samples=7 --warmups=1/g) || []).length, 18);
|
|
assert.equal((workflow.match(/--allow-stage2-base/g) || []).length, 1,
|
|
'only the Stage 3 comparison base may bypass the candidate-only DOM contract');
|
|
assert.ok(workflow.includes('budgets-isometric-stage3-dense.json'));
|
|
assert.equal((workflow.match(/--baseline-sha=/g) || []).length, 2);
|
|
assert.equal((workflow.match(/--candidate-sha=/g) || []).length, 2);
|
|
|
|
const release = readWorkflow('release.yml');
|
|
// #540: признак стабильного — тег кандидата, не поле события: тот же гейт
|
|
// работает и по `workflow_dispatch`, где события нет.
|
|
assert.ok(release.includes("if: ${{ needs.candidate.outputs.prerelease != 'true' }}"));
|
|
assert.ok(release.includes('--workflow=performance.yml --label="Полные бенчмарки производительности"'));
|
|
assert.ok(release.includes('test -s dist/houseplan-panel.js'));
|
|
assert.ok(release.includes('cp dist/houseplan-card.js houseplan.zip release-assets/'),
|
|
'the standalone release asset remains card-only; the panel ships through HACS zip');
|
|
assert.ok(!release.includes('softprops/action-gh-release'), 'one upload path (gh release upload into the draft), not two');
|
|
});
|
|
|
|
test('#160 Stage 3 dense fixture extends rather than mutates the historical witness', () => {
|
|
const historical = makeLargeHouseFixture();
|
|
const historicalSnapshot = structuredClone(historical);
|
|
const dense = makeIsometricStage3DenseFixture();
|
|
|
|
assert.equal(ISOMETRIC_STAGE3_DENSE_PROFILE, 'isometric-stage3-dense-v1');
|
|
assert.deepEqual(historical, historicalSnapshot);
|
|
assert.deepEqual(makeLargeHouseFixture(), historicalSnapshot);
|
|
assert.equal(dense.counts.devices, 200);
|
|
assert.equal(dense.counts.denseMarkers, 200);
|
|
assert.ok(dense.counts.decoratedMarkers > 0);
|
|
assert.equal(dense.counts.boundOpenings, dense.counts.floors * 3);
|
|
assert.ok(dense.config.markers.some((marker) => marker.display === 'value'));
|
|
assert.ok(dense.config.markers.some((marker) => marker.value_badge?.enabled));
|
|
assert.equal(Object.keys(dense.stage3Dense.pulseDeviceIdsBySpace).length,
|
|
dense.config.spaces.length);
|
|
assert.ok(dense.stage3Dense.decoratedDeviceIds
|
|
.some((id) => dense.config.settings.new_device_ids.includes(id)));
|
|
assert.deepEqual(dense.stage3Dense.expectedOpeningKinds, ['door', 'window', 'gate']);
|
|
assert.ok(Object.values(dense.states).some((state) => Number.isFinite(state.attributes?.lqi)));
|
|
for (const space of dense.config.spaces) {
|
|
const pulseDeviceId = dense.stage3Dense.pulseDeviceIdsBySpace[space.id];
|
|
assert.equal(dense.layout[pulseDeviceId]?.s, space.id);
|
|
assert.equal(dense.config.markers.find((marker) => marker.id === pulseDeviceId)
|
|
?.display, 'icon_ripple');
|
|
const pulseEntity = Object.values(dense.entities)
|
|
.find((entity) => entity.device_id === pulseDeviceId);
|
|
assert.match(pulseEntity?.entity_id || '', /^fan\./);
|
|
assert.equal(dense.states[pulseEntity.entity_id]?.state, 'on');
|
|
assert.ok(dense.config.settings.new_device_ids
|
|
.some((id) => dense.layout[id]?.s === space.id), `${space.id} lacks new-device facet`);
|
|
for (const setting of ['label_temp', 'label_hum', 'label_lqi', 'label_light'])
|
|
assert.equal(space.settings[setting], true, `${space.id} lacks ${setting}`);
|
|
assert.ok(space.openings.some((opening) => opening.type === 'window' && opening.contact));
|
|
assert.ok(space.openings.some((opening) => opening.type === 'door'
|
|
&& opening.contact && opening.lock));
|
|
assert.ok(space.openings.some((opening) => opening.type === 'gate'
|
|
&& opening.contact && opening.lock));
|
|
}
|
|
});
|
|
|
|
test('#160 Stage 3 budget preserves every historical common ceiling', () => {
|
|
const historical = readJson('../demo/performance/budgets-large-house-isometric.json');
|
|
const dense = readJson('../demo/performance/budgets-isometric-stage3-dense.json');
|
|
assert.equal(dense.profile, ISOMETRIC_STAGE3_DENSE_PROFILE);
|
|
for (const [metric, budget] of Object.entries(historical.timings))
|
|
assert.deepEqual(dense.timings[metric], budget, `changed historical limit for ${metric}`);
|
|
for (const key of ['longTasks', 'heap', 'cacheEntries', 'cacheGrowth', 'renderedDevices'])
|
|
assert.deepEqual(dense[key], historical[key], `changed historical ${key} contract`);
|
|
for (const metric of ['openingUpdateMs', 'overlayInteractionMs']) {
|
|
assert.equal(dense.timings[metric].maxRegressionRatio, 0.2);
|
|
assert.ok(dense.timings[metric].noiseAllowanceMs
|
|
<= historical.timings.stateUpdateMs.noiseAllowanceMs);
|
|
assert.ok(dense.timings[metric].hardMaxMs <= historical.timings.stateUpdateMs.hardMaxMs);
|
|
}
|
|
});
|
|
|
|
test('#570 current Stage 4 runner fails closed on the agreed observable DOM contract', () => {
|
|
const runner = readFileSync(new URL('../demo/benchmark_large_house.mjs', import.meta.url), 'utf8');
|
|
for (const contract of [
|
|
'data-hp-iso-stage',
|
|
'data-hp-iso-structural-builds',
|
|
'data-hp-iso-overlay-kind',
|
|
'data-hp-iso-raised',
|
|
'data-hp-iso-nudged',
|
|
'data-hp-iso-material-def',
|
|
'totalRootsByKind',
|
|
'stage3RootsByKind',
|
|
'openingSurfaceCounts',
|
|
'facetCounts',
|
|
'definitionCounts',
|
|
'no shared texture pattern',
|
|
'no shared shadow filter',
|
|
'no observable',
|
|
'Stage 4 roots',
|
|
'opening surfaces',
|
|
'entered Flat fallback',
|
|
'structural build counter is absent',
|
|
'raised overlay nudged although #713 lifts every tile by one rise',
|
|
'isoStructuralBuilds',
|
|
'haUpdateDelta',
|
|
'performed a structural rebuild for an HA-only state update',
|
|
'requires an exact git source SHA',
|
|
]) assert.ok(runner.includes(contract), `missing Stage 4 runner contract: ${contract}`);
|
|
assert.match(runner,
|
|
/\[data-hp-iso-overlay-kind\]\[data-hp-iso-floor\]\[data-hp-iso-visual\]/,
|
|
'Stage 4 counts must use interactive roots, not duplicated inert SVG diagnostics');
|
|
assert.match(runner, /raisedVacuumCount/,
|
|
'floor-bound vacuum must have an independent negative raised-state check');
|
|
assert.match(runner, /stage3 !== total/,
|
|
'every rendered device, room label and lock root must participate in Stage 4');
|
|
assert.match(runner, /expectedOpeningKinds\.map/,
|
|
'door, window and gate surfaces must contribute to the measured Stage 4 scene');
|
|
assert.match(runner, /pulseDeviceIdsBySpace\?\.\[card\._space\]/,
|
|
'every switched floor must retain its own observable pulse witness');
|
|
assert.match(runner,
|
|
/if \(snapshot\.effectiveProjection !== 'iso'\) failures\.push\('effective projection is not iso'\);/,
|
|
'a dense Stage 4 report must reject Flat fallback instead of timing it as success');
|
|
assert.match(runner,
|
|
/requiresIsoStructuralBuildCounter[\s\S]*?afterStateIsoStructuralBuilds !== steadyIsoStructuralBuilds/,
|
|
'both isometric profiles must reject a structural rebuild on the HA-only window');
|
|
});
|
|
|
|
test('#735 switchCycle times warmed navigation and fails on a floor build inside its window', () => {
|
|
// Every sample mounts a new card that has seen only floors 1 and 2 before
|
|
// the cycle, so the first visit to floor 3 (and, in the interaction
|
|
// profile, a rebuilt floor 1) used to dominate switchCycleMs. The runner
|
|
// visits every fixture floor once after the settings dialog closes and
|
|
// before the timed window, then guards the window against any build.
|
|
const runner = readFileSync(new URL('../demo/benchmark_large_house.mjs', import.meta.url), 'utf8');
|
|
const settingsClosed = runner.indexOf('card._settingsDialog = null;');
|
|
const cycleStart = runner.indexOf('const switchCycle = await duration(');
|
|
assert.ok(settingsClosed > 0 && cycleStart > settingsClosed,
|
|
'the switch cycle must follow the closed settings dialog');
|
|
const warmup = runner.slice(settingsClosed, cycleStart);
|
|
assert.match(warmup,
|
|
/for \(let floor = 1; floor <= fixture\.counts\.floors; floor\+\+\) \{\s*card\._pickSpace\(`perf-floor-\$\{floor\}`\);\s*await card\.updateComplete;\s*await frame\(\);\s*\}/,
|
|
'every fixture floor must be visited once before the switchCycle window');
|
|
assert.match(warmup,
|
|
/card\._pickSpace\('perf-floor-2'\);\s*await card\.updateComplete;\s*await frame\(\);\s*const switchCycleCachesBefore = cacheSnapshot\(card\);\s*const switchCycleBuildsBefore = isoStructuralBuildCount\(card\);\s*$/,
|
|
'the cycle must still start from floor 2 with the guard snapshot taken last');
|
|
assert.ok(!warmup.includes('duration(') && !warmup.includes('startLongTaskWindow('),
|
|
'the warm-up must stay outside every timed and Long Task window');
|
|
const afterCycle = runner.slice(cycleStart, runner.indexOf('await forceGc();', cycleStart));
|
|
for (const contract of [
|
|
'const switchCycleCachesAfter = cacheSnapshot(card);',
|
|
'const switchCycleBuildsAfter = isoStructuralBuildCount(card);',
|
|
'switchCycleCachesAfter[key] > switchCycleCachesBefore[key]',
|
|
'switchCycleBuildsBefore != null && switchCycleBuildsAfter !== switchCycleBuildsBefore',
|
|
'${profile} switchCycle built a floor inside the window: ',
|
|
]) assert.ok(afterCycle.includes(contract), `missing #735 switchCycle guard: ${contract}`);
|
|
});
|
|
|
|
test('#347: a rewritten before forces the full run instead of guessing the range', () => {
|
|
// Force-push kills github.event.before; the merge-base fallback then
|
|
// guessed a range that hid a real custom_components/** diff behind two doc
|
|
// files, and the heavy jobs silently skipped while the run stayed green —
|
|
// the #171/#207 class of silent pass. The contract: a non-zero before that
|
|
// no longer exists switches classification to an unconditional full run
|
|
// with a loud step-summary note, and the merge-base fallback remains ONLY
|
|
// for the genuinely new branch (zero before).
|
|
const workflow = readWorkflow('validate.yml');
|
|
const classify = workflow.slice(
|
|
workflow.indexOf('Классификация изменённых файлов'),
|
|
workflow.indexOf('reuse:'),
|
|
);
|
|
// Полный прогон — `classify-changes.mjs --all`: все выходы true (#473 вынес
|
|
// список выходов из inline-shell, ветка force-push идёт тем же путём).
|
|
assert.ok(
|
|
/force-push[\s\S]*?node scripts\/classify-changes\.mjs --all >> "\$GITHUB_OUTPUT"/.test(classify),
|
|
'мёртвый before обязан включать полный прогон, не merge-base-угадывание');
|
|
assert.ok(classify.includes('GITHUB_STEP_SUMMARY'),
|
|
'пропуск классификации обязан быть громким в summary');
|
|
const fallback = classify.slice(classify.indexOf('Новая ветка'));
|
|
assert.ok(!fallback.includes('cat-file'),
|
|
'merge-base-фолбэк остаётся только для нулевого before — без повторной проверки существования');
|
|
});
|