diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index e71a69da..aa279bd5 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -40,6 +40,7 @@ jobs: profile: - large-house - isometric + - isometric-backdrop - isometric-stage3 - plan-snap - interaction @@ -134,6 +135,10 @@ jobs: npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json ;; + isometric-backdrop) + npm run benchmark:large-house-isometric-backdrop -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-backdrop-baseline.json + npm run benchmark:large-house-isometric-backdrop -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-backdrop-candidate.json + ;; isometric-stage3) npm run benchmark:isometric-stage3-dense -- --allow-stage2-base --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-baseline.json npm run benchmark:isometric-stage3-dense -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-candidate.json @@ -188,6 +193,9 @@ jobs: isometric) npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-comparison.json ;; + isometric-backdrop) + npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric-backdrop.json --baseline=../artifacts/performance/isometric-backdrop-baseline.json --candidate=../artifacts/performance/isometric-backdrop-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-backdrop-comparison.json + ;; isometric-stage3) npm run benchmark:compare -- --budgets=demo/performance/budgets-isometric-stage3-dense.json --baseline=../artifacts/performance/isometric-stage3-baseline.json --candidate=../artifacts/performance/isometric-stage3-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-stage3-comparison.json ;; diff --git a/demo/benchmark_large_house.mjs b/demo/benchmark_large_house.mjs index 62f32c57..1572abcb 100644 --- a/demo/benchmark_large_house.mjs +++ b/demo/benchmark_large_house.mjs @@ -22,7 +22,7 @@ const targetRoot = resolve(valueArg('target-root') ?? '.'); const profile = valueArg('profile') ?? 'large-house-v1'; if (![ 'large-house-v1', 'large-house-isometric-v1', ISOMETRIC_STAGE3_DENSE_PROFILE, - 'large-house-plan-snap-v1', 'large-house-interaction-v1', + 'large-house-plan-snap-v1', 'large-house-interaction-v1', 'large-house-isometric-backdrop-v1', ].includes(profile)) throw new Error(`unknown large-house profile: ${profile}`); const stage3Dense = profile === ISOMETRIC_STAGE3_DENSE_PROFILE; @@ -30,7 +30,8 @@ const allowStage2Base = process.argv.includes('--allow-stage2-base'); if (allowStage2Base && !stage3Dense) throw new Error('--allow-stage2-base is valid only for the Stage 3 dense profile'); const requireStage3 = stage3Dense && !allowStage2Base; -const isometric = profile === 'large-house-isometric-v1' || stage3Dense; +const backdrop = profile === 'large-house-isometric-backdrop-v1'; +const isometric = profile === 'large-house-isometric-v1' || stage3Dense || backdrop; const planSnap = profile === 'large-house-plan-snap-v1'; const interaction = profile === 'large-house-interaction-v1'; const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts')); @@ -42,7 +43,20 @@ const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-fa const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts')); const requiresStairs = existsSync(resolve(targetRoot, 'src/stairs.ts')); const fixture = stage3Dense ? makeIsometricStage3DenseFixture() : makeLargeHouseFixture(); -const fixtureCounts = stage3Dense ? fixture.counts : LARGE_HOUSE_COUNTS; +if (backdrop) { + // #743: the 2.5D twin with a backdrop image on every floor, so the imagePlan + // path (#739 K1) is measured at all. One shipped picture (demo/srv/assets + // since v1.14.0, present in every base) under a URL of its own per floor, so + // each floor decodes its own image; plan_aspect 1 lays it over the whole + // plan square and leaves the room geometry alone. The variant lives here, as + // plan-snap's does: demo/fixtures/** and the bundle fingerprint stay as they are. + for (const space of fixture.config.spaces) { + space.plan_url = `/assets/f1.svg?${space.id}`; + space.plan_aspect = 1; + } + fixture.counts = { ...fixture.counts, backdrops: fixture.config.spaces.length }; +} +const fixtureCounts = stage3Dense || backdrop ? fixture.counts : LARGE_HOUSE_COUNTS; const sourceSha = (() => { try { return execFileSync('git', ['-C', targetRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); @@ -101,7 +115,7 @@ try { const row = await page.evaluate(async ({ fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace, interaction, requiresInteraction, stage3Dense, requireStage3, - requiresIsoStructuralBuildCounter, requiresStairs, profile, + requiresIsoStructuralBuildCounter, requiresStairs, profile, backdrop, }) => { const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))); const until = async (predicate, timeout = 10000) => { @@ -406,6 +420,10 @@ try { && card.renderRoot.querySelectorAll('[data-hp="stair"]').length !== fixture.counts.stairs) { throw new Error(`${profile} did not render the bounded maximum stair collection`); } + // #743 precondition (docs/TESTING.md rule 3): the fixture carries the + // layer the profile guards, so a stable frame without the backdrop fails. + if (backdrop && !card.renderRoot.querySelector('.stage svg image.hp-backdrop')) + throw new Error(`${profile} rendered no backdrop image`); const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2)); const bootDiag = { updates: card.__diag.updates, @@ -1163,6 +1181,24 @@ try { if (switchCycleBuilt.length) throw new Error(`${profile} switchCycle built a floor inside the window: ${switchCycleBuilt.join(', ')}`); + // #743 structural probe, untimed and outside every Long Task window: six + // warm switches round the floors (the cycle ended on the last floor, so + // each one is a real switch). Each counts the update passes from the + // pick until updateComplete reports quiescence (resolves true). Counting + // right after the first await would miss the #739 K1 second pass: it + // starts after that promise resolves, in the same task. The wait is + // bounded so an endless update loop yields a large count instead of a + // hung job. evaluate.mjs judges the candidate; a base is only reported. + const floorSwitchPasses = []; + if (backdrop) { + for (let index = 0; index < 6; index++) { + const passesBefore = card.__diag.updates; + card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`); + for (let wait = 0; wait < 20 && !(await card.updateComplete); wait++); + floorSwitchPasses.push(card.__diag.updates - passesBefore); + } + } + await forceGc(); const cacheBefore = cacheSnapshot(card); const heapBefore = performance.memory?.usedJSHeapSize ?? null; @@ -1239,6 +1275,7 @@ try { ? null : afterStateIsoStructuralBuilds - steadyIsoStructuralBuilds, }, } : {}), + ...(backdrop ? { floorSwitchPasses: { supported: true, perSwitch: floorSwitchPasses } } : {}), ...(finalStage3 ? { isoStageRevision: finalStage3.isoStageRevision, stage3Diagnostics: { @@ -1256,7 +1293,7 @@ try { fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT, isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace, interaction, requiresInteraction, stage3Dense, requireStage3, - requiresIsoStructuralBuildCounter, requiresStairs, profile, + requiresIsoStructuralBuildCounter, requiresStairs, profile, backdrop, }); // #520: диагностика печатается в лог прогона и в запись не попадает. const { bootDiag, ...measured } = row; diff --git a/demo/performance/README.md b/demo/performance/README.md index e0eecbc4..224589bd 100644 --- a/demo/performance/README.md +++ b/demo/performance/README.md @@ -90,6 +90,37 @@ on both sides of the change are recorded in #735. Budgets and `hardMaxMs` did not change in #735; #747 then brought the `switchCycleMs` ceilings down to the warmed level (see "CI contracts" below). +Issue #743 adds `large-house-isometric-backdrop-v1`, the 2.5D twin of +`large-house-isometric-v1` with a loaded plan picture on every floor. Before it +no Full Performance profile walked the backdrop (`imagePlan`) path, so the +double render of #739 K1 — every warm floor switch cleared the ready paper, +inserted the first-frame veil, probed the card background and asked for a +second full update — was invisible to CI both by time and structurally; a +temporary probe found it. The runner derives the fixture as it does for +plan-snap: `large-house-v1` plus `plan_url: '/assets/f1.svg?'` and +`plan_aspect: 1` on each floor. One shipped picture (in `demo/srv/assets` since +v1.14.0, so every base has it) under a URL of its own per floor decodes once per +floor; it covers the whole plan square and the room geometry is unchanged. +`demo/fixtures/**` and the bundle fingerprint stay as they are, and the report's +fixture counts gain `backdrops: 3`. A sample fails unless its first stable frame +has the backdrop `` in the stage SVG (`docs/TESTING.md` rule 3). The +profile runs every window of the historical isometric profile; after the +`switchCycle` window and its #735 guard, before forced GC and outside every +timed and Long Task window, a structural probe makes six warm switches round the +floors and counts the `performUpdate` passes of each, from the pick until +`updateComplete` resolves `true`. Counting has to run to that quiescence: the K1 +second pass starts after the first `updateComplete` has resolved, in the same +task, so a count taken right after the first `await` reads one pass on both +sides. Late updates after the frame are not counted. Each row reports +`floorSwitchPasses: { supported, perSwitch }`, and `evaluate.mjs` rejects a +candidate unless every element is 1 (`candidate: a warm 2.5D floor switch with a +backdrop took update passes`, #739 AC2 and `docs/ISOMETRIC.md`). The base is +reported, not judged: bases before #739, v1.78.0 among them, take two passes. +`budgets-large-house-isometric-backdrop.json` is a copy of the historical +isometric budget that differs only in `profile`, so the twin shares every +ceiling (as #160 does); the historical profile keeps its meaning as the #124 +witness. The profile runs in Full Performance only, not in the Validate smoke. + Every report is tied to the source fingerprint embedded by Rollup. A stale bundle is a hard failure. @@ -296,6 +327,7 @@ Build and copy a fresh demo bundle first, then run: ```bash npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json npm run benchmark:large-house-isometric -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-local.json +npm run benchmark:large-house-isometric-backdrop -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-backdrop-local.json npm run benchmark:isometric-stage3-dense -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-stage3-local.json npm run benchmark:large-house-plan-snap -- --samples=7 --warmups=1 --output=artifacts/performance/plan-snap-local.json npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 --output=artifacts/performance/interaction-local.json diff --git a/demo/performance/budgets-large-house-isometric-backdrop.json b/demo/performance/budgets-large-house-isometric-backdrop.json new file mode 100644 index 00000000..f7a9875f --- /dev/null +++ b/demo/performance/budgets-large-house-isometric-backdrop.json @@ -0,0 +1,49 @@ +{ + "schema": 1, + "profile": "large-house-isometric-backdrop-v1", + "minimumSamples": 7, + "timings": { + "modelReadyMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 200, "hardMaxMs": 3000 }, + "firstStableRenderMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 3500 }, + "spaceSwitchMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 2200 }, + "stateUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 }, + "resizePreviewMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 150, "hardMaxMs": 2200 }, + "panZoomMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 60, "hardMaxMs": 600 }, + "settingsDialogMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1200 }, + "switchCycleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 8000 } + }, + "longTasks": { + "maxSingleMs": 3000, + "maxSingleRegressionRatio": 0.2, + "maxSingleNoiseAllowanceMs": 250, + "maxCountP95": 30, + "maxCountRegressionRatio": 0.2, + "countNoiseAllowance": 5, + "maxTotalP95Ms": 12000, + "maxTotalRegressionRatio": 0.2, + "noiseAllowanceMs": 150 + }, + "heap": { + "required": true, + "hardMaxGrowthBytes": 67108864, + "maxRegressionRatio": 0.2, + "noiseAllowanceBytes": 16777216 + }, + "cacheEntries": { + "cleanFloor": 100, + "glowClip": 200, + "wallUnion": 1, + "openingTunnel": 1, + "openingWallIndex": 1, + "isoGeometry": 8 + }, + "cacheGrowth": { + "cleanFloor": 0, + "glowClip": 0, + "wallUnion": 0, + "openingTunnel": 0, + "openingWallIndex": 0, + "isoGeometry": 0 + }, + "renderedDevices": 200 +} diff --git a/demo/performance/evaluate.mjs b/demo/performance/evaluate.mjs index 8a14702b..67435655 100644 --- a/demo/performance/evaluate.mjs +++ b/demo/performance/evaluate.mjs @@ -38,8 +38,9 @@ export const summarizeLongTasks = (rows) => { const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b); const exactGitSha = (value) => typeof value === 'string' && /^[0-9a-f]{40}$/i.test(value); +const ISOMETRIC_BACKDROP_PROFILE = 'large-house-isometric-backdrop-v1'; const isIsometricProfile = (profile) => profile === 'large-house-isometric-v1' - || profile === 'isometric-stage3-dense-v1'; + || profile === 'isometric-stage3-dense-v1' || profile === ISOMETRIC_BACKDROP_PROFILE; const requireReport = (report, budgets, label, expectedSha = null) => { if (!report || report.schema !== 2) throw new Error(`${label}: unsupported report schema`); @@ -61,6 +62,19 @@ const requireReport = (report, budgets, label, expectedSha = null) => { || row.isoStructuralBuilds?.haUpdateDelta !== 0)) { throw new Error(`${label}: Iso structural build count is missing or changed on HA-only update`); } + if (label === 'candidate' && report.profile === ISOMETRIC_BACKDROP_PROFILE) { + // #743: a warm floor switch with a backdrop renders once (#739 AC2, + // docs/ISOMETRIC.md). The base is not judged: bases before #739, + // v1.78.0 among them, take two passes. + for (const row of report.rows) { + const passes = row.floorSwitchPasses; + if (passes?.supported !== true || !Array.isArray(passes.perSwitch) || !passes.perSwitch.length) + throw new Error(`${label}: floor switch update passes are missing`); + const extra = passes.perSwitch.find((count) => count !== 1); + if (extra !== undefined) + throw new Error(`${label}: a warm 2.5D floor switch with a backdrop took ${extra} update passes`); + } + } if (label === 'candidate' && report.profile === 'isometric-stage3-dense-v1') { // The profile/schema name is intentionally stable for baseline continuity; // the candidate DOM revision tracks the current experimental Stage 4. diff --git a/package.json b/package.json index 8f85e9f7..8b047b76 100755 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "audit:config": "node scripts/config-audit.mjs", "benchmark:large-house": "node demo/benchmark_large_house.mjs", "benchmark:large-house-isometric": "node demo/benchmark_large_house.mjs --profile=large-house-isometric-v1", + "benchmark:large-house-isometric-backdrop": "node demo/benchmark_large_house.mjs --profile=large-house-isometric-backdrop-v1", "benchmark:isometric-stage3-dense": "node demo/benchmark_large_house.mjs --profile=isometric-stage3-dense-v1", "benchmark:large-house-plan-snap": "node demo/benchmark_large_house.mjs --profile=large-house-plan-snap-v1", "benchmark:large-house-interaction": "node demo/benchmark_large_house.mjs --profile=large-house-interaction-v1", diff --git a/test/performance-budget.test.mjs b/test/performance-budget.test.mjs index 2b5da6dd..6325ac9b 100644 --- a/test/performance-budget.test.mjs +++ b/test/performance-budget.test.mjs @@ -142,6 +142,55 @@ test('isometric reports fail closed on exact SHA, effective projection and curre }), /sourceSha does not match/); }); +test('#743 backdrop candidate renders a warm 2.5D floor switch in one update pass', () => { + const backdropBudgets = { ...budgets, profile: 'large-house-isometric-backdrop-v1' }; + const withPasses = (sourceSha, perSwitch) => { + const value = report(); + Object.assign(value, { profile: backdropBudgets.profile, sourceSha, effectiveProjection: ['iso'] }); + value.rows.forEach((row) => Object.assign(row, { + effectiveProjection: 'iso', + isoStructuralBuilds: { + supported: true, initial: 1, beforeHaUpdate: 2, afterHaUpdate: 2, haUpdateDelta: 0, + }, + ...(perSwitch ? { floorSwitchPasses: { supported: true, perSwitch: [...perSwitch] } } : {}), + })); + return value; + }; + const evaluate = (candidate, baseline) => evaluatePerformanceBudget({ + baseline, candidate, budgets: backdropBudgets, + baselineSha: baseline.sourceSha, candidateSha: candidate.sourceSha, + }); + // The base is reported, not judged: before #739 (v1.78.0 too) it takes two passes. + const base = withPasses('2'.repeat(40), [2, 2]); + assert.equal(evaluate(withPasses('1'.repeat(40), [1, 1, 1, 1, 1, 1]), base).pass, true); + + const twice = withPasses('1'.repeat(40), [1, 2]); + assert.throws(() => evaluate(twice, base), + /^Error: candidate: a warm 2\.5D floor switch with a backdrop took 2 update passes$/); + const oneRowTwice = withPasses('1'.repeat(40), [1, 1]); + oneRowTwice.rows[1].floorSwitchPasses.perSwitch = [1, 2]; + assert.throws(() => evaluate(oneRowTwice, base), /took 2 update passes/, 'every sample is judged'); + + for (const mutate of [ + (candidate) => { delete candidate.rows[0].floorSwitchPasses; }, + (candidate) => { candidate.rows[0].floorSwitchPasses.supported = false; }, + (candidate) => { candidate.rows[0].floorSwitchPasses.perSwitch = []; }, + ]) { + const candidate = withPasses('1'.repeat(40), [1, 1]); + mutate(candidate); + assert.throws(() => evaluate(candidate, base), /candidate: floor switch update passes are missing/); + } + assert.throws(() => evaluate(withPasses('1'.repeat(40), null), base), + /candidate: floor switch update passes are missing/, 'a candidate report without the probe fails closed'); + // The twin is an isometric profile: exact SHA and effective Iso are required as well. + const flat = withPasses('1'.repeat(40), [1, 1]); + flat.effectiveProjection = ['flat']; + assert.throws(() => evaluate(flat, base), /effectiveProjection is not exclusively iso/); + const unpinned = withPasses(null, [1, 1]); + assert.throws(() => evaluatePerformanceBudget({ baseline: base, candidate: unpinned, budgets: backdropBudgets }), + /candidate: missing exact sourceSha/); +}); + test('absolute performance smoke needs no baseline and enforces hard ceilings', () => { const accepted = evaluatePerformanceBudget({ candidate: report({ timing: 450, heap: 900, long: 190 }), budgets, absoluteOnly: true, @@ -424,6 +473,7 @@ test('#720: the 2.5D view toggle is reported, not budgeted (owner decision in #6 'budgets-isometric-smoke.json', 'budgets-large-house-isometric.json', 'budgets-isometric-stage3-dense.json', + 'budgets-large-house-isometric-backdrop.json', ]) { const budget = readBudget(name); assert.equal('viewToggleMs' in budget.timings, false, `${name} budgets viewToggleMs`); diff --git a/test/performance-workflow.test.mjs b/test/performance-workflow.test.mjs index 38ca5f27..aeb3a919 100644 --- a/test/performance-workflow.test.mjs +++ b/test/performance-workflow.test.mjs @@ -41,6 +41,7 @@ test('full performance is isolated to stable, scheduled and manual entry points' 'profile:', '- large-house', '- isometric', + '- isometric-backdrop', '- isometric-stage3', '- plan-snap', '- interaction', @@ -54,12 +55,13 @@ test('full performance is isolated to stable, scheduled and manual entry points' ]) 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(/--samples=7 --warmups=1/g) || []).length, 20); 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); + assert.ok(workflow.includes('budgets-large-house-isometric-backdrop.json')); + assert.equal((workflow.match(/--baseline-sha=/g) || []).length, 3); + assert.equal((workflow.match(/--candidate-sha=/g) || []).length, 3); const release = readWorkflow('release.yml'); // #540: признак стабильного — тег кандидата, не поле события: тот же гейт @@ -129,6 +131,56 @@ test('#160 Stage 3 budget preserves every historical common ceiling', () => { } }); +test('#743 backdrop budget is the historical isometric budget under its own profile id', () => { + // A new fixture meaning gets a new id (README, "Changing budgets"); the twin + // shares every ceiling with large-house-isometric-v1, as #160 does. + const historical = readJson('../demo/performance/budgets-large-house-isometric.json'); + const backdrop = readJson('../demo/performance/budgets-large-house-isometric-backdrop.json'); + assert.equal(backdrop.profile, 'large-house-isometric-backdrop-v1'); + assert.deepEqual({ ...backdrop, profile: historical.profile }, historical, + 'the twin differs from the historical isometric budget only in profile'); + assert.equal('viewToggleMs' in backdrop.timings, false, '#720: the toggle is reported, not budgeted'); + const workflow = readWorkflow('performance.yml'); + const capture = workflow.slice(workflow.indexOf(' isometric-backdrop)')); + assert.match(capture, + /^ {12}isometric-backdrop\)\n {14}npm run benchmark:large-house-isometric-backdrop -- --target-root=\.\.\/baseline --samples=7 --warmups=1 [^\n]+\n {14}npm run benchmark:large-house-isometric-backdrop -- --target-root=\. --samples=7 --warmups=1 /, + 'base and candidate are captured by the same runner, base first'); + assert.match(workflow, + /--budgets=demo\/performance\/budgets-large-house-isometric-backdrop\.json [^\n]*--baseline-sha="[^\n]*--candidate-sha="/, + 'the comparison pins both exact SHAs, as the isometric profile does'); + const pkg = readJson('../package.json'); + assert.equal(pkg.scripts['benchmark:large-house-isometric-backdrop'], + 'node demo/benchmark_large_house.mjs --profile=large-house-isometric-backdrop-v1'); +}); + +test('#743 backdrop profile walks the imagePlan path and probes update passes after the #735 guard', () => { + const runner = readFileSync(new URL('../demo/benchmark_large_house.mjs', import.meta.url), 'utf8'); + assert.match(runner, /const isometric = [^;\n]*\|\| backdrop;/, + 'the backdrop twin runs every 2.5D window of large-house-isometric-v1'); + assert.match(runner, + /space\.plan_url = `\/assets\/f1\.svg\?\$\{space\.id\}`;\s*space\.plan_aspect = 1;/, + 'every floor gets the shipped picture under a URL of its own over the whole plan square'); + assert.match(runner, /backdrops: fixture\.config\.spaces\.length/); + assert.match(runner, + /if \(backdrop && !card\.renderRoot\.querySelector\('\.stage svg image\.hp-backdrop'\)\)\s*throw new Error\(`\$\{profile\} rendered no backdrop image`\);\s*const firstStableRenderMs/, + 'a stable frame without the backdrop layer fails the sample (docs/TESTING.md rule 3)'); + const cycleStart = runner.indexOf('const switchCycle = await duration('); + const guard = runner.indexOf('switchCycle built a floor inside the window', cycleStart); + const probe = runner.indexOf('const floorSwitchPasses = [];', cycleStart); + const gc = runner.indexOf('await forceGc();', cycleStart); + assert.ok(cycleStart > 0 && guard > cycleStart && probe > guard && gc > probe, + 'the probe follows the switchCycle window and its #735 guard and precedes forced GC'); + const probeBody = runner.slice(probe, gc); + assert.ok(!probeBody.includes('duration(') && !probeBody.includes('startLongTaskWindow('), + 'the probe stays outside every timed and Long Task window'); + assert.match(probeBody, + /for \(let index = 0; index < 6; index\+\+\) \{\s*const passesBefore = card\.__diag\.updates;\s*card\._pickSpace\(`perf-floor-\$\{\(index % fixture\.counts\.floors\) \+ 1\}`\);\s*for \(let wait = 0; wait < 20 && !\(await card\.updateComplete\); wait\+\+\);\s*floorSwitchPasses\.push\(card\.__diag\.updates - passesBefore\);/, + 'six warm switches, each counted until updateComplete reports quiescence'); + assert.match(runner, + /\.\.\.\(backdrop \? \{ floorSwitchPasses: \{ supported: true, perSwitch: floorSwitchPasses \} \} : \{\}\)/, + 'only the backdrop profile reports the passes'); +}); + 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 [