From e9d388b1865ecb6777ac0d147dfd9460ebdde0db Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 13:48:16 +0300 Subject: [PATCH 1/2] [fix] quality governor flap lock: auto-quality toggles stop switching back and forth - 40 F12: on a phone Aquarium's water refraction switched off and on by itself. The governor walked 6 -> 5 after 10 s of cheap frames at "post off", level 5 brought the screen-space water pass back and overloaded 3 s later; doubling the recovery hold only slowed the flap (10/20/40/80 s) - a step back UP to the level a recovery just left (within flapWindowMs) now locks that level as the lowest recovery may reach, until clearFlapLock: a new scene (load end), releaseQuality / keepFullQuality, or the auto-quality switch. Every auto toggle (water, fluid, HDRI tier, shadows, resolution, AO, post, particles) derives from the level, so all of them get the rule. A scene that becomes light still walks back - keepFullQuality(): full quality and no automatic step for the rest of the page session (not saved; the Settings auto switch stays on) - unit: qualityFlapLock (10-minute phone simulation, counterfactual: >8 refraction switches without the lock, <=3 with it), the old flap test now ends in the lock Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/qualityGovernor.js | 21 +++++ src/lib/qualityGovernorCore.js | 38 ++++++-- tests/unit/qualityFlapLock.test.js | 135 +++++++++++++++++++++++++++++ tests/unit/qualityGovernor.test.js | 7 +- 4 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 tests/unit/qualityFlapLock.test.js diff --git a/src/lib/qualityGovernor.js b/src/lib/qualityGovernor.js index ca30fed81..132734717 100644 --- a/src/lib/qualityGovernor.js +++ b/src/lib/qualityGovernor.js @@ -187,6 +187,8 @@ export function noteFrameForQuality(ms) { if (wasLoading) { wasLoading = false; governor.settleFor(t); + // 40 F12: a new scene is new evidence — the last scene's flap lock does not carry over + governor.clearFlapLock(); } governor.noteFrame(ms, t); if (phone) notePhoneFrame(ms); @@ -254,10 +256,25 @@ export function pinQuality() { export function releaseQuality() { pinned = false; snoozedUntil = Date.now() + RELEASE_SNOOZE_MS; + governor.clearFlapLock(); governor.setLevel(0, now()); publish(0, 'released'); } +/** 40 F12 — "Keep full quality" (the auto-quality notice): full quality now and NO automatic + * step for the rest of this page session. Not saved — Settings' auto switch stays on, so the + * next visit is protected again; turning that switch off and on ends the hold too. */ +export function keepFullQuality() { + releaseQuality(); + snoozedUntil = Infinity; + publish(0, 'kept full quality'); +} + +/** 40 F12: the level recovery is held at because it flapped there (-1 = none). For the suite. */ +export function qualityFlapLock() { + return governor.flapLock(); +} + /** @param {boolean} on */ export function setAutoQuality(on) { autoQuality.set(!!on); @@ -273,6 +290,9 @@ autoQuality.subscribe((on) => { return; } safeStorage.setItem('autoQuality', on ? 'true' : 'false'); + // 40 F12: either way the switch is a fresh start — no flap lock, no "keep full quality" hold + governor.clearFlapLock(); + snoozedUntil = 0; if (!on) { pinned = false; governor.setLevel(0, now()); @@ -338,6 +358,7 @@ export const governorForTest = { snoozedUntil = 0; drawGapEngaged = false; lastDecideAt = 0; + governor.clearFlapLock(); governor.setLevel(0, -1e9); governor.forget(); ingestDrawGap.set(0); diff --git a/src/lib/qualityGovernorCore.js b/src/lib/qualityGovernorCore.js index 890ce29b6..880909b4f 100644 --- a/src/lib/qualityGovernorCore.js +++ b/src/lib/qualityGovernorCore.js @@ -246,6 +246,17 @@ function percentile(sorted, q) { * each transition itself a visible hitch). A step back up shortly after a walk down * doubles the recovery hold for next time, capped; a step up long after the last walk down * is just a heavier scene, and resets it. + * + * 40 F12 — THE FLAP LOCK. Doubling the hold only slowed the flap down: on a phone, Aquarium's + * water switched its refraction off and on by itself every 10-80 s (level 6 "post off" drops the + * screen-space water pass, so its frames are cheap and recover; level 5 brings the pass back and + * overloads 3 s later). The frames of a level say nothing about the cost of the level above it, + * so a recovery that had to be undone is not retried: stepping back UP to the level just walked + * down FROM (within `flapWindowMs`) makes that level the lowest recovery may reach until + * `clearFlapLock` — a new scene, "Keep full quality", the auto switch. Every automatic toggle + * (water, fluid, HDRI tier, shadows, resolution, AO, post, particles) derives from the level, so + * every one gets the same rule: at most one retry per scene. A scene that becomes LIGHT still + * walks back (that is new evidence, not the same frames again). * @param {{timing?: Partial}} [opts] */ export function createGovernor(opts = {}) { @@ -257,6 +268,9 @@ export function createGovernor(opts = {}) { let level = 0; let changedAt = -Infinity; let lastDownAt = -Infinity; + /** 40 F12: the level the last walk down started from, and the flap lock (-1 = none) */ + let lastDownFrom = -1; + let flapLock = -1; let recoverHoldMs = timing.recoverWindowMs; let settleUntil = -Infinity; /** 31-perf P3: a live XR session's thresholds (xrThresholds), null = the profile's; @@ -287,14 +301,17 @@ export function createGovernor(opts = {}) { } function change(/** @type {number} */ to, /** @type {number} */ now) { - if (to < level) lastDownAt = now; - else if (to > level) + if (to < level) { + lastDownAt = now; + lastDownFrom = level; + } else if (to > level) { // a step up soon after a walk down is a FLAP: make the next recovery wait longer. // A step up long after one is simply a scene that got heavier: forgive the history - recoverHoldMs = - now - lastDownAt < timing.flapWindowMs - ? Math.min(timing.maxRecoverHoldMs, recoverHoldMs * 2) - : timing.recoverWindowMs; + const flap = now - lastDownAt < timing.flapWindowMs; + recoverHoldMs = flap ? Math.min(timing.maxRecoverHoldMs, recoverHoldMs * 2) : timing.recoverWindowMs; + // 40 F12: straight back to the level the recovery left — lock it (see the header) + if (flap && to === lastDownFrom) flapLock = Math.max(flapLock, to); + } level = to; changedAt = now; settleUntil = now + timing.settleMs; @@ -359,6 +376,8 @@ export function createGovernor(opts = {}) { change(level - 1, now); return { level, moved: 'down', reason: 'scene is light', p95 }; } + // 40 F12: a recovery that already had to be undone is not retried + if (level <= flapLock) return { level, moved: null, reason: 'held (flapped)', p95 }; const calm = p95Over(now, recoverHoldMs); if (calm != null && calm < t.underMs && tasks === 0) { change(level - 1, now); @@ -373,6 +392,13 @@ export function createGovernor(opts = {}) { if (next !== level) change(next, now); return level; }, + /** 40 F12: the lowest level recovery may reach because it flapped there (-1 = none) */ + flapLock: () => flapLock, + /** 40 F12: a new scene / "Keep full quality" / the auto switch — the old flap is no evidence */ + clearFlapLock() { + flapLock = -1; + lastDownFrom = -1; + }, /** 31-perf P3: judge against these (an XR session's rate) until cleared with null. * @param {{overMs: number, underMs: number, budgetMs?: number} | null} t */ setThresholds(t) { diff --git a/tests/unit/qualityFlapLock.test.js b/tests/unit/qualityFlapLock.test.js new file mode 100644 index 000000000..f4e96af01 --- /dev/null +++ b/tests/unit/qualityFlapLock.test.js @@ -0,0 +1,135 @@ +// 40 F12 — the user's phone: Aquarium's water switched its refraction OFF and ON by itself. +// Level 6 ("post off") drops the water's screen-space pass, so its frames are cheap and the +// governor recovered to level 5 after the hold — where the pass comes back and overloads 3 s +// later. The old flap rule only doubled the hold (10 / 20 / 40 / 80 s), so the switch kept +// flipping for as long as the scene was open. The flap LOCK: once a recovery had to be undone, +// it is not retried (until a new scene, "Keep full quality", or the auto switch). +import { describe, it, expect } from 'vitest'; +import { createGovernor, phoneThresholds, overridesAt } from '../../src/lib/qualityGovernorCore.js'; + +const ctx = { profile: /** @type {'desktop'} */ ('desktop'), heavy: true, calls: 60 }; + +/** + * A 60 Hz phone whose frame cost depends on the level: at `cheapFrom` and above the frames + * are on time, below it every third frame is missed (the screen-space water pass). Decides + * every 250 ms like the wiring and records every level change. + * @param {ReturnType} g @param {number} from @param {number} dur @param {number} cheapFrom + */ +function phone(g, from, dur, cheapFrom) { + /** @type {{t: number, level: number}[]} */ + const moves = []; + let t = from; + let i = 0; + let next = from + 250; + while (t < from + dur) { + const ms = g.level() >= cheapFrom ? 16.7 : i % 3 === 0 ? 33.3 : 16.7; + t += ms; + i++; + g.noteFrame(ms, t); + if (t >= next) { + next = t + 250; + const d = g.decide(t, ctx); + if (d.moved) moves.push({ t: Math.round(t), level: d.level }); + } + } + return { t, moves }; +} + +/** the water tier the level gives (waterRuntime.resolveTier's auto rule) @param {number} level */ +const waterTier = (level) => { + const o = overridesAt(level); + return o.postOff ? 'quest' : o.aoOff || o.dprScale < 0.8 ? 'medium' : 'high'; +}; + +/** how many times the water's refraction switched (quest <-> screen-space) @param {{level: number}[]} moves @param {number} start */ +function refractionFlips(moves, start) { + let flips = 0; + let was = waterTier(start) === 'quest'; + for (const m of moves) { + const now = waterTier(m.level) === 'quest'; + if (now !== was) flips++; + was = now; + } + return flips; +} + +describe('flap lock (F12)', () => { + it('the phone premise: level 5 refracts, level 6 does not', () => { + expect(waterTier(5)).toBe('medium'); + expect(waterTier(6)).toBe('quest'); + }); + + it('COUNTERFACTUAL: without the lock the refraction keeps switching for the whole session', () => { + const g = createGovernor(); + g.setThresholds(phoneThresholds(60)); + g.setLevel(5, 0); + // the lock disabled: clear it after every decision, which is the pre-F12 rule + const realDecide = g.decide; + g.decide = (t, c) => { + const d = realDecide(t, c); + g.clearFlapLock(); + return d; + }; + const { moves } = phone(g, 1000, 600000, 6); + expect(refractionFlips(moves, 5)).toBeGreaterThan(8); + }); + + it('with the lock: off, one retry, off for good — at most 3 switches in 10 minutes', () => { + const g = createGovernor(); + g.setThresholds(phoneThresholds(60)); + g.setLevel(5, 0); + const { moves } = phone(g, 1000, 600000, 6); + expect(refractionFlips(moves, 5)).toBeLessThanOrEqual(3); + expect(g.level()).toBe(6); + expect(g.flapLock()).toBe(6); + // and the last move happened within the first minute: nothing after that + expect(moves[moves.length - 1].t).toBeLessThan(61000); + }); + + it('a step up LONG after a walk down is a heavier scene, not a flap — no lock', () => { + const g = createGovernor(); + g.setThresholds(phoneThresholds(60)); + g.setLevel(6, 0); + let t = phone(g, 1000, 15000, 0).t; // cheap everywhere: recovers 6 -> 5 + expect(g.level()).toBe(5); + // a long calm, then the scene gets heavy + const calm = phone(g, t, 60000, 0); + t = calm.t; + const heavyCtx = { ...ctx }; + for (let i = 0; i < 400; i++) { + t += 33.3; + g.noteFrame(33.3, t); + } + const lv = g.level(); + const d = g.decide(t, heavyCtx); + expect(d.moved).toBe('up'); + expect(g.level()).toBe(lv + 1); + expect(g.flapLock()).toBe(-1); + }); + + it('a scene that becomes LIGHT still walks back through the lock (new evidence)', () => { + const g = createGovernor(); + g.setThresholds(phoneThresholds(60)); + g.setLevel(5, 0); + const { t } = phone(g, 1000, 120000, 6); + expect(g.flapLock()).toBe(6); + let tt = t; + for (let i = 0; i < 700; i++) { + tt += 16.7; + g.noteFrame(16.7, tt); + } + expect(g.decide(tt, { ...ctx, heavy: false })).toMatchObject({ moved: 'down', reason: 'scene is light' }); + }); + + it('clearFlapLock (a new scene) gives recovery back', () => { + const g = createGovernor(); + g.setThresholds(phoneThresholds(60)); + g.setLevel(5, 0); + const { t } = phone(g, 1000, 120000, 6); + g.clearFlapLock(); + expect(g.flapLock()).toBe(-1); + // cheap at every level now (a lighter scene): recovery walks down again + const after = phone(g, t, 200000, 0); + expect(after.moves.some((m) => m.level < 6)).toBe(true); + }); +}); diff --git a/tests/unit/qualityGovernor.test.js b/tests/unit/qualityGovernor.test.js index f89a14ed1..2a1eac8ed 100644 --- a/tests/unit/qualityGovernor.test.js +++ b/tests/unit/qualityGovernor.test.js @@ -146,7 +146,12 @@ describe('createGovernor', () => { expect(g.recoverHoldMs()).toBe(20000); t = run(g, t, 10500, 16.7); expect(g.decide(t, { heavy: true }).moved).toBe(null); // 10s is no longer enough - t = run(g, t, 10000, 16.7); + // 40 F12: ...and that flap LOCKED level 1 — good frames never walk below it again + t = run(g, t, 30000, 16.7); + expect(g.decide(t, { heavy: true })).toMatchObject({ moved: null, reason: 'held (flapped)' }); + expect(g.flapLock()).toBe(1); + // a new scene clears the lock; the doubled hold is already served, so it walks down + g.clearFlapLock(); expect(g.decide(t, { heavy: true }).moved).toBe('down'); // much later, a heavier scene: not a flap t = run(g, t + 60000, 2100, 50); From 3a3b023a7d0b7282f27275da89c648164642be61 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 8 Oct 2026 14:03:44 +0300 Subject: [PATCH 2/2] =?UTF-8?q?[feat]=20auto-quality=20notice:=20one=20"Lo?= =?UTF-8?q?wered=20...=20to=20keep=20it=20smooth=20=C2=B7=20Keep=20full=20?= =?UTF-8?q?quality"=20per=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 40 F12: when the device lowers a quality toggle on its own it says so once per toggle per session, batched per drop: "Lowered water quality and the scene look to keep it smooth" with a "Keep full quality" action (keepFullQuality: full quality for the rest of the page session, the Settings auto switch untouched) - the rule is pure (qualityNoticeCore: which toggles, newly lowered, the words); the AutoQualityNotice component watches qualityOverrides + simplifiedWater, counts only automatic drops (frames / long tasks / the phone's lighter start, never a game preset or the headset floor), holds a drop made in Play or a headset until you are back, and judges in a microtask because publish writes the overrides before the reason - replaces the object list's first-step toast (gone whenever that window was closed) and SimplifiedWaterNotice; the quality chip says "stays this way for this scene" while held - governorForTest: setLevel on a synthetic clock, flapLock / clearFlapLock - e2e auto-quality-notice (phone 390x844 touch + desktop 1440x900: the drop, one retry, held for 10 min, counterfactual 15 switches, scene load clears the lock, Play deferral, tap Keep full quality, shadows on desktop); fb-water-phone part 3 asserts a game preset is not announced; unit qualityNotice Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 4 +- src/components/AutoQualityNotice.svelte | 93 +++++++ src/components/menu/Controls.svelte | 25 +- .../water/SimplifiedWaterNotice.svelte | 102 ------- src/lib/qualityGovernor.js | 21 +- src/lib/qualityNoticeCore.js | 78 ++++++ src/lib/water/simplifiedNotice.js | 4 +- src/lib/water/waterRuntime.js | 2 +- tests/e2e/auto-quality-notice.test.cjs | 248 ++++++++++++++++++ tests/e2e/fb-water-phone.test.cjs | 43 +-- tests/unit/qualityNotice.test.js | 45 ++++ 11 files changed, 499 insertions(+), 166 deletions(-) create mode 100644 src/components/AutoQualityNotice.svelte delete mode 100644 src/components/water/SimplifiedWaterNotice.svelte create mode 100644 src/lib/qualityNoticeCore.js create mode 100644 tests/e2e/auto-quality-notice.test.cjs create mode 100644 tests/unit/qualityNotice.test.js diff --git a/src/App.svelte b/src/App.svelte index 6cd1bc9f3..0085c97a8 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -139,7 +139,7 @@ import { startMusicToolbox } from './lib/musicToolbox' import { startSharedLibrary } from '$lib/sharedLibrary' import { startSceneIdentity } from '$lib/sceneIdentity' import GameChip from './components/hud/GameChip.svelte' - import SimplifiedWaterNotice from './components/water/SimplifiedWaterNotice.svelte' // 36-fb-water F27 + import AutoQualityNotice from './components/AutoQualityNotice.svelte' // 40 F12 (was SimplifiedWaterNotice) import ShortcutSheet from './components/menu/ShortcutSheet.svelte' // 36 I3 import HudLayer from './components/hud/HudLayer.svelte' import PickCycleHint from './components/menu/PickCycleHint.svelte' // 36 F22 / S6 / S12 @@ -452,7 +452,7 @@ import { startMusicToolbox } from './lib/musicToolbox' editor's stand-in for a game's screens, which HudLayer no longer draws outside Play. Editor-only (it hides itself in Play, in VR and in embed mode). --> - + diff --git a/src/components/AutoQualityNotice.svelte b/src/components/AutoQualityNotice.svelte new file mode 100644 index 000000000..2cfec0197 --- /dev/null +++ b/src/components/AutoQualityNotice.svelte @@ -0,0 +1,93 @@ + diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index 9512d31e3..42a5f5ea2 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -25,7 +25,7 @@ import { sendPing } from '$lib/ping'; import { buildObjectMenuItems } from '$lib/objectMenu'; import * as THREE from 'three'; - import { onMount, setContext, tick, untrack } from 'svelte'; + import { onMount, setContext, tick } from 'svelte'; import { createGesture } from '$lib/modalGrab'; import { writable, get } from 'svelte/store'; import { shareObject } from '$lib/objectPermissions'; @@ -599,30 +599,17 @@ const what = q.labels.join(', ').toLowerCase(); return q.pinned ? 'Held at reduced quality (' + what + '). Click to restore full quality.' - : 'This scene is heavy for this device, so drawing was reduced: ' + what + '. It comes back on its own when frames recover. Click to keep it this way.'; + : q.held + ? 'This scene is heavy for this device, so drawing was reduced: ' + what + '. It stays this way for this scene (it came back once and the frames dropped again). Click to keep it this way.' + : 'This scene is heavy for this device, so drawing was reduced: ' + what + '. It comes back on its own when frames recover. Click to keep it this way.'; }); function qualityChipClick(node: HTMLElement) { const click = () => (get(qualityState).pinned ? releaseQuality() : pinQuality()); node.addEventListener('click', click); return { destroy() { node.removeEventListener('click', click); } }; } - // …and once per session, a toast when it FIRST acts, because the chip lives in the object - // list's footer and that window can be closed - let qualityToasted = false; - $effect(() => { - const q = $qualityState; - if (q.level > 0 && !qualityToasted) { - qualityToasted = true; - untrack(() => - showQualityToast('Quality reduced — this scene is heavy for this device (' + q.labels.join(', ').toLowerCase() + '). It comes back on its own.', [ - // 33 integrate: not "Restore …" — on a phone this toast now shows on every load, beside the - // session's own "Restore" (the L1 Restore path), and a tap on the wrong one undid the phone fix - { label: 'Use full quality', action: () => releaseQuality() }, - { label: 'Keep it', action: () => pinQuality() } - ]) - ); - } - }); + // 40 F12: the once-per-session "Lowered … to keep it smooth · Keep full quality" toast is + // AutoQualityNotice (App root) — it used to live here and was gone whenever this window was closed. // bottom status line: totals across the whole tree (N objects · M hidden) let objectCount = $state(0); diff --git a/src/components/water/SimplifiedWaterNotice.svelte b/src/components/water/SimplifiedWaterNotice.svelte deleted file mode 100644 index 2dd593c2f..000000000 --- a/src/components/water/SimplifiedWaterNotice.svelte +++ /dev/null @@ -1,102 +0,0 @@ - - -{#if shown} -
- - Simplified water for this device — - - shows refraction. - - -
-{/if} - - diff --git a/src/lib/qualityGovernor.js b/src/lib/qualityGovernor.js index 132734717..30e27c246 100644 --- a/src/lib/qualityGovernor.js +++ b/src/lib/qualityGovernor.js @@ -59,7 +59,8 @@ export const ingestDrawGap = writable(0); /** * For the chip and the suite. - * @type {import('svelte/store').Writable<{level: number, max: number, pinned: boolean, labels: string[], reason: string, at: number, snoozedUntil: number}>} + * `held` (40 F12): recovery is held at this level because coming back from it flapped. + * @type {import('svelte/store').Writable<{level: number, max: number, pinned: boolean, labels: string[], reason: string, at: number, snoozedUntil: number, held: boolean}>} */ export const qualityState = writable({ level: 0, @@ -68,7 +69,8 @@ export const qualityState = writable({ labels: /** @type {string[]} */ ([]), reason: '', at: 0, - snoozedUntil: 0 + snoozedUntil: 0, + held: false }); /** The opt-out. LOCAL preference, default ON. */ @@ -132,7 +134,8 @@ function publish(level, reason) { labels: stepLabelsAt(level), reason, at, - snoozedUntil + snoozedUntil, + held: level > 0 && governor.flapLock() >= level }); } @@ -189,6 +192,7 @@ export function noteFrameForQuality(ms) { governor.settleFor(t); // 40 F12: a new scene is new evidence — the last scene's flap lock does not carry over governor.clearFlapLock(); + if (get(qualityState).held) qualityState.update((s) => ({ ...s, held: false })); } governor.noteFrame(ms, t); if (phone) notePhoneFrame(ms); @@ -348,9 +352,9 @@ export const governorForTest = { longTask(t) { governor.noteLongTask(t); }, - /** @param {number} level */ - setLevel(level) { - governor.setLevel(level, now()); + /** @param {number} level @param {number} [t] (40 F12: a synthetic clock, so real frames stay inert) */ + setLevel(level, t) { + governor.setLevel(level, t ?? now()); publish(governor.level(), 'test'); }, reset() { @@ -364,7 +368,10 @@ export const governorForTest = { ingestDrawGap.set(0); publish(0, 'reset'); }, - level: () => governor.level() + level: () => governor.level(), + /** 40 F12: the flap lock, and the pre-F12 rule (clear it after every decision) */ + flapLock: () => governor.flapLock(), + clearFlapLock: () => governor.clearFlapLock() }; registerFrameObserver(noteFrameForQuality); diff --git a/src/lib/qualityNoticeCore.js b/src/lib/qualityNoticeCore.js new file mode 100644 index 000000000..c97967c37 --- /dev/null +++ b/src/lib/qualityNoticeCore.js @@ -0,0 +1,78 @@ +// 40 F12 — WHAT THE AUTO-QUALITY NOTICE SAYS. PURE and import-free (vitest drives it). +// +// Every automatic quality toggle — the governor's ladder (shadows, resolution, AO, the scene +// look, particles) and what derives from it (the water losing its refraction, a fluid tank +// dropping to drops) — follows ONE rule: when the device lowers it on its own, the person is +// told once ("Lowered water quality to keep it smooth · Keep full quality"). One toast per +// drop names every toggle that drop newly lowered; a toggle already announced this session is +// not announced again (the chip in the object list still shows the current state). +// +// A snapshot is the state the notice watches: +// {dprScale, shadowsOff, aoOff, postOff, particlesCapped, water, fluid} +// where `water` / `fluid` are simplifiedNotice's flags (set only when QUALITY simplified them — +// never for a headset or the user's own "Low" water pick). + +/** The toggles, in the order a toast names them (the most visible first). */ +export const QUALITY_TOGGLES = [ + { key: 'water', name: 'water quality' }, + { key: 'fluid', name: 'fluid quality' }, + { key: 'shadows', name: 'shadows' }, + { key: 'resolution', name: 'resolution' }, + { key: 'ao', name: 'ambient occlusion' }, + { key: 'post', name: 'the scene look' }, + { key: 'particles', name: 'particles' } +]; + +/** @typedef {{dprScale?: number, shadowsOff?: boolean, aoOff?: boolean, postOff?: boolean, particlesCapped?: boolean, water?: boolean, fluid?: boolean}} QualitySnapshot */ + +/** Is this toggle lowered in `s`? @param {string} key @param {QualitySnapshot | null | undefined} s */ +export function isLowered(key, s) { + if (!s) return false; + switch (key) { + case 'water': + return !!s.water; + case 'fluid': + return !!s.fluid; + case 'shadows': + return !!s.shadowsOff; + case 'resolution': + return Number(s.dprScale ?? 1) < 1; + case 'ao': + return !!s.aoOff; + case 'post': + return !!s.postOff; + case 'particles': + return !!s.particlesCapped; + } + return false; +} + +/** + * The toggles lowered in `next` that were not in `prev` (a further resolution step counts only + * the first time resolution drops below full). @param {QualitySnapshot | null | undefined} prev + * @param {QualitySnapshot} next @returns {string[]} + */ +export function newlyLowered(prev, next) { + return QUALITY_TOGGLES.filter((t) => isLowered(t.key, next) && !isLowered(t.key, prev)).map((t) => t.key); +} + +/** + * What to announce: `keys` minus everything announced before, in QUALITY_TOGGLES order. + * @param {string[]} keys @param {Iterable} announced @returns {string[]} + */ +export function toAnnounce(keys, announced) { + const seen = new Set(announced); + const want = new Set(keys); + return QUALITY_TOGGLES.filter((t) => want.has(t.key) && !seen.has(t.key)).map((t) => t.key); +} + +/** "Lowered water quality and shadows to keep it smooth" @param {string[]} keys */ +export function noticeText(keys) { + const names = QUALITY_TOGGLES.filter((t) => keys.includes(t.key)).map((t) => t.name); + if (!names.length) return ''; + const list = names.length === 1 ? names[0] : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1]; + return 'Lowered ' + list + ' to keep it smooth'; +} + +/** The action's label (one place, so the suite and the docs agree). */ +export const KEEP_FULL_LABEL = 'Keep full quality'; diff --git a/src/lib/water/simplifiedNotice.js b/src/lib/water/simplifiedNotice.js index 7eae6cd7f..1bcee8f4f 100644 --- a/src/lib/water/simplifiedNotice.js +++ b/src/lib/water/simplifiedNotice.js @@ -1,8 +1,8 @@ // 36-fb-water F27: "the quality level simplified the water / fluid on this device" — a LEAF // (svelte/store only). waterRuntime sets `water` while a visible volume draws the Quest tier // BECAUSE OF QUALITY (not a headset, not the user's own "Low" pick); fluidRuntime sets `fluid` -// while a tank draws the points tier for the same reason. SimplifiedWaterNotice shows ONE small -// dismissible line about it, once per session, never in Play and never in a headset. +// while a tank draws the points tier for the same reason. AutoQualityNotice (40 F12) names it +// as "water quality" / "fluid quality", once per session, never in Play and never in a headset. import { writable, get } from 'svelte/store'; /** @type {import('svelte/store').Writable<{water: boolean, fluid: boolean}>} */ diff --git a/src/lib/water/waterRuntime.js b/src/lib/water/waterRuntime.js index cde6586eb..d3fb70ce0 100644 --- a/src/lib/water/waterRuntime.js +++ b/src/lib/water/waterRuntime.js @@ -1434,7 +1434,7 @@ export function tickWater(_delta) { return; } // 36-fb-water F27: the QUALITY LEVEL (not a headset, not the user's own Low) took the - // refraction away from water on screen — SimplifiedWaterNotice says so, once a session + // refraction away from water on screen — AutoQualityNotice says so, once a session (40 F12) noteSimplified( 'water', tier === 'quest' && qualityPref === 'auto' && !rendererRef?.xr?.isPresenting && [...entries.values()].some((e) => shown(e.object)) diff --git a/tests/e2e/auto-quality-notice.test.cjs b/tests/e2e/auto-quality-notice.test.cjs new file mode 100644 index 000000000..10e3335c0 --- /dev/null +++ b/tests/e2e/auto-quality-notice.test.cjs @@ -0,0 +1,248 @@ +// 40 F12 — "water refractions switch off/on by themselves on the phone" (the user's 1.31 review, +// refractions-off.jpg / refractions--on.jpg). Through the REAL wiring (qualityGovernor + the +// water runtime + AutoQualityNotice) on an emulated phone (390x844, touch) and a desktop +// (1440x900): +// 1. the phone's lighter start is announced once ("Lowered shadows, resolution and ambient +// occlusion to keep it smooth"); +// 2. Aquarium: a drop to "post off" turns the refraction off and says so ONCE ("Lowered water +// quality … to keep it smooth · Keep full quality"); the one retry comes back, overloads, and +// the level is then HELD — no more switching for the rest of a simulated 10 minutes; +// the counterfactual (the lock cleared after every decision = the 1.31 rule) switches > 8x; +// a scene load clears the lock; +// 3. a drop in Play is not announced until you leave Play; "Keep full quality" (a TAP) gives +// full quality and the device stops lowering it; once per session; +// 4. desktop: the same rule for shadows. +// Frames are SYNTHETIC on a clock 1e9 ms ahead (governorForTest), so the page's own frames — +// on a real timeline far behind it — can neither step nor recover the level mid-test. +// OUT= writes the evidence screenshots (phone + desktop, dark + light). +const h = require('./helpers.cjs'); +const fs = require('fs'); +const path = require('path'); + +const OUT = process.env.OUT || ''; +const SC = [process.env.FBW_SCENES, path.resolve(__dirname, '../../../scenes-lane-39-int/examples'), path.resolve(__dirname, '../../../scenes/examples')] + .filter(Boolean) + .find((d) => fs.existsSync(path.join(/** @type {string} */ (d), 'aquarium', 'scene.tpscene'))); +const PHONE = { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, userAgent: 'Mozilla/5.0 (Linux; Android 15; PKH110) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36' }; +const DESKTOP = { viewport: { width: 1440, height: 900 } }; + +/** @param {any} page @param {string} slug */ +async function load(page, slug) { + const bytes = Array.from(fs.readFileSync(path.join(/** @type {string} */ (SC), slug, 'scene.tpscene'))); + await page.evaluate(async (arr) => { + const s = /** @type {any} */ (window).__stores; + const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer); + await s.sessions.applySession(payload, { backup: false }); + }, bytes); +} + +/** install the synthetic driver and freeze the governor on the synthetic clock @param {any} page @param {number} level */ +async function freeze(page, level) { + await page.evaluate((lv) => { + const w = /** @type {any} */ (window); + w.__f12 = { t: 1e9 }; + w.__stores.qualityGovernor.governorForTest.setLevel(lv, w.__f12.t); + w.__f12.t += 1000; + /** + * frames at `slow` ms on every third frame while the level is below `cheapFrom` (the + * screen-space water pass), on time otherwise; a decision every 250 ms like the wiring. + * @param {number} dur @param {number} cheapFrom @param {{slow?: number, noLock?: boolean}} [o] + */ + w.__f12drive = (dur, cheapFrom, o = {}) => { + const Q = w.__stores.qualityGovernor; + const g = Q.governorForTest; + const slow = o.slow ?? 33.3; + /** @type {number[]} */ const moves = []; + const end = w.__f12.t + dur; + let next = w.__f12.t + 250; + let i = 0; + while (w.__f12.t < end) { + const ms = g.level() >= cheapFrom ? 16.7 : i % 3 === 0 ? slow : 16.7; + w.__f12.t += ms; + i++; + g.frame(ms, w.__f12.t); + if (w.__f12.t >= next) { + next = w.__f12.t + 250; + const d = Q.decideNow(w.__f12.t); + if (o.noLock) g.clearFlapLock(); + if (d.moved) moves.push(d.level); + } + } + return { moves, level: g.level(), lock: g.flapLock() }; + }; + }, level); +} + +/** @param {any} page */ +const state = (page) => + page.evaluate(() => { + const S = /** @type {any} */ (window).__stores; + let q; + S.qualityGovernor.qualityState.subscribe((/** @type {any} */ v) => (q = v))(); + return { + level: q?.level, + held: q?.held, + reason: q?.reason, + lock: S.qualityGovernor.qualityFlapLock(), + water: S.waterRuntime.waterDebug?.()?.tier ?? null, + toasts: [...document.querySelectorAll('.tp-toast-text')].map((e) => e.textContent?.trim() ?? '') + }; + }); + +/** the water tier a level gives on auto (waterRuntime.resolveTier) */ +const tierOf = (/** @type {number} */ lv) => (lv >= 6 ? 'quest' : lv >= 3 ? 'medium' : 'high'); +/** refraction switches along a list of levels @param {number[]} levels @param {number} start */ +function switches(levels, start) { + let n = 0; + let off = tierOf(start) === 'quest'; + for (const lv of levels) { + const now = tierOf(lv) === 'quest'; + if (now !== off) n++; + off = now; + } + return n; +} + +/** @param {any} page @param {string} name */ +async function shots(page, name) { + if (!OUT) return; + for (const t of ['dark', 'light']) { + await page.evaluate((th) => /** @type {any} */ (window).__stores.themes.theme.set(th), t); + await page.waitForTimeout(400); + await page.screenshot({ path: path.join(OUT, `${name}-${t}.png`) }); + } + await page.evaluate(() => /** @type {any} */ (window).__stores.themes.theme.set('dark')); +} + +/** h.eventually, returning the state it accepted (null on timeout) + * @param {() => Promise} fn @param {(v: any) => boolean} pred @param {string} label @param {number} [timeout] */ +async function until(fn, pred, label, timeout = 10000) { + const t0 = Date.now(); + let last; + while (Date.now() - t0 < timeout) { + last = await fn(); + if (pred(last)) { + h.check(true, label); + return last; + } + await new Promise((r) => setTimeout(r, 400)); + } + console.log(' last: ' + JSON.stringify(last)); + h.check(false, label); + return null; +} + +/** @param {any} page @param {RegExp} re */ +const toastLike = (page, re) => page.evaluate((src) => [...document.querySelectorAll('.tp-toast-text')].some((e) => new RegExp(src).test(e.textContent ?? '')), re.source); + +h.run(async () => { + if (OUT) fs.mkdirSync(OUT, { recursive: true }); + h.check(!!SC, `an Aquarium scene to load (${SC})`); + const browser = await h.launch({ args: h.GPU_ARGS }); + + // ── 1. the phone's lighter start, announced once ───────────────────────────────────────────── + const A = await h.setupPage(browser, 'f12-phone', { context: PHONE }); + h.check(await A.page.evaluate(() => /** @type {any} */ (window).__stores.qualityGovernor.phoneQuality()), 'the app judges this device a phone'); + await until(() => state(A.page), (s) => s.toasts.some((t) => /^Lowered shadows, resolution and ambient occlusion to keep it smooth$/.test(t)), '1.1 the lighter start is announced: "Lowered shadows, resolution and ambient occlusion to keep it smooth"', 8000); + h.check(await A.page.evaluate(() => [...document.querySelectorAll('.tp-toast-action')].some((b) => b.textContent?.trim() === 'Keep full quality')), '1.2 …with a "Keep full quality" action'); + h.check((await A.page.$('#simplified-water-notice')) === null, '1.3 the old "Simplified water" strip is gone (one notice for every toggle)'); + await freeze(A.page, 4); + await A.page.evaluate(() => document.querySelectorAll('.tp-toast-x').forEach((b) => /** @type {HTMLElement} */ (b).click())); + + // ── 2. Aquarium: off, one retry, then held ─────────────────────────────────────────────────── + await load(A.page, 'aquarium'); + await A.page.waitForTimeout(2500); + await A.page.evaluate(() => { + const w = /** @type {any} */ (window); + w.__stores.qualityGovernor.governorForTest.setLevel(5, w.__f12.t); + w.__f12.t += 1000; + }); + await h.eventually(() => state(A.page), (s) => s.water === 'medium', '2.0 (premise) level 5: Aquarium refracts on the half-res screen-space tier', 8000); + if (OUT) await A.page.screenshot({ path: path.join(OUT, '20-after-phone-aquarium-L5-refraction-on.png') }); + + const drop = await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(3000, 6)); + h.check(drop.level === 6 && drop.moves.join() === '6', `2.1 the screen-space pass overloads: 5 -> 6 (${drop.moves.join(',')})`); + const off1 = await until(() => state(A.page), (s) => s.water === 'quest' && s.toasts.some((t) => /^Lowered water quality/.test(t)), '2.2 refraction OFF, and the toast says so', 8000); + const waterToast = off1?.toasts.find((/** @type {string} */ t) => /^Lowered water quality/.test(t)); + h.check(waterToast === 'Lowered water quality and the scene look to keep it smooth', `2.3 the words ("${waterToast}")`); + await shots(A.page, '21-after-phone-water-lowered-toast'); + + const retry = await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(10000, 6)); + h.check(retry.moves.join() === '5', `2.4 cheap frames at level 6: ONE retry back to 5 (${retry.moves.join(',')})`); + await h.eventually(() => state(A.page), (s) => s.water === 'medium', '2.5 the retry brings the refraction back', 8000); + const again = await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(4000, 6)); + h.check(again.moves.join() === '6' && again.lock === 6, `2.6 the retry overloads again: 5 -> 6 and the level is LOCKED (lock ${again.lock})`); + const held = await until(() => state(A.page), (s) => s.water === 'quest' && s.held === true, '2.7 refraction off, qualityState.held', 8000); + h.check(!!held && held.toasts.filter((/** @type {string} */ t) => /^Lowered water quality/.test(t)).length <= 1, '2.8 no second water toast (once per session)'); + const ten = await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(600000, 6)); + h.check(ten.moves.length === 0 && ten.level === 6, `2.9 ten more minutes of steady frames: no switch at all (moves ${ten.moves.join(',') || 'none'})`); + const all = [...drop.moves, ...retry.moves, ...again.moves, ...ten.moves]; + h.check(switches(all, 5) === 3, `2.10 refraction switched ${switches(all, 5)} times in ~10.3 min: off, the one retry, off (was every 10-80 s)`); + if (OUT) await A.page.screenshot({ path: path.join(OUT, '22-after-phone-aquarium-held-refraction-off.png') }); + + // counterfactual: the 1.31 rule (no lock) on the same page and the same frames + await A.page.evaluate(() => { + const w = /** @type {any} */ (window); + w.__stores.qualityGovernor.governorForTest.clearFlapLock(); + w.__stores.qualityGovernor.governorForTest.setLevel(5, w.__f12.t); + w.__f12.t += 1000; + }); + const old = await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(600000, 6, { noLock: true })); + h.check(switches(old.moves, 5) > 8, `2.11 COUNTERFACTUAL — without the lock the refraction switches ${switches(old.moves, 5)} times in 10 min`); + + // a new scene clears the lock + await A.page.evaluate(() => { + const w = /** @type {any} */ (window); + w.__stores.qualityGovernor.governorForTest.setLevel(5, w.__f12.t); + w.__f12.t += 1000; + }); + await A.page.evaluate(() => /** @type {any} */ (window).__f12drive(20000, 6)); + h.check((await state(A.page)).lock === 6, '2.12 (premise) locked again'); + await load(A.page, 'aquarium'); + await h.eventually(() => state(A.page), (s) => s.lock === -1 && s.held === false, '2.12 a scene load clears the lock (new scene, new evidence)', 15000); + await A.ctx.close(); + + // ── 3. Play defers the notice; "Keep full quality" (a tap) ─────────────────────────────────── + const B = await h.setupPage(browser, 'f12-phone-keep', { context: PHONE }); + await freeze(B.page, 4); + await load(B.page, 'aquarium'); + await B.page.waitForTimeout(2500); + await B.page.evaluate(() => { + const w = /** @type {any} */ (window); + w.__stores.qualityGovernor.governorForTest.setLevel(5, w.__f12.t); + w.__f12.t += 1000; + w.__stores.isLocked.set(true); + }); + await B.page.evaluate(() => /** @type {any} */ (window).__f12drive(3000, 6)); + await B.page.waitForTimeout(1500); + h.check(!(await toastLike(B.page, /^Lowered water quality/)), '3.1 a drop while playing is not announced in Play'); + await B.page.evaluate(() => /** @type {any} */ (window).__stores.isLocked.set(null)); + await h.eventually(() => toastLike(B.page, /^Lowered water quality/), (v) => v, '3.2 …it is, once you are back in the editor', 8000); + if (OUT) await B.page.screenshot({ path: path.join(OUT, '23-after-phone-toast-before-tap.png') }); + const keep = B.page.locator('.tp-toast', { hasText: 'Lowered water quality' }).locator('.tp-toast-action', { hasText: 'Keep full quality' }); + await keep.tap(); + const kept = await until(() => state(B.page), (s) => s.level === 0 && s.water === 'high', '3.3 tapping "Keep full quality": full quality, the water refracts at full resolution', 8000); + h.check(kept?.reason === 'kept full quality', `3.4 …recorded as kept (${kept?.reason})`); + const after = await B.page.evaluate(() => /** @type {any} */ (window).__f12drive(60000, 99)); + h.check(after.moves.length === 0 && after.level === 0, `3.5 a minute of missed frames afterwards: it stays at full quality (moves ${after.moves.join(',') || 'none'})`); + if (OUT) await B.page.screenshot({ path: path.join(OUT, '24-after-phone-kept-full-quality.png') }); + h.check((await h.pageErrors(B)).length === 0, '3.6 no page errors'); + await B.ctx.close(); + + // ── 4. desktop: the same rule for every toggle (shadows) ───────────────────────────────────── + const D = await h.setupPage(browser, 'f12-desktop', { context: DESKTOP }); + h.check(!(await D.page.evaluate(() => /** @type {any} */ (window).__stores.qualityGovernor.phoneQuality())), '4.0 (premise) a desktop'); + await freeze(D.page, 0); + await load(D.page, 'aquarium'); + await D.page.waitForTimeout(2500); + await D.page.evaluate(() => { + // a heavy scene for the desktop budget (the governor only acts on one) + /** @type {any} */ (window).__stores.sceneBudget.qualityBaseline.set({ objects: 0, triangles: 0, calls: 5000 }); + }); + const d1 = await D.page.evaluate(() => /** @type {any} */ (window).__f12drive(2500, 99, { slow: 60 })); + h.check(d1.moves.join() === '1', `4.1 missed frames on a heavy scene: shadows off (${d1.moves.join(',')})`); + await h.eventually(() => toastLike(D.page, /^Lowered shadows to keep it smooth$/), (v) => v, '4.2 "Lowered shadows to keep it smooth" · Keep full quality', 8000); + await shots(D.page, '25-after-desktop-shadows-lowered-toast'); + h.check((await h.pageErrors(D)).length === 0, '4.3 no page errors'); + await h.finish(browser); +}); diff --git a/tests/e2e/fb-water-phone.test.cjs b/tests/e2e/fb-water-phone.test.cjs index 1df269c6f..4963d371f 100644 --- a/tests/e2e/fb-water-phone.test.cjs +++ b/tests/e2e/fb-water-phone.test.cjs @@ -4,8 +4,8 @@ // left the Quest tier) — 36-int-124's regression probe as a check; // 2. every quality level (pinned through the game-quality seam): a screenshot of both scenes // and a perf row (calls, triangles, p50/p95 at CPU x4) -> PHONE_OUT/perf-phone.md; -// 3. the one-time "Simplified water" notice: shown when quality takes refraction away, dismiss -// works, once per session, never in Play. +// 3. a game's Quality preset that simplifies the water is not announced (40 F12: the automatic +// drops are AutoQualityNotice's, suite auto-quality-notice). const h = require('./helpers.cjs'); const fs = require('fs'); const path = require('path'); @@ -95,42 +95,19 @@ h.run(async () => { ); console.log(rows.join('\n')); - // ── 3. the one-time notice — on a FRESH page: the level sweep above already used this - // session's one notice (correctly), and the component remembers that for the page's life - h.check((await A.page.evaluate(() => sessionStorage.getItem('water:simplifiedNoticeSeen'))) === '1', 'the sweep\'s first simplified level used the session\'s notice'); + // ── 3. 40 F12: the old "Simplified water" strip became AutoQualityNotice (one toast per + // automatic drop, suite auto-quality-notice). What stays here: a game's own Quality preset is + // an explicit choice, so pinning a level that simplifies the water is NOT announced await A.ctx.close(); - const B = await h.setupPage(browser, 'phone-notice', { context: PHONE }); - const A2 = B; + const A2 = await h.setupPage(browser, 'phone-notice', { context: PHONE }); await load(A2.page, 'aquarium'); await A2.page.waitForTimeout(3000); - // in Play first: never shown there - await A2.page.evaluate(() => { - window.__stores.isLocked.set(true); - window.__stores.qualityGovernor.applyGameQuality(6, 'test'); - }); - await A2.page.waitForTimeout(1500); - h.check((await A2.page.$('#simplified-water-notice')) === null, 'no notice while playing'); - await A2.page.evaluate(() => window.__stores.isLocked.set(null)); - const note = await A2.page.waitForSelector('#simplified-water-notice', { timeout: 5000 }).catch(() => null); - h.check(!!note, 'leaving Play, the notice says the water was simplified'); - const text = note ? await note.textContent() : ''; - h.check(/Simplified water for this device/.test(text) && /Water quality/.test(text), `...and where to give refraction back ("${text?.trim()}")`); - if (OUT && note) - for (const t of ['dark', 'light']) { - await A2.page.evaluate((th) => window.__stores.themes.theme.set(th), t); - await A2.page.waitForTimeout(300); - await A2.page.screenshot({ path: path.join(OUT, `72-phone-simplified-notice-${t}.png`) }); - } - await A2.page.evaluate(() => window.__stores.themes.theme.set('dark')); - await A2.page.click('#simplified-water-notice .sw-x').catch(() => {}); - await A2.page.waitForTimeout(400); - h.check((await A2.page.$('#simplified-water-notice')) === null, 'dismiss hides it'); - // once per session: down to full and back to simplified -> no second notice - await A2.page.evaluate(() => window.__stores.qualityGovernor.applyGameQuality(0, 'test')); - await A2.page.waitForTimeout(1000); await A2.page.evaluate(() => window.__stores.qualityGovernor.applyGameQuality(6, 'test')); + await h.eventually(() => state(A2.page), (s) => s.water === 'quest', 'a pinned level 6: the water is simplified', 8000); await A2.page.waitForTimeout(1500); - h.check((await A2.page.$('#simplified-water-notice')) === null, 'once per session: it does not come back'); + h.check((await A2.page.$('#simplified-water-notice')) === null, 'the old strip is gone'); + h.check(!(await A2.page.evaluate(() => [...document.querySelectorAll('.tp-toast-text')].some((e) => /^Lowered water quality/.test(e.textContent ?? '')))), 'a game preset is not announced as an automatic drop'); + if (OUT) await A2.page.screenshot({ path: path.join(OUT, '72-phone-game-preset-no-notice.png') }); await A2.page.evaluate(() => window.__stores.qualityGovernor.applyGameQuality(null)); h.check((await h.pageErrors(A2)).length === 0, 'no page errors'); await h.finish(browser); diff --git a/tests/unit/qualityNotice.test.js b/tests/unit/qualityNotice.test.js new file mode 100644 index 000000000..19a39c92a --- /dev/null +++ b/tests/unit/qualityNotice.test.js @@ -0,0 +1,45 @@ +// 40 F12 — what the auto-quality notice says: one toast per drop, naming every toggle that drop +// newly lowered, each toggle announced once per session ("Lowered water quality to keep it +// smooth · Keep full quality"). +import { describe, it, expect } from 'vitest'; +import { newlyLowered, toAnnounce, noticeText, isLowered, QUALITY_TOGGLES, KEEP_FULL_LABEL } from '../../src/lib/qualityNoticeCore.js'; +import { overridesAt } from '../../src/lib/qualityGovernorCore.js'; + +/** @param {number} level @param {Record} [extra] */ +const snap = (level, extra = {}) => ({ ...overridesAt(level), water: false, fluid: false, ...extra }); + +describe('quality notice (F12)', () => { + it('the spec sentence, verbatim', () => { + expect(noticeText(['water'])).toBe('Lowered water quality to keep it smooth'); + expect(KEEP_FULL_LABEL).toBe('Keep full quality'); + }); + + it('lists several in toggle order with "and"', () => { + expect(noticeText(['ao', 'shadows', 'resolution'])).toBe('Lowered shadows, resolution and ambient occlusion to keep it smooth'); + expect(noticeText(['post', 'water'])).toBe('Lowered water quality and the scene look to keep it smooth'); + expect(noticeText([])).toBe(''); + }); + + it('the phone start (level 0 -> 4) lowers shadows, resolution and AO', () => { + expect(newlyLowered(snap(0), snap(4))).toEqual(['shadows', 'resolution', 'ao']); + }); + + it('level 5 -> 6 with the water simplified names the water first', () => { + expect(newlyLowered(snap(5), snap(6, { water: true }))).toEqual(['water', 'post']); + }); + + it('a further resolution step is not a new drop; a recovery is not a drop', () => { + expect(newlyLowered(snap(2), snap(3))).toEqual([]); // 85 % -> 72 % + expect(newlyLowered(snap(6, { water: true }), snap(5))).toEqual([]); + }); + + it('once per session: an announced toggle is never announced again', () => { + expect(toAnnounce(['water', 'post'], ['post'])).toEqual(['water']); + expect(toAnnounce(['water'], ['water'])).toEqual([]); + }); + + it('every toggle in the list has a lowered rule', () => { + for (const t of QUALITY_TOGGLES) expect(isLowered(t.key, snap(9, { water: true, fluid: true }))).toBe(true); + for (const t of QUALITY_TOGGLES) expect(isLowered(t.key, snap(0))).toBe(false); + }); +});