feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul
The build the studios needed, across eight workstreams and one strict file partition. **The render rig was the quality ceiling.** The renderer ran three's NoToneMapping default while atmosphere drove the sun to 2.35 and assets set emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is why walls blew out and every fitting looked like a white rectangle. ACES filmic tone mapping and an explicit output colour space land in `stage.ts`, and the atmosphere intensity table and palette headroom are re-tuned against the new curve rather than left tuned for the clipping we removed. `engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally, so nothing binary is committed. There was no environment map anywhere before, so every `metalness > 0` role had nothing to reflect and rendered dull grey — a defect the code already documented against itself in `office/optimus.ts`, where a whole material role was abandoned over it, and worked around in `modelX.ts` with a fake emissive that this change deletes. Atmosphere remains the sole light owner; the rig derives from the `LightingState` it already produced. **Studio hardware exists.** There was no device concept anywhere in the product: no type, no route, no state. `devices/types.ts` fixes a declaration/state/ capability/command contract that a smart light, a thermostat, a door sensor and a charger all fit without a schema change, and both studios now carry a desk mic and a computer speaker with deterministic simulated behaviour behind an adapter seam a real API can occupy later. Reads are the demo and are open; commands are a signed-in action and are kept off the read body entirely, because a shared cache replaying a GET that turned a microphone on is exactly what the fail-closed cache default exists to prevent. **The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the response was served publicly cacheable, and the attribution hardcoded adsb.lol regardless of where the endpoint pointed — one env var away from republishing non-redistributable data under an open-terms credit. The host is now allowlisted, the credit is derived from the host actually configured, public cacheability is conditional on redistributability, and a refused endpoint demotes to simulated flights and says so in `degraded[]`. The gate is on the source, not the feature: live aircraft and their detail cards stay open to anonymous visitors. **The LA studio was never the smaller pack** — 16 rooms and 248 props against SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were ceiling troffers, it bound no props to seats, placed none of the habitat kit, and 12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather than more instances, because `furnish.ts` draws once per kind and folds colour into the batch key, so repeat instances add nothing the eye can read. **The interface stops being forty imperative mutations.** Every visibility decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the chrome has coverage for the first time. Deleted: ~100 lines of CSS and two bindings targeting elements that no longer exist, and a `body:has()` rule that shifted the desktop layout by 160px for touch controls hidden there. Fixed: the office picker tabs that drew their label and their badge on top of each other. Added: a first-run flow, because the product is two verbs and neither was ever stated on screen. Mobile is designed on its own terms instead of being the desktop with things hidden — the plan view comes back, and the keyboard-only shortcuts button is replaced by touch controls. `arena/studioOps.ts` frames the whole thing as the multi-variable environment it is, wrapping the same simulators the renderer drives rather than a headless copy. Also removed `input/vehicle.ts`, which nothing but its own test imported. Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six matrix cells, no-binaries, provenance, dependency licences, zero-config boot and arena source hashes all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+55
-16
@@ -14,17 +14,22 @@ import {
|
||||
OFFICE_NAV_SCENARIOS,
|
||||
OFFICE_JOBS_INACTION,
|
||||
OFFICE_JOBS_SCENARIOS,
|
||||
STUDIO_OPS_INACTION,
|
||||
STUDIO_OPS_SCENARIOS,
|
||||
CaliforniaFlightEnvironment,
|
||||
CrowNavEnvironment,
|
||||
Drive101Environment,
|
||||
OfficeNavEnvironment,
|
||||
OfficeJobsEnvironment,
|
||||
StudioOpsEnvironment,
|
||||
arenaChecksum,
|
||||
californiaFlightScriptedBaseline,
|
||||
crowNavScriptedBaseline,
|
||||
driveScriptedBaseline,
|
||||
officeNavScriptedBaseline,
|
||||
officeJobsScriptedBaseline,
|
||||
rollout,
|
||||
studioOpsScriptedBaseline,
|
||||
type ArenaEnvironment,
|
||||
type ArenaManifest,
|
||||
type ArenaScenarioRegistry,
|
||||
@@ -41,6 +46,17 @@ interface EnvironmentCase {
|
||||
inaction: unknown;
|
||||
scripted(observation: unknown): unknown;
|
||||
successReason: string;
|
||||
/**
|
||||
* Where the documented inaction action ends up.
|
||||
*
|
||||
* `"max-steps"` for the five environments whose only clock is the step cap.
|
||||
* `studio-ops-v1` has a second one — a departure the studio's car has to be
|
||||
* ready for — and standing still misses it, which is an *outcome* and so sets
|
||||
* `terminated`. The field exists so the contract test can keep asserting the
|
||||
* thing that actually matters (`truncated` is set by the step cap and by
|
||||
* nothing else) rather than being weakened to accommodate the sixth case.
|
||||
*/
|
||||
inactionReason: string;
|
||||
}
|
||||
|
||||
const CASES: EnvironmentCase[] = [
|
||||
@@ -51,6 +67,7 @@ const CASES: EnvironmentCase[] = [
|
||||
inaction: DRIVE_INACTION,
|
||||
scripted: () => driveScriptedBaseline(),
|
||||
successReason: "goal",
|
||||
inactionReason: "max-steps",
|
||||
},
|
||||
{
|
||||
name: "office",
|
||||
@@ -61,6 +78,7 @@ const CASES: EnvironmentCase[] = [
|
||||
observation as Parameters<typeof officeNavScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "goal",
|
||||
inactionReason: "max-steps",
|
||||
},
|
||||
{
|
||||
name: "office-jobs",
|
||||
@@ -71,6 +89,7 @@ const CASES: EnvironmentCase[] = [
|
||||
observation as Parameters<typeof officeJobsScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "job-complete",
|
||||
inactionReason: "max-steps",
|
||||
},
|
||||
{
|
||||
name: "crow",
|
||||
@@ -81,6 +100,7 @@ const CASES: EnvironmentCase[] = [
|
||||
observation as Parameters<typeof crowNavScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "goal",
|
||||
inactionReason: "max-steps",
|
||||
},
|
||||
{
|
||||
name: "flight",
|
||||
@@ -91,33 +111,48 @@ const CASES: EnvironmentCase[] = [
|
||||
observation as Parameters<typeof californiaFlightScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "goal",
|
||||
inactionReason: "max-steps",
|
||||
},
|
||||
{
|
||||
name: "studio-ops",
|
||||
create: () => new StudioOpsEnvironment() as AnyEnvironment,
|
||||
registry: STUDIO_OPS_SCENARIOS as ArenaScenarioRegistry<object>,
|
||||
inaction: STUDIO_OPS_INACTION,
|
||||
scripted: (observation) => studioOpsScriptedBaseline(
|
||||
observation as Parameters<typeof studioOpsScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "job-complete",
|
||||
inactionReason: "departure-missed",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* One episode, through the package's own `rollout`.
|
||||
*
|
||||
* This function used to be a hand-written loop, and so did the two baseline
|
||||
* proofs below and every example in ARENA.md — four copies of the same nine
|
||||
* lines, agreeing by luck. `rollout` is now the one copy, and running the
|
||||
* contract suite through it means a regression in the shared loop fails here
|
||||
* rather than in a consumer's trainer.
|
||||
*/
|
||||
function run(
|
||||
entry: EnvironmentCase,
|
||||
scenarioId: string,
|
||||
seed: number,
|
||||
policy: (observation: unknown) => unknown,
|
||||
): { total: number; final: ArenaStepResult<unknown, NumericRewards> } {
|
||||
const environment = entry.create();
|
||||
let observation = environment.reset(seed, scenarioId).observation;
|
||||
let total = 0;
|
||||
let final: ArenaStepResult<unknown, NumericRewards> | undefined;
|
||||
for (let step = 0; step < environment.manifest.maxSteps; step += 1) {
|
||||
final = environment.step(policy(observation));
|
||||
observation = final.observation;
|
||||
total += final.reward;
|
||||
if (final.terminated || final.truncated) break;
|
||||
}
|
||||
if (!final) throw new Error("environment manifest must permit at least one step");
|
||||
return { total, final };
|
||||
const result = rollout(entry.create(), (observation) => policy(observation), {
|
||||
seed,
|
||||
scenario: scenarioId,
|
||||
});
|
||||
return { total: result.total, final: result.final };
|
||||
}
|
||||
|
||||
describe("arena contract and manifests", () => {
|
||||
it("exports five versioned renderer-independent manifests with disjoint public splits", () => {
|
||||
it("exports six versioned renderer-independent manifests with disjoint public splits", () => {
|
||||
assert.deepEqual(ARENA_MANIFESTS.map((manifest: ArenaManifest) => manifest.id), [
|
||||
"drive-101-v1", "office-nav-v1", "office-jobs-v1", "crow-nav-v1", "california-flight-v1",
|
||||
"studio-ops-v1",
|
||||
]);
|
||||
for (const manifest of ARENA_MANIFESTS) {
|
||||
assert.equal(manifest.apiVersion, ARENA_API_VERSION);
|
||||
@@ -170,9 +205,13 @@ describe("arena contract and manifests", () => {
|
||||
for (const entry of CASES) {
|
||||
const id = entry.registry.ids("train")[0]!;
|
||||
const idle = run(entry, id, 5, () => entry.inaction).final;
|
||||
assert.equal(idle.terminated, false, entry.name);
|
||||
assert.equal(idle.truncated, true, entry.name);
|
||||
assert.equal(idle.info.terminalReason, "max-steps", entry.name);
|
||||
// `truncated` is set by the step cap and by nothing else, in every
|
||||
// environment. An inaction outcome that is not `max-steps` is a genuine
|
||||
// terminal and must therefore set `terminated` instead — which is the
|
||||
// whole distinction this test exists to pin.
|
||||
assert.equal(idle.info.terminalReason, entry.inactionReason, entry.name);
|
||||
assert.equal(idle.truncated, entry.inactionReason === "max-steps", entry.name);
|
||||
assert.equal(idle.terminated, entry.inactionReason !== "max-steps", entry.name);
|
||||
|
||||
const scripted = run(entry, id, 5, entry.scripted).final;
|
||||
assert.equal(scripted.terminated, true, entry.name);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Test home for the `arena` workstream.
|
||||
#
|
||||
# Each build workstream owns its own subdirectory so eight builders can add
|
||||
# suites in parallel without ever colliding on a path. `npm test` picks these
|
||||
# up through the widened `src/test/**/*.test.ts` glob in package.json.
|
||||
@@ -0,0 +1,157 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
ARENA_CHECKSUM_DECIMALS,
|
||||
StudioOpsEnvironment,
|
||||
arenaChecksum,
|
||||
canonicalJson,
|
||||
quantizeForChecksum,
|
||||
quantizeToPlaces,
|
||||
studioOpsScriptedBaseline,
|
||||
} from "../../index.ts";
|
||||
|
||||
describe("canonical json refuses what it cannot describe", () => {
|
||||
it("throws on Map, Set and Date rather than silently emitting an empty object", () => {
|
||||
// The three named in the build spec, and the reason this test exists: each
|
||||
// of them reached the `typeof value === "object"` branch, had no own
|
||||
// enumerable keys, and hashed as `{}`.
|
||||
assert.throws(() => canonicalJson(new Map()), /do not accept Map/);
|
||||
assert.throws(() => canonicalJson(new Set()), /do not accept Set/);
|
||||
assert.throws(() => canonicalJson(new Date()), /do not accept Date/);
|
||||
});
|
||||
|
||||
it("would have hashed a populated Map identically to an empty object", () => {
|
||||
// The regression itself, stated as the thing that must never come back: if
|
||||
// this ever stops throwing, assert that the two do not agree.
|
||||
const populated = new Map([["a", 1], ["b", 2]]);
|
||||
assert.throws(() => arenaChecksum({ operations: populated }));
|
||||
assert.throws(() => arenaChecksum({ operations: new Map() }));
|
||||
});
|
||||
|
||||
it("throws on every other non-plain object, and names it", () => {
|
||||
class Operations {
|
||||
readonly id = "sf";
|
||||
}
|
||||
assert.throws(() => canonicalJson(new Operations()), /do not accept Operations/);
|
||||
assert.throws(() => canonicalJson(new Float64Array(3)), /do not accept Float64Array/);
|
||||
assert.throws(() => canonicalJson(new WeakMap()), /do not accept WeakMap/);
|
||||
assert.throws(() => canonicalJson(/x/), /do not accept RegExp/);
|
||||
assert.throws(() => canonicalJson(Object.create({ inherited: true })), /do not accept/);
|
||||
// Nested, because the dangerous case is a field somebody added to a
|
||||
// snapshot rather than a value somebody handed to the hash directly.
|
||||
assert.throws(() => canonicalJson({ simulation: { stations: new Map() } }), /do not accept Map/);
|
||||
assert.throws(() => canonicalJson([1, { at: new Date(0) }]), /do not accept Date/);
|
||||
});
|
||||
|
||||
it("throws on the primitives JSON has no room for", () => {
|
||||
assert.throws(() => canonicalJson(() => 1), /do not accept function/);
|
||||
assert.throws(() => canonicalJson(10n), /do not accept bigint/);
|
||||
assert.throws(() => canonicalJson(Symbol("x")), /do not accept symbol/);
|
||||
assert.throws(() => canonicalJson(Number.NaN), /finite/);
|
||||
assert.throws(() => canonicalJson(Number.POSITIVE_INFINITY), /finite/);
|
||||
});
|
||||
|
||||
it("still accepts everything a snapshot legitimately contains", () => {
|
||||
assert.equal(
|
||||
canonicalJson({ b: 1, a: [true, null, "x"], c: Object.create(null) }),
|
||||
'{"a":[true,null,"x"],"b":1,"c":{}}',
|
||||
);
|
||||
// Key order is canonical and `undefined` is omitted rather than encoded, so
|
||||
// two objects that differ only in those ways hash the same.
|
||||
assert.equal(arenaChecksum({ a: 1, b: 2 }), arenaChecksum({ b: 2, a: 1, c: undefined }));
|
||||
});
|
||||
|
||||
it("refuses cycles instead of recursing forever", () => {
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
assert.throws(() => canonicalJson(cyclic), /cycles/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checksum quantisation survives a last-place float disagreement", () => {
|
||||
it("rounds non-integers to the documented decimals and leaves integers exact", () => {
|
||||
assert.equal(ARENA_CHECKSUM_DECIMALS, 9);
|
||||
assert.equal(quantizeForChecksum(1 / 3), 0.333333333);
|
||||
// Collapsed to a positive zero, not a negative one: `Object.is` is what
|
||||
// tells the two apart and `canonicalJson` must never emit "-0".
|
||||
assert.ok(Object.is(quantizeForChecksum(-0.0000000004), 0));
|
||||
assert.equal(quantizeToPlaces(1.2345678, 3), 1.235);
|
||||
// Integers pass through untouched, including ones that scaling by 1e9 would
|
||||
// push out of the safe-integer range and *lose* precision on.
|
||||
assert.equal(quantizeForChecksum(9_007_199_254_740_991), 9_007_199_254_740_991);
|
||||
assert.equal(quantizeForChecksum(0), 0);
|
||||
// And so does a magnitude whose own spacing is coarser than the quantum:
|
||||
// rounding it cannot be computed, so the input is returned rather than an
|
||||
// approximation of it.
|
||||
const huge = 1.5e300;
|
||||
assert.equal(quantizeForChecksum(huge), huge);
|
||||
assert.throws(() => quantizeForChecksum(Number.NaN), /finite/);
|
||||
});
|
||||
|
||||
it("hashes two values a last place apart identically", () => {
|
||||
// The failure this defends against: `Math.sin` is not required to be
|
||||
// correctly rounded, so two conforming engines can return neighbouring
|
||||
// doubles for the same argument. Before quantisation that was a verifier
|
||||
// rejecting an honest rollout.
|
||||
const value = 0.8414709848078965;
|
||||
const neighbour = value + Number.EPSILON * value;
|
||||
assert.notEqual(value, neighbour, "the two doubles must genuinely differ");
|
||||
assert.equal(arenaChecksum({ sun: value }), arenaChecksum({ sun: neighbour }));
|
||||
});
|
||||
|
||||
it("still separates values that differ by anything a reward can see", () => {
|
||||
assert.notEqual(arenaChecksum({ reward: 1 }), arenaChecksum({ reward: 1.000001 }));
|
||||
assert.notEqual(arenaChecksum({ reward: 1 }), arenaChecksum({ reward: 1.00000001 }));
|
||||
assert.notEqual(arenaChecksum({ x: 0 }), arenaChecksum({ x: 1e-8 }));
|
||||
});
|
||||
|
||||
it("collapses a negative zero so a vanishing quantity cannot change a hash", () => {
|
||||
assert.equal(arenaChecksum({ x: -0 }), arenaChecksum({ x: 0 }));
|
||||
assert.equal(arenaChecksum({ x: -1e-12 }), arenaChecksum({ x: 0 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("restore pins the simulator sources, not only the manifest", () => {
|
||||
function checkpointed() {
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(23, "train-la-overcast-inspection").observation;
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
observation = environment.step(studioOpsScriptedBaseline(observation)).observation;
|
||||
}
|
||||
return { environment, snapshot: environment.snapshot() };
|
||||
}
|
||||
|
||||
it("carries the environment's own source pins inside the checksummed core", () => {
|
||||
const { snapshot } = checkpointed();
|
||||
assert.match(snapshot.sourceHashes.environment, /^sha256:[0-9a-f]{64}$/);
|
||||
assert.match(snapshot.sourceHashes.simulator, /^sha256:[0-9a-f]{64}$/);
|
||||
const { checksum, ...core } = snapshot;
|
||||
assert.equal(arenaChecksum(core), checksum);
|
||||
});
|
||||
|
||||
it("rejects a snapshot whose sourceHashes differ from the environment's own", () => {
|
||||
// A snapshot used to survive a change to the physics under it: `envHash`
|
||||
// covers the manifest, and the manifest does not move when a walker's
|
||||
// collision epsilon does. Re-signed with a valid checksum, so this can only
|
||||
// be caught by comparing the pins themselves.
|
||||
const { environment, snapshot } = checkpointed();
|
||||
for (const field of ["environment", "simulator"] as const) {
|
||||
const tampered = {
|
||||
...snapshot,
|
||||
sourceHashes: { ...snapshot.sourceHashes, [field]: `sha256:${"9".repeat(64)}` },
|
||||
};
|
||||
const { checksum: _drop, ...core } = tampered;
|
||||
assert.throws(
|
||||
() => environment.restore({ ...core, checksum: arenaChecksum(core) }),
|
||||
/incompatible with this environment/,
|
||||
field,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("still accepts its own snapshot", () => {
|
||||
const { environment, snapshot } = checkpointed();
|
||||
const restored = environment.restore(snapshot);
|
||||
assert.equal(restored.info.step, snapshot.step);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
ARENA_ENVIRONMENTS,
|
||||
ARENA_MANIFESTS,
|
||||
ARENA_SOURCE_HASHES,
|
||||
ArenaScenarioRegistry,
|
||||
arenaEnvironmentIds,
|
||||
rollout,
|
||||
type ArenaManifest,
|
||||
} from "../../index.ts";
|
||||
|
||||
const ARENA_DIR = new URL("../../arena/", import.meta.url);
|
||||
|
||||
describe("ARENA_ENVIRONMENTS is the missing half of the catalogue", () => {
|
||||
it("has a key for every manifest id, and no key that is not one", () => {
|
||||
// Before this existed, `ARENA_MANIFESTS` described environments a harness
|
||||
// had no supported way to instantiate: a caller handed "drive-101-v1" off a
|
||||
// config file kept its own switch, which is a copy of this catalogue
|
||||
// maintained outside the package and wrong the day a sixth env lands.
|
||||
const manifestIds = ARENA_MANIFESTS.map((manifest: ArenaManifest) => manifest.id).sort();
|
||||
assert.deepEqual(Object.keys(ARENA_ENVIRONMENTS).sort(), manifestIds);
|
||||
assert.deepEqual([...arenaEnvironmentIds()].sort(), manifestIds);
|
||||
assert.deepEqual(Object.keys(ARENA_SOURCE_HASHES).sort(), manifestIds);
|
||||
});
|
||||
|
||||
it("returns an object satisfying the whole ArenaEnvironment shape, keyed to its own id", () => {
|
||||
for (const [id, factory] of Object.entries(ARENA_ENVIRONMENTS)) {
|
||||
const environment = factory();
|
||||
assert.equal(environment.manifest.id, id);
|
||||
for (const member of ["reset", "step", "snapshot", "restore", "trace", "replay"] as const) {
|
||||
assert.equal(typeof environment[member], "function", `${id}.${member}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a fresh instance every call, because an episode is state", () => {
|
||||
for (const [id, factory] of Object.entries(ARENA_ENVIRONMENTS)) {
|
||||
const first = factory();
|
||||
const second = factory();
|
||||
assert.notEqual(first, second, id);
|
||||
first.reset(3, { split: "train" });
|
||||
// The second must still be un-reset: two rollouts in flight through the
|
||||
// registry must not be stepping each other's episode.
|
||||
assert.throws(() => second.step({}), /must be reset/, id);
|
||||
}
|
||||
});
|
||||
|
||||
it("drives every environment through the shared rollout by id alone", () => {
|
||||
for (const id of arenaEnvironmentIds()) {
|
||||
const environment = ARENA_ENVIRONMENTS[id]!();
|
||||
const result = rollout(environment, () => ({}), { seed: 12, maxSteps: 24 });
|
||||
assert.equal(result.steps, 24, id);
|
||||
assert.equal(result.final.info.envId, id);
|
||||
assert.ok(Number.isFinite(result.total), id);
|
||||
// Cut short rather than ended, and both flags say so.
|
||||
assert.equal(result.final.terminated || result.final.truncated, false, id);
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps a rollout budget to the manifest and refuses a budget of nothing", () => {
|
||||
const environment = ARENA_ENVIRONMENTS["office-nav-v1"]!();
|
||||
const capped = rollout(environment, () => ({}), { seed: 1, maxSteps: 10_000 });
|
||||
assert.ok(capped.steps <= environment.manifest.maxSteps);
|
||||
assert.throws(
|
||||
() => rollout(ARENA_ENVIRONMENTS["office-nav-v1"]!(), () => ({}), { maxSteps: 0 }),
|
||||
/at least one step/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scenario selection is bound to the id, not to definition order", () => {
|
||||
interface Parameters extends Record<string, number> {
|
||||
marker: number;
|
||||
}
|
||||
|
||||
function registryOf(ids: readonly string[]): ArenaScenarioRegistry<Parameters> {
|
||||
return new ArenaScenarioRegistry<Parameters>(
|
||||
"selection-fixture",
|
||||
ids.map((id, index) => ({
|
||||
id,
|
||||
split: id.startsWith("dev") ? ("dev" as const) : ("train" as const),
|
||||
parameters: { marker: index },
|
||||
})),
|
||||
(parameters) => ({ ...parameters }),
|
||||
);
|
||||
}
|
||||
|
||||
const SEEDS = Array.from({ length: 400 }, (_, index) => index * 7919 + 3);
|
||||
|
||||
it("keeps every seed on the scenario it had when one is inserted in the middle", () => {
|
||||
// The trap this replaces: `candidates[seed % candidates.length]` binds a
|
||||
// seed to an *array position*, so inserting a scenario silently remaps
|
||||
// every seed past it. Nothing fails; the numbers in a results table just
|
||||
// quietly stop meaning what they meant.
|
||||
const before = registryOf(["train-a", "train-b", "train-c", "dev-a"]);
|
||||
const after = registryOf(["train-a", "train-inserted", "train-b", "train-c", "dev-a"]);
|
||||
|
||||
let moved = 0;
|
||||
for (const seed of SEEDS) {
|
||||
const was = before.resolve(seed, { split: "train" }).id;
|
||||
const now = after.resolve(seed, { split: "train" }).id;
|
||||
if (now === "train-inserted") continue;
|
||||
assert.equal(now, was, `seed ${seed}`);
|
||||
moved += 1;
|
||||
}
|
||||
// And the new scenario genuinely wins some seeds, or the assertion above is
|
||||
// vacuous rather than reassuring.
|
||||
assert.ok(moved < SEEDS.length, "the inserted scenario must win some seeds");
|
||||
assert.ok(moved > SEEDS.length * 0.5, "it must not win most of them either");
|
||||
});
|
||||
|
||||
it("is unaffected by reordering the literal at all", () => {
|
||||
const declared = registryOf(["train-a", "train-b", "train-c", "dev-a"]);
|
||||
const shuffled = registryOf(["train-c", "train-a", "dev-a", "train-b"]);
|
||||
for (const seed of SEEDS) {
|
||||
assert.equal(
|
||||
shuffled.resolve(seed, { split: "train" }).id,
|
||||
declared.resolve(seed, { split: "train" }).id,
|
||||
`seed ${seed}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("spreads seeds across the split rather than parking them on one scenario", () => {
|
||||
const registry = registryOf(["train-a", "train-b", "train-c", "dev-a"]);
|
||||
const counts = new Map<string, number>();
|
||||
for (const seed of SEEDS) {
|
||||
const id = registry.resolve(seed, { split: "train" }).id;
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
}
|
||||
assert.equal(counts.size, 3);
|
||||
for (const [id, count] of counts) assert.ok(count > SEEDS.length / 6, `${id}=${count}`);
|
||||
});
|
||||
|
||||
it("bumped every shipped manifest's version, because selection changed under them", () => {
|
||||
// The other half of the fix. A selection change that nothing recorded is
|
||||
// the same silent remap; `version` is what a snapshot, a trace and a
|
||||
// results table are pinned to, so it moves when selection does.
|
||||
for (const manifest of ARENA_MANIFESTS) {
|
||||
const expected = manifest.id === "studio-ops-v1" ? 1 : 2;
|
||||
assert.equal(manifest.version, expected, manifest.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the arena boundary holds", () => {
|
||||
const SOURCES = readdirSync(ARENA_DIR)
|
||||
.filter((name) => name.endsWith(".ts"))
|
||||
.map((name) => ({ name, text: readFileSync(new URL(name, ARENA_DIR), "utf8") }));
|
||||
|
||||
it("has sources to check", () => {
|
||||
assert.ok(SOURCES.length >= 12);
|
||||
});
|
||||
|
||||
it("imports no three.js, no scene adapter, no DOM and no network", () => {
|
||||
// The executable form of ARENA.md's first paragraph and of the build spec's
|
||||
// grep. An arena that reached the renderer would be an arena that cannot be
|
||||
// run headless, and a `studio-ops-v1` that imported `engine/flights.ts` for
|
||||
// its aircraft would have done exactly that on one line.
|
||||
const forbidden =
|
||||
/from ["'](three|three\/[^"']*|\.\.\/engine\/(scene|stage|scenekit|flights|atmosphere|world)\.ts|\.\.\/actors\/sceneActor\.ts|\.\.\/interiors\/officeScene\.ts)["']/;
|
||||
for (const source of SOURCES) {
|
||||
assert.equal(forbidden.test(source.text), false, `${source.name} imports the renderer`);
|
||||
assert.equal(/\bdocument\.|\bwindow\.|\bfetch\(/.test(source.text), false, source.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares a simulator pin for every file studio-ops actually wraps", () => {
|
||||
// A pin list shorter than the import list is a snapshot surviving a change
|
||||
// it should not have survived, so the two are compared rather than trusted.
|
||||
const studioOps = SOURCES.find((source) => source.name === "studioOps.ts")!;
|
||||
const script = readFileSync(new URL("../../../scripts/check-arena-source-hashes.mjs", import.meta.url), "utf8");
|
||||
const pinned = script.slice(script.indexOf('"studio-ops-v1"'));
|
||||
for (const match of studioOps.text.matchAll(/from "\.\.\/([^"]+)"/g)) {
|
||||
const relative = `src/${match[1]}`;
|
||||
assert.ok(pinned.includes(`"${relative}"`), `${relative} is imported but not pinned`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
ARENA_ENVIRONMENTS,
|
||||
ARENA_MANIFESTS,
|
||||
STUDIO_OPS_INACTION,
|
||||
STUDIO_OPS_SCENARIOS,
|
||||
StudioOpsEnvironment,
|
||||
actionWidth,
|
||||
arenaEnvironmentIds,
|
||||
arenaFieldWidth,
|
||||
arenaManifest,
|
||||
flattenAction,
|
||||
flattenObservation,
|
||||
observationWidth,
|
||||
structureAction,
|
||||
studioOpsScriptedBaseline,
|
||||
type ArenaFieldSpec,
|
||||
type StudioOpsAction,
|
||||
type ArenaManifest,
|
||||
type StudioOpsObservation,
|
||||
} from "../../index.ts";
|
||||
|
||||
describe("every manifest declares a space that matches its field list", () => {
|
||||
it("names the same fields, in the same order, on both lists", () => {
|
||||
// The two lists are redundant on purpose — names are the contract that has
|
||||
// been published since v1, spaces are the machine-readable one — and this
|
||||
// is what stops the redundancy rotting into a disagreement.
|
||||
for (const manifest of ARENA_MANIFESTS as readonly ArenaManifest[]) {
|
||||
assert.deepEqual(
|
||||
manifest.observationSpace.map((spec: ArenaFieldSpec) => spec.name),
|
||||
[...manifest.observationFields],
|
||||
manifest.id,
|
||||
);
|
||||
assert.deepEqual(
|
||||
manifest.actionSpace.map((spec: ArenaFieldSpec) => spec.name),
|
||||
[...manifest.actionFields],
|
||||
manifest.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares a usable encoding for every field", () => {
|
||||
for (const manifest of ARENA_MANIFESTS as readonly ArenaManifest[]) {
|
||||
for (const spec of [...manifest.observationSpace, ...manifest.actionSpace]) {
|
||||
// `arenaFieldWidth` is where a malformed spec is caught, so calling it
|
||||
// over the whole catalogue is the validation pass.
|
||||
assert.ok(arenaFieldWidth(spec) >= 1, `${manifest.id}.${spec.name}`);
|
||||
}
|
||||
assert.equal(observationWidth(manifest.id) > 0, true, manifest.id);
|
||||
assert.equal(actionWidth(manifest.id) > 0, true, manifest.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a malformed spec rather than encoding it as something", () => {
|
||||
assert.throws(() => arenaFieldWidth({ name: "x", kind: "float" }), /finite low < high/);
|
||||
assert.throws(
|
||||
() => arenaFieldWidth({ name: "x", kind: "float", low: 1, high: 1 }),
|
||||
/finite low < high/,
|
||||
);
|
||||
assert.throws(
|
||||
() => arenaFieldWidth({ name: "x", kind: "float", low: 0, high: Number.POSITIVE_INFINITY }),
|
||||
/finite low < high/,
|
||||
);
|
||||
assert.throws(() => arenaFieldWidth({ name: "x", kind: "enum", values: [] }), /non-empty/);
|
||||
assert.throws(
|
||||
() => arenaFieldWidth({ name: "x", kind: "enum", values: ["a", "a"] }),
|
||||
/duplicate/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on an environment id nobody published", () => {
|
||||
assert.throws(() => arenaManifest("studio-ops-v2"), /unknown arena environment/);
|
||||
assert.throws(() => flattenObservation("nope", {}), /unknown arena environment/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flattenObservation produces a fixed-width vector of finite numbers", () => {
|
||||
it("matches the declared width at reset for every environment", () => {
|
||||
for (const id of arenaEnvironmentIds()) {
|
||||
const environment = ARENA_ENVIRONMENTS[id]!();
|
||||
const observation = environment.reset(9, { split: "train" }).observation;
|
||||
const vector = flattenObservation(id, observation);
|
||||
assert.equal(vector.length, observationWidth(id), id);
|
||||
assert.ok(vector.every((value) => Number.isFinite(value)), id);
|
||||
}
|
||||
});
|
||||
|
||||
it("stays exactly that width at every step of a full 1200-step studio-ops episode", () => {
|
||||
// A full episode rather than a short one, because the width has to survive
|
||||
// every branch: an empty `nextStationId`, a null payload, a `phase` string
|
||||
// the enum has to already know about, and the truncation at the cap.
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(11, "train-sf-clear-morning-desk-check")
|
||||
.observation as StudioOpsObservation;
|
||||
const widths = new Set([flattenObservation("studio-ops-v1", observation).length]);
|
||||
let steps = 0;
|
||||
let terminalReason: string | null = null;
|
||||
for (let index = 0; index < 1200; index += 1) {
|
||||
// A policy that never works the job but does get the car ready, so the
|
||||
// departure is met and the episode runs the whole cap.
|
||||
const result = environment.step({
|
||||
...STUDIO_OPS_INACTION,
|
||||
micMute: true,
|
||||
vehicleCharge: !observation.vehicleReadyByDeparture,
|
||||
vehiclePrecondition: Math.abs(observation.vehicleCabinC - 21) > 0.5,
|
||||
});
|
||||
observation = result.observation as StudioOpsObservation;
|
||||
widths.add(flattenObservation("studio-ops-v1", observation).length);
|
||||
steps += 1;
|
||||
terminalReason = result.info.terminalReason;
|
||||
if (result.terminated || result.truncated) break;
|
||||
}
|
||||
assert.equal(steps, 1200);
|
||||
assert.equal(terminalReason, "max-steps");
|
||||
assert.deepEqual([...widths], [observationWidth("studio-ops-v1")]);
|
||||
});
|
||||
|
||||
it("keeps the width when the observation is empty, partial or malformed", () => {
|
||||
const width = observationWidth("studio-ops-v1");
|
||||
assert.equal(flattenObservation("studio-ops-v1", {}).length, width);
|
||||
assert.equal(flattenObservation("studio-ops-v1", null).length, width);
|
||||
assert.equal(flattenObservation("studio-ops-v1", { x: Number.NaN }).length, width);
|
||||
assert.ok(
|
||||
flattenObservation("studio-ops-v1", { x: Number.NaN }).every((v) => Number.isFinite(v)),
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes each kind the way the manifest says it does", () => {
|
||||
const space = arenaManifest("studio-ops-v1").observationSpace;
|
||||
const at = (name: string): number => {
|
||||
let cursor = 0;
|
||||
for (const spec of space) {
|
||||
if (spec.name === name) return cursor;
|
||||
cursor += arenaFieldWidth(spec);
|
||||
}
|
||||
throw new Error(`no field ${name}`);
|
||||
};
|
||||
|
||||
// float: the clamped raw value, not a normalization of it.
|
||||
const clamped = flattenObservation("studio-ops-v1", { windKph: 5000 });
|
||||
assert.equal(clamped[at("windKph")], 120);
|
||||
assert.equal(flattenObservation("studio-ops-v1", { windKph: 31.5 })[at("windKph")], 31.5);
|
||||
// A field that is absent or unusable falls back to the declared low, which
|
||||
// cannot be mistaken for a measurement.
|
||||
assert.equal(flattenObservation("studio-ops-v1", {})[at("micLevelDb")], -60);
|
||||
|
||||
// bool: 1 only for a genuine `true`.
|
||||
assert.equal(flattenObservation("studio-ops-v1", { deskOccupied: true })[at("deskOccupied")], 1);
|
||||
assert.equal(flattenObservation("studio-ops-v1", { deskOccupied: 1 })[at("deskOccupied")], 0);
|
||||
|
||||
// enum: one-hot, and all-zeros for a value outside the vocabulary — which
|
||||
// is what a null payload has to encode as. "Carrying nothing" is not a
|
||||
// thing being carried, and must not collide with "carrying a parcel".
|
||||
const cursor = at("weatherCondition");
|
||||
const rain = flattenObservation("studio-ops-v1", { weatherCondition: "rain" });
|
||||
assert.equal(rain.slice(cursor, cursor + 8).reduce((sum, value) => sum + value, 0), 1);
|
||||
assert.equal(rain[cursor + 5], 1);
|
||||
const unknown = flattenObservation("studio-ops-v1", { weatherCondition: "hail" });
|
||||
assert.deepEqual(unknown.slice(cursor, cursor + 8), [0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
assert.equal(flattenObservation("studio-ops-v1", { payload: null })[at("payload")], 0);
|
||||
assert.equal(flattenObservation("studio-ops-v1", { payload: "parcel" })[at("payload")], 1);
|
||||
|
||||
// id: one stable slot in [0, 1), changing exactly when the identity does.
|
||||
const first = flattenObservation("studio-ops-v1", { nextStationId: "sf-studio-monitor" });
|
||||
const same = flattenObservation("studio-ops-v1", { nextStationId: "sf-studio-monitor" });
|
||||
const other = flattenObservation("studio-ops-v1", { nextStationId: "sf-studio-display" });
|
||||
assert.equal(first[at("nextStationId")], same[at("nextStationId")]);
|
||||
assert.notEqual(first[at("nextStationId")], other[at("nextStationId")]);
|
||||
assert.ok(first[at("nextStationId")]! >= 0 && first[at("nextStationId")]! < 1);
|
||||
assert.equal(flattenObservation("studio-ops-v1", { nextStationId: "" })[at("nextStationId")], 0);
|
||||
});
|
||||
|
||||
it("never observes an enum value its own vocabulary does not carry", () => {
|
||||
// The one way a one-hot silently loses information: a `phase` string the
|
||||
// controller assigns and the manifest has never heard of encodes as all
|
||||
// zeros and reads to a policy as an unremarkable state.
|
||||
const space = arenaManifest("studio-ops-v1").observationSpace;
|
||||
const enums = space.filter((spec) => spec.kind === "enum");
|
||||
const seen = new Map<string, Set<string>>(enums.map((spec) => [spec.name, new Set<string>()]));
|
||||
const record = (observation: StudioOpsObservation): void => {
|
||||
for (const spec of enums) {
|
||||
const value = (observation as unknown as Record<string, unknown>)[spec.name];
|
||||
if (typeof value === "string") seen.get(spec.name)!.add(value);
|
||||
}
|
||||
};
|
||||
// Three policies, because one policy visits one corridor of the phase
|
||||
// machine: the scripted baseline works its stations, the drifter never
|
||||
// interacts and sits in `awaiting-interaction`, and the spammer walks into
|
||||
// walls and reaches the recovery phases.
|
||||
const POLICIES: ((observation: StudioOpsObservation, step: number) => StudioOpsAction)[] = [
|
||||
(observation) => studioOpsScriptedBaseline(observation),
|
||||
(observation, step) => ({
|
||||
...STUDIO_OPS_INACTION,
|
||||
micMute: true,
|
||||
x: Math.sin(step / 40) * 0.4,
|
||||
z: Math.cos(step / 37) * 0.4,
|
||||
speakerPlay: observation.deskOccupied,
|
||||
}),
|
||||
() => ({ ...STUDIO_OPS_INACTION, z: 1, interact: true }),
|
||||
];
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
for (const seed of [4, 77]) {
|
||||
for (const policy of POLICIES) {
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(seed, definition.id).observation as
|
||||
StudioOpsObservation;
|
||||
record(observation);
|
||||
for (let index = 0; index < 900; index += 1) {
|
||||
const result = environment.step(policy(observation, index));
|
||||
observation = result.observation as StudioOpsObservation;
|
||||
record(observation);
|
||||
if (result.terminated || result.truncated) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const spec of enums) {
|
||||
for (const value of seen.get(spec.name)!) {
|
||||
assert.ok(spec.values!.includes(value), `${spec.name} observed unknown "${value}"`);
|
||||
}
|
||||
}
|
||||
// And the sweep must actually have seen something, or this proves nothing.
|
||||
assert.ok(seen.get("phase")!.size >= 6, [...seen.get("phase")!].join(","));
|
||||
assert.ok(seen.get("mode")!.size >= 3, [...seen.get("mode")!].join(","));
|
||||
assert.ok(seen.get("weatherCondition")!.size >= 3, [...seen.get("weatherCondition")!].join(","));
|
||||
assert.ok(seen.get("levelId")!.size === 2, [...seen.get("levelId")!].join(","));
|
||||
});
|
||||
});
|
||||
|
||||
describe("structureAction inverts the action encoding", () => {
|
||||
it("round-trips every environment's own baseline action", () => {
|
||||
for (const id of arenaEnvironmentIds()) {
|
||||
const environment = ARENA_ENVIRONMENTS[id]!();
|
||||
environment.reset(2, { split: "train" });
|
||||
const zeroed = structureAction(id, new Array(actionWidth(id)).fill(0));
|
||||
const vector = flattenAction(id, zeroed);
|
||||
assert.equal(vector.length, actionWidth(id), id);
|
||||
assert.deepEqual(structureAction(id, vector), zeroed, id);
|
||||
// And the result is a legal action: stepping with it must not throw.
|
||||
assert.ok(Number.isFinite(environment.step(zeroed).reward), id);
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps a raw network output instead of refusing it", () => {
|
||||
const action = structureAction("studio-ops-v1", [
|
||||
9, -9, 1, 999, 0.4, 5, -1, 0.5, 0.49,
|
||||
]);
|
||||
assert.equal(action.x, 1);
|
||||
assert.equal(action.z, -1);
|
||||
assert.equal(action.interact, true);
|
||||
assert.equal(action.micGain, 36);
|
||||
assert.equal(action.micMute, false);
|
||||
assert.equal(action.speakerVolume, 1);
|
||||
assert.equal(action.speakerPlay, false);
|
||||
assert.equal(action.vehiclePrecondition, true);
|
||||
assert.equal(action.vehicleCharge, false);
|
||||
});
|
||||
|
||||
it("refuses a vector of the wrong length", () => {
|
||||
assert.throws(() => structureAction("studio-ops-v1", [0, 0]), /exactly 9 values/);
|
||||
assert.throws(
|
||||
() => structureAction("studio-ops-v1", new Array(10).fill(0)),
|
||||
/exactly 9 values/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,657 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
STUDIO_OPS_INACTION,
|
||||
STUDIO_OPS_MANIFEST,
|
||||
STUDIO_OPS_SCENARIOS,
|
||||
StudioOpsEnvironment,
|
||||
arenaChecksum,
|
||||
quantizeObservable,
|
||||
rollout,
|
||||
studioOpsEnergyPenalty,
|
||||
studioOpsNoisePenalty,
|
||||
studioOpsScriptedBaseline,
|
||||
studioOverflights,
|
||||
studioSkyAt,
|
||||
studioVehicleReadiness,
|
||||
studioWeatherAt,
|
||||
type StudioOpsAction,
|
||||
type StudioOpsObservation,
|
||||
type StudioOpsReward,
|
||||
} from "../../index.ts";
|
||||
import { Plan } from "../../interiors/plan.ts";
|
||||
import { LUMBRIDGE_HQ } from "../../offices/lumbridge-hq.ts";
|
||||
import { MATEO_COURT } from "../../offices/mateo-court.ts";
|
||||
|
||||
const SEEDS = [1, 0xdecafbad];
|
||||
|
||||
type Observation = StudioOpsObservation;
|
||||
|
||||
function scripted(observation: unknown): StudioOpsAction {
|
||||
return studioOpsScriptedBaseline(observation as Observation);
|
||||
}
|
||||
|
||||
/** Runs a targeted policy and reports where the episode ended. */
|
||||
function reach(
|
||||
scenarioId: string,
|
||||
seed: number,
|
||||
policy: (observation: Observation, step: number) => StudioOpsAction,
|
||||
): { reason: string | null; steps: number; total: number } {
|
||||
const result = rollout(
|
||||
new StudioOpsEnvironment(),
|
||||
(observation, step) => policy(observation as Observation, step),
|
||||
{ seed, scenario: scenarioId },
|
||||
);
|
||||
return { reason: result.final.info.terminalReason, steps: result.steps, total: result.total };
|
||||
}
|
||||
|
||||
describe("studio-ops baselines", () => {
|
||||
it("keeps inaction below zero and never lets it reach the goal", () => {
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
for (const seed of SEEDS) {
|
||||
const idle = rollout(new StudioOpsEnvironment(), () => STUDIO_OPS_INACTION, {
|
||||
seed,
|
||||
scenario: definition.id,
|
||||
});
|
||||
assert.ok(idle.total < 0, `${definition.id}/${seed} inaction=${idle.total}`);
|
||||
assert.notEqual(idle.final.info.terminalReason, "job-complete");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("proves a positive scripted completion that beats inaction on every scenario", () => {
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
for (const seed of SEEDS) {
|
||||
const idle = rollout(new StudioOpsEnvironment(), () => STUDIO_OPS_INACTION, {
|
||||
seed,
|
||||
scenario: definition.id,
|
||||
});
|
||||
const run = rollout(new StudioOpsEnvironment(), scripted, {
|
||||
seed,
|
||||
scenario: definition.id,
|
||||
});
|
||||
const label = `${definition.id}/${seed}`;
|
||||
assert.equal(run.final.info.terminalReason, "job-complete", label);
|
||||
assert.equal(run.final.terminated, true, label);
|
||||
assert.equal(run.final.truncated, false, label);
|
||||
assert.ok(run.total > 0, `${label} scripted=${run.total}`);
|
||||
assert.ok(run.total > idle.total, `${label} ${run.total} <= ${idle.total}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("sums thirteen finite named components and authors no total", () => {
|
||||
const run = rollout(new StudioOpsEnvironment(), scripted, {
|
||||
seed: 5,
|
||||
scenario: "train-sf-clear-morning-desk-check",
|
||||
});
|
||||
const components = run.final.rewardComponents as StudioOpsReward;
|
||||
assert.deepEqual(
|
||||
Object.keys(components).sort(),
|
||||
Object.keys(STUDIO_OPS_MANIFEST.rewardComponents).sort(),
|
||||
);
|
||||
const sum = Object.values(components).reduce((total, value) => total + value, 0);
|
||||
assert.ok(Math.abs(sum - run.final.reward) < 1e-12);
|
||||
assert.ok(Object.values(components).every((value) => Number.isFinite(value)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio-ops terminals are each individually reachable", () => {
|
||||
it("completes the job", () => {
|
||||
assert.equal(reach("train-sf-clear-morning-desk-check", 1, scripted).reason, "job-complete");
|
||||
});
|
||||
|
||||
it("stalls against resolved office collision", () => {
|
||||
// Straight into a wall, forever. The controller's own recovery gives up.
|
||||
const result = reach("train-la-overcast-inspection", 2, () => ({
|
||||
...STUDIO_OPS_INACTION,
|
||||
z: 1,
|
||||
}));
|
||||
assert.equal(result.reason, "collision-stall");
|
||||
});
|
||||
|
||||
it("hits the invalid-interaction limit", () => {
|
||||
const result = reach("train-la-overcast-inspection", 2, () => ({
|
||||
...STUDIO_OPS_INACTION,
|
||||
interact: true,
|
||||
}));
|
||||
assert.equal(result.reason, "wrong-interaction-limit");
|
||||
// Exactly at the limit rather than somewhere after it.
|
||||
assert.equal(result.steps, 8);
|
||||
});
|
||||
|
||||
it("exhausts the studio's energy reserve", () => {
|
||||
// Everything on and the car plugged into a post that draws from the same
|
||||
// reserve. This is the terminal the `energyReservePct` observation exists
|
||||
// to make visible; without it the failure would be unattributable.
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
const result = reach(definition.id, 3, () => ({
|
||||
...STUDIO_OPS_INACTION,
|
||||
speakerVolume: 1,
|
||||
speakerPlay: true,
|
||||
vehiclePrecondition: true,
|
||||
vehicleCharge: true,
|
||||
}));
|
||||
assert.equal(result.reason, "battery-depleted", definition.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("misses the departure", () => {
|
||||
const result = reach("dev-sf-windy-evening-delivery", 1, () => STUDIO_OPS_INACTION);
|
||||
assert.equal(result.reason, "departure-missed");
|
||||
assert.equal(result.steps, 1000);
|
||||
});
|
||||
|
||||
it("declares every one of them except the goal as a safety terminal", () => {
|
||||
assert.deepEqual([...STUDIO_OPS_MANIFEST.safetyTerminals], [
|
||||
"collision-stall",
|
||||
"wrong-interaction-limit",
|
||||
"battery-depleted",
|
||||
"departure-missed",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio-ops snapshot, trace and replay", () => {
|
||||
it("continues bit-exactly from a mid-episode snapshot on every scenario", () => {
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(311, definition.id).observation;
|
||||
for (let index = 0; index < 25; index += 1) {
|
||||
observation = environment.step(scripted(observation)).observation;
|
||||
}
|
||||
const checkpoint = environment.snapshot();
|
||||
const action = scripted(observation);
|
||||
const expected = environment.step(action);
|
||||
const restored = environment.restore(checkpoint);
|
||||
assert.equal(restored.info.step, checkpoint.step, definition.id);
|
||||
// Deep-equal on the whole transition: observation, reward, every
|
||||
// component, both flags and the state checksum.
|
||||
assert.deepEqual(environment.step(action), expected, definition.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("replays a trace to the identical final checksum and cumulative reward", () => {
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(311, definition.id).observation;
|
||||
for (let index = 0; index < 80; index += 1) {
|
||||
const result = environment.step(scripted(observation));
|
||||
observation = result.observation;
|
||||
if (result.terminated || result.truncated) break;
|
||||
}
|
||||
const trace = environment.trace();
|
||||
assert.equal(arenaChecksum({ ...trace, checksum: undefined }), trace.checksum);
|
||||
const replay = new StudioOpsEnvironment().replay(trace);
|
||||
assert.equal(replay.finalStateChecksum, trace.finalStateChecksum, definition.id);
|
||||
assert.equal(replay.cumulativeReward, trace.cumulativeReward, definition.id);
|
||||
assert.equal(replay.steps, trace.steps.length, definition.id);
|
||||
}
|
||||
});
|
||||
|
||||
/** Twelve scripted steps, and the environment left mid-episode. */
|
||||
function midEpisode(): StudioOpsEnvironment {
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(19, "train-la-hot-afternoon-studio").observation;
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
observation = environment.step(scripted(observation)).observation;
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
|
||||
it("throws on a tampered snapshot, and refuses to keep running afterwards", () => {
|
||||
const environment = midEpisode();
|
||||
const snapshot = environment.snapshot();
|
||||
assert.throws(
|
||||
() => environment.restore({ ...snapshot, cumulativeReward: snapshot.cumulativeReward + 1 }),
|
||||
/checksum mismatch/,
|
||||
);
|
||||
// Re-signed, so only the source pins can catch it.
|
||||
const reserved = structuredClone(snapshot.simulation);
|
||||
reserved.energyReserveKWh = -1;
|
||||
const { checksum: _snapshotChecksum, ...snapshotCore } = {
|
||||
...snapshot,
|
||||
simulation: reserved,
|
||||
};
|
||||
assert.throws(
|
||||
() => environment.restore({ ...snapshotCore, checksum: arenaChecksum(snapshotCore) }),
|
||||
/simulation snapshot is invalid/,
|
||||
);
|
||||
// And the environment is now un-reset rather than half-restored: the
|
||||
// episode bookkeeping was written before the simulation payload was
|
||||
// rejected, so continuing would produce a mixture of two episodes.
|
||||
assert.throws(() => environment.trace(), /must be reset/);
|
||||
assert.throws(() => environment.snapshot(), /must be reset/);
|
||||
// A fresh reset brings it back.
|
||||
assert.equal(environment.reset(19, "train-la-hot-afternoon-studio").info.step, 0);
|
||||
});
|
||||
|
||||
it("throws on a tampered trace", () => {
|
||||
const environment = midEpisode();
|
||||
const trace = environment.trace();
|
||||
assert.throws(
|
||||
() => new StudioOpsEnvironment().replay({
|
||||
...trace,
|
||||
cumulativeReward: trace.cumulativeReward + 1,
|
||||
}),
|
||||
/checksum mismatch/,
|
||||
);
|
||||
// A re-signed reward claim: the envelope's own checksum verifies, so the
|
||||
// only thing standing between this and an accepted rollout is that `replay`
|
||||
// recomputes the reward and compares it. This is the cheat the whole
|
||||
// envelope exists to refuse.
|
||||
const inflated = trace.steps.map((frame, index) =>
|
||||
index === 4 ? { ...frame, reward: frame.reward + 1 } : frame,
|
||||
);
|
||||
const { checksum: _rewardChecksum, ...rewardCore } = { ...trace, steps: inflated };
|
||||
assert.throws(
|
||||
() => new StudioOpsEnvironment().replay({
|
||||
...rewardCore,
|
||||
checksum: arenaChecksum(rewardCore),
|
||||
}),
|
||||
/diverged at step 5/,
|
||||
);
|
||||
|
||||
// And a re-signed action swap, which is refused wherever it first shows —
|
||||
// at the frame whose checksum no longer matches, or at the final state if
|
||||
// the swapped action happened to change nothing until the end.
|
||||
const swapped = trace.steps.map((frame, index) =>
|
||||
index === 4 ? { ...frame, action: { ...frame.action, speakerPlay: true } } : frame,
|
||||
);
|
||||
const { checksum: _traceChecksum, ...traceCore } = { ...trace, steps: swapped };
|
||||
assert.throws(
|
||||
() => new StudioOpsEnvironment().replay({
|
||||
...traceCore,
|
||||
checksum: arenaChecksum(traceCore),
|
||||
}),
|
||||
/diverged at step|final state mismatch/,
|
||||
);
|
||||
});
|
||||
|
||||
it("reproduces an episode exactly from the same seed, twice", () => {
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
const first = rollout(new StudioOpsEnvironment(), scripted, {
|
||||
seed: 88,
|
||||
scenario: definition.id,
|
||||
});
|
||||
const second = rollout(new StudioOpsEnvironment(), scripted, {
|
||||
seed: 88,
|
||||
scenario: definition.id,
|
||||
});
|
||||
assert.deepEqual(first, second, definition.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio-ops couples its variables rather than stacking five tasks", () => {
|
||||
it("charges more for the same energy under more cloud, strictly", () => {
|
||||
// The reward's own implementation, over a fine grid: `advanceSimulation`
|
||||
// calls exactly this function with exactly these arguments.
|
||||
for (const loadKw of [0.42, 2.4, 13.9, 152]) {
|
||||
let previous = 0;
|
||||
for (let cloud = 0; cloud <= 1.0001; cloud += 0.02) {
|
||||
const penalty = studioOpsEnergyPenalty(loadKw, cloud);
|
||||
assert.ok(penalty < 0, `${loadKw}@${cloud}`);
|
||||
if (cloud > 0) {
|
||||
assert.ok(
|
||||
Math.abs(penalty) > Math.abs(previous),
|
||||
`magnitude did not rise at cloud=${cloud}, load=${loadKw}`,
|
||||
);
|
||||
}
|
||||
previous = penalty;
|
||||
}
|
||||
}
|
||||
// And it is monotone in the load as well, which is the other half of the
|
||||
// claim that this is an energy price and not a weather penalty.
|
||||
assert.ok(
|
||||
Math.abs(studioOpsEnergyPenalty(150, 0.5)) > Math.abs(studioOpsEnergyPenalty(2, 0.5)),
|
||||
);
|
||||
});
|
||||
|
||||
it("charges more under an overcast sky end to end, even against a heavier load", () => {
|
||||
// Stronger than "all else equal": the hot LA afternoon draws strictly more
|
||||
// power than the overcast one (a 13 K climate gap against a 4 K one) and is
|
||||
// still charged less, because the cloud term dominates the load difference.
|
||||
const energyAt = (scenarioId: string): { energy: number; cloud: number } => {
|
||||
const environment = new StudioOpsEnvironment();
|
||||
environment.reset(1, scenarioId);
|
||||
const result = environment.step(STUDIO_OPS_INACTION);
|
||||
return {
|
||||
energy: (result.rewardComponents as StudioOpsReward).energy,
|
||||
cloud: (result.observation as Observation).cloudCover,
|
||||
};
|
||||
};
|
||||
const clear = energyAt("train-la-hot-afternoon-studio");
|
||||
const overcast = energyAt("train-la-overcast-inspection");
|
||||
assert.ok(clear.cloud < 0.25, `clear cloud=${clear.cloud}`);
|
||||
assert.ok(overcast.cloud > 0.7, `overcast cloud=${overcast.cloud}`);
|
||||
assert.ok(
|
||||
Math.abs(overcast.energy) > Math.abs(clear.energy),
|
||||
`${overcast.energy} vs ${clear.energy}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("charges more for playback the closer an aircraft is, and nothing when silent", () => {
|
||||
const base = {
|
||||
speakerPlaying: true,
|
||||
speakerVolume: 0.6,
|
||||
windKph: 5,
|
||||
micLive: false,
|
||||
deskOccupied: false,
|
||||
};
|
||||
let previous = 0;
|
||||
// Walking the aircraft in from beyond audible range to directly overhead.
|
||||
for (let slant = 5000; slant >= 0; slant -= 100) {
|
||||
const penalty = studioOpsNoisePenalty({ ...base, nearestAircraftSlantM: slant });
|
||||
if (slant < 5000) {
|
||||
assert.ok(penalty < previous, `did not worsen at slant=${slant}`);
|
||||
}
|
||||
previous = penalty;
|
||||
}
|
||||
assert.ok(previous < 0);
|
||||
// Beyond the overhead range it is flat, not negative: an aircraft that
|
||||
// cannot be heard costs nothing.
|
||||
assert.equal(studioOpsNoisePenalty({ ...base, nearestAircraftSlantM: 9000 }), 0);
|
||||
// And a silent speaker cannot be ruined by anything at all.
|
||||
assert.equal(
|
||||
studioOpsNoisePenalty({ ...base, speakerPlaying: false, nearestAircraftSlantM: 0 }),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
studioOpsNoisePenalty({ ...base, speakerVolume: 0, nearestAircraftSlantM: 0 }),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("adds wind and microphone bleed to the same penalty", () => {
|
||||
const quiet = {
|
||||
speakerPlaying: true,
|
||||
speakerVolume: 0.5,
|
||||
nearestAircraftSlantM: 40_000,
|
||||
windKph: 5,
|
||||
micLive: false,
|
||||
deskOccupied: false,
|
||||
};
|
||||
assert.equal(studioOpsNoisePenalty(quiet), 0);
|
||||
assert.ok(studioOpsNoisePenalty({ ...quiet, windKph: 80 }) < 0);
|
||||
assert.ok(studioOpsNoisePenalty({ ...quiet, micLive: true, deskOccupied: true }) < 0);
|
||||
// Bleed needs both: a live microphone in an empty room is not on the take.
|
||||
assert.equal(studioOpsNoisePenalty({ ...quiet, micLive: true }), 0);
|
||||
});
|
||||
|
||||
it("computes the noise component from the sky and wind it publishes", () => {
|
||||
// The wiring, end to end: whatever the observation says the sky and the
|
||||
// wind are doing is what the penalty was computed from. A regression that
|
||||
// read a stale step's weather would break here and nowhere else.
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(31, "train-la-hot-afternoon-studio")
|
||||
.observation as Observation;
|
||||
let sawOverhead = false;
|
||||
for (let index = 0; index < 700; index += 1) {
|
||||
const result = environment.step({
|
||||
...STUDIO_OPS_INACTION,
|
||||
micMute: true,
|
||||
speakerVolume: 0.5,
|
||||
speakerPlay: true,
|
||||
});
|
||||
observation = result.observation as Observation;
|
||||
const expected = studioOpsNoisePenalty({
|
||||
speakerPlaying: observation.speakerPlaying,
|
||||
speakerVolume: observation.speakerVolume,
|
||||
nearestAircraftSlantM: observation.nearestAircraftSlantM,
|
||||
windKph: observation.windKph,
|
||||
micLive: observation.micPowered && !observation.micMuted,
|
||||
deskOccupied: observation.deskOccupied,
|
||||
});
|
||||
assert.equal((result.rewardComponents as StudioOpsReward).noise, expected, `step ${index}`);
|
||||
if (observation.aircraftOverheadCount > 0) sawOverhead = true;
|
||||
if (result.terminated || result.truncated) break;
|
||||
}
|
||||
assert.ok(sawOverhead, "no aircraft came overhead; the assertion above proved nothing");
|
||||
});
|
||||
|
||||
it("couples the microphone to the robot's job rather than to a schedule", () => {
|
||||
// The non-negotiable property, observable: the desk in front of
|
||||
// `sf-desk-mic` is occupied exactly while the robot is working the station
|
||||
// that stands at it, and a mic left live through the rest of the episode is
|
||||
// charged for it.
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(1, "train-sf-clear-morning-desk-check")
|
||||
.observation as Observation;
|
||||
let occupied = 0;
|
||||
let wasted = 0;
|
||||
let ready = 0;
|
||||
for (let index = 0; index < 1200; index += 1) {
|
||||
const result = environment.step({ ...STUDIO_OPS_INACTION, ...scripted(observation), micMute: false });
|
||||
observation = result.observation as Observation;
|
||||
const components = result.rewardComponents as StudioOpsReward;
|
||||
if (observation.deskOccupied) occupied += 1;
|
||||
if (components.audioWaste < 0) wasted += 1;
|
||||
if (components.audioReady > 0) ready += 1;
|
||||
if (result.terminated || result.truncated) break;
|
||||
}
|
||||
assert.ok(occupied > 0, "the robot never reached the desk its microphone serves");
|
||||
assert.ok(ready >= occupied, "a live mic at an occupied desk must be paid for");
|
||||
assert.ok(wasted > 0, "a live mic at an empty desk must be charged for");
|
||||
});
|
||||
|
||||
it("stops charging the mic the moment it is muted", () => {
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(1, "train-la-overcast-inspection")
|
||||
.observation as Observation;
|
||||
const hot = environment.step({ ...STUDIO_OPS_INACTION, micMute: false });
|
||||
assert.equal((hot.rewardComponents as StudioOpsReward).audioWaste < 0, true);
|
||||
observation = hot.observation as Observation;
|
||||
assert.equal(observation.deskOccupied, false);
|
||||
const muted = environment.step({ ...STUDIO_OPS_INACTION, micMute: true });
|
||||
assert.equal((muted.rewardComponents as StudioOpsReward).audioWaste, 0);
|
||||
});
|
||||
|
||||
it("shapes the vehicle with a bounded potential that cannot be farmed", () => {
|
||||
// Potential-based: plugging and unplugging round-trips to zero rather than
|
||||
// paying twice, because the term is the *change* in a bounded readiness.
|
||||
const required = 61.6;
|
||||
assert.equal(studioVehicleReadiness(0, 21, required), 0.5);
|
||||
assert.equal(studioVehicleReadiness(required, 21, required), 1);
|
||||
assert.ok(studioVehicleReadiness(61, 21, required) < studioVehicleReadiness(61.5, 21, required));
|
||||
assert.ok(studioVehicleReadiness(61, 30, required) < studioVehicleReadiness(61, 22, required));
|
||||
// Bounded on both sides, so the shaping cannot diverge.
|
||||
for (const soc of [-10, 0, 50, 500]) {
|
||||
for (const cabin of [-40, 21, 90]) {
|
||||
const value = studioVehicleReadiness(soc, cabin, required);
|
||||
assert.ok(value >= 0 && value <= 1, `${soc}/${cabin}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio-ops weather and sky are scenario parameters, deterministically evolved", () => {
|
||||
const parameters = STUDIO_OPS_SCENARIOS.resolve(7, "dev-sf-windy-evening-delivery").parameters;
|
||||
|
||||
it("is a pure function of the scenario and the elapsed time", () => {
|
||||
for (const seconds of [0, 13.7, 60, 119.9]) {
|
||||
assert.deepEqual(
|
||||
studioWeatherAt(parameters, seconds),
|
||||
studioWeatherAt(parameters, seconds),
|
||||
);
|
||||
}
|
||||
assert.deepEqual(studioOverflights(parameters), studioOverflights(parameters));
|
||||
});
|
||||
|
||||
it("actually moves inside one episode, in every field a policy can read", () => {
|
||||
const samples = [0, 20, 40, 60, 80, 100, 119].map((s) => studioWeatherAt(parameters, s));
|
||||
for (const field of ["cloudCover", "precipitation", "windKph", "windDirDeg"] as const) {
|
||||
const values = new Set(samples.map((sample) => sample[field]));
|
||||
assert.ok(values.size > 1, `${field} never changed`);
|
||||
}
|
||||
const sky = [0, 30, 60, 90, 119].map((s) => studioSkyAt(studioOverflights(parameters), s));
|
||||
assert.ok(new Set(sky.map((entry) => entry.nearestSlantM)).size > 1);
|
||||
});
|
||||
|
||||
it("keeps every reading inside the range its own space declares", () => {
|
||||
for (let seconds = 0; seconds <= 120; seconds += 0.5) {
|
||||
const weather = studioWeatherAt(parameters, seconds);
|
||||
assert.ok(weather.cloudCover >= 0 && weather.cloudCover <= 1);
|
||||
assert.ok(weather.precipitation >= 0 && weather.precipitation <= 1);
|
||||
assert.ok(weather.windKph >= 0);
|
||||
assert.ok(weather.windDirDeg >= 0 && weather.windDirDeg < 360);
|
||||
assert.ok(weather.visibilityKm >= 0.2);
|
||||
}
|
||||
});
|
||||
|
||||
it("never claims a profile was observed when it was invented", () => {
|
||||
// Every shipped scenario is a fixture, and the flag says so. The field
|
||||
// exists for an operator who freezes a real observation into one.
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
const resolved = STUDIO_OPS_SCENARIOS.resolve(3, definition.id);
|
||||
assert.equal(resolved.parameters.weatherReported, false, definition.id);
|
||||
const environment = new StudioOpsEnvironment();
|
||||
const observation = environment.reset(3, definition.id).observation as Observation;
|
||||
assert.equal(observation.weatherReported, false, definition.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("puts the sky and the weather in the scenario hash, where a trace can carry them", () => {
|
||||
const a = STUDIO_OPS_SCENARIOS.resolve(3, "train-la-overcast-inspection");
|
||||
const b = STUDIO_OPS_SCENARIOS.resolve(4, "train-la-overcast-inspection");
|
||||
assert.notEqual(a.hash, b.hash);
|
||||
assert.notEqual(a.parameters.aircraftScheduleSeed, b.parameters.aircraftScheduleSeed);
|
||||
assert.notEqual(a.parameters.cloudPhase, b.parameters.cloudPhase);
|
||||
// The jitter must not turn a named profile into a different one.
|
||||
assert.ok(a.parameters.cloudCoverBase > 0.85 && b.parameters.cloudCoverBase > 0.85);
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio-ops quantises everything a transcendental touched", () => {
|
||||
it("reports no observation finer than the quantum", () => {
|
||||
// The cross-runtime defence, checked where it has to hold: if any of these
|
||||
// carried full double precision, a verifier on other hardware could reject
|
||||
// an honest rollout over the last bit of a sine.
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(2, "train-la-hot-afternoon-studio")
|
||||
.observation as Observation;
|
||||
const fields = [
|
||||
"sunAltitudeDeg", "sunAzimuthDeg", "hourOfDay", "cloudCover", "precipitation",
|
||||
"visibilityKm", "windKph", "windDirDeg", "nearestAircraftSlantM",
|
||||
"vehicleSocPct", "vehicleCabinC", "energyReservePct",
|
||||
] as const;
|
||||
for (let index = 0; index < 400; index += 1) {
|
||||
for (const field of fields) {
|
||||
const value = observation[field];
|
||||
assert.equal(value, quantizeObservable(value), `${field} at step ${index}`);
|
||||
}
|
||||
const result = environment.step(scripted(observation));
|
||||
observation = result.observation as Observation;
|
||||
if (result.terminated || result.truncated) break;
|
||||
}
|
||||
});
|
||||
|
||||
it("puts no Date anywhere near the checksum", () => {
|
||||
// `canonicalJson` throws on one, so this is a live proof rather than a
|
||||
// convention: the snapshot survives being hashed.
|
||||
const environment = new StudioOpsEnvironment();
|
||||
environment.reset(2, "train-sf-clear-morning-desk-check");
|
||||
environment.step(STUDIO_OPS_INACTION);
|
||||
const snapshot = environment.snapshot();
|
||||
assert.match(arenaChecksum(snapshot.simulation), /^fnv1a64:/);
|
||||
assert.equal(typeof JSON.parse(JSON.stringify(snapshot.simulation)), "object");
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio-ops wraps the simulators the renderer drives", () => {
|
||||
it("simulates exactly the devices the shipped packs declare and Plan resolved", () => {
|
||||
// Not a headless copy of the device list: the ids the scenarios name are
|
||||
// the pack's own, and they are the ones `Plan` accepted.
|
||||
const plans = {
|
||||
"lumbridge-hq": new Plan(LUMBRIDGE_HQ, { depth: "public", warn: false }),
|
||||
"mateo-court": new Plan(MATEO_COURT, { depth: "public", warn: false }),
|
||||
};
|
||||
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
|
||||
const plan = plans[definition.parameters.officeId];
|
||||
const mic = plan.device(definition.parameters.micId);
|
||||
const speaker = plan.device(definition.parameters.speakerId);
|
||||
assert.ok(mic, `${definition.id} names a microphone the plan did not resolve`);
|
||||
assert.ok(speaker, `${definition.id} names a speaker the plan did not resolve`);
|
||||
assert.equal(mic.kind, "mic");
|
||||
assert.equal(speaker.kind, "speaker");
|
||||
assert.equal(mic.provenance, "simulated");
|
||||
assert.match(mic.disclosure.toLowerCase(), /simulat/);
|
||||
}
|
||||
});
|
||||
|
||||
it("names the same simulator stack in its manifest as it imports", () => {
|
||||
assert.equal(STUDIO_OPS_MANIFEST.id, "studio-ops-v1");
|
||||
for (const fragment of [
|
||||
"Plan",
|
||||
"robotActivity",
|
||||
"createSimulatedDevices",
|
||||
"createSimulatedVehicleTelemetry",
|
||||
"solarPosition",
|
||||
]) {
|
||||
assert.ok(STUDIO_OPS_MANIFEST.simulator.includes(fragment), fragment);
|
||||
}
|
||||
});
|
||||
|
||||
it("observes the robot job, the hardware, the weather, the car and the sky at once", () => {
|
||||
// The point of the environment, as a shape assertion: forty-four fields
|
||||
// across five groups, none of them constant across the catalogue.
|
||||
const environment = new StudioOpsEnvironment();
|
||||
const observation = environment.reset(1, "dev-la-marine-layer-loft-delivery")
|
||||
.observation as Observation;
|
||||
assert.equal(Object.keys(observation).length, STUDIO_OPS_MANIFEST.observationFields.length);
|
||||
assert.deepEqual(
|
||||
Object.keys(observation).sort(),
|
||||
[...STUDIO_OPS_MANIFEST.observationFields].sort(),
|
||||
);
|
||||
assert.equal(observation.officeId, "mateo-court");
|
||||
assert.equal(observation.levelId, "level-2");
|
||||
assert.equal(observation.micPowered, true);
|
||||
assert.equal(observation.energyReservePct, 100);
|
||||
assert.equal(observation.stepsToDeparture, 1000);
|
||||
});
|
||||
|
||||
it("keeps the loft scenario's desk unreachable, on purpose", () => {
|
||||
// Every LA device is on level 1 and this job runs on level 2, so the
|
||||
// correct play is to mute and get on with it. A policy that has only seen a
|
||||
// reachable desk has not learned the difference between "unmute when
|
||||
// somebody arrives" and "unmute".
|
||||
const environment = new StudioOpsEnvironment();
|
||||
let observation = environment.reset(1, "dev-la-marine-layer-loft-delivery")
|
||||
.observation as Observation;
|
||||
for (let index = 0; index < 400; index += 1) {
|
||||
const result = environment.step(scripted(observation));
|
||||
observation = result.observation as Observation;
|
||||
assert.equal(observation.deskOccupied, false, `step ${index}`);
|
||||
if (result.terminated || result.truncated) break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio-ops device commands settle instead of churning", () => {
|
||||
it("stops re-commanding a setpoint the instrument has already reached", () => {
|
||||
// A gain of 12.005 dB is reported back as 12.01, so an action compared
|
||||
// against the reading would disagree with itself forever and be charged a
|
||||
// churn cost on every step for holding a control still. `normalizeAction`
|
||||
// rounds to the precision the instrument reports, which makes the setpoint
|
||||
// a fixed point.
|
||||
const environment = new StudioOpsEnvironment();
|
||||
environment.reset(1, "train-la-overcast-inspection");
|
||||
const action: StudioOpsAction = {
|
||||
...STUDIO_OPS_INACTION,
|
||||
micGain: 12.005,
|
||||
micMute: true,
|
||||
speakerVolume: 0.2225,
|
||||
};
|
||||
const first = environment.step(action);
|
||||
const settled = environment.step(action);
|
||||
const again = environment.step(action);
|
||||
const controlOf = (result: typeof first): number =>
|
||||
(result.rewardComponents as StudioOpsReward).control;
|
||||
assert.ok(controlOf(first) < 0, "the first step really does issue commands");
|
||||
assert.equal(controlOf(settled), controlOf(again));
|
||||
assert.equal(controlOf(settled), 0);
|
||||
const observation = again.observation as Observation;
|
||||
assert.equal(observation.micGainDb, 12.01);
|
||||
assert.equal(observation.speakerVolume, 0.223);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
# Test home for the `assets` workstream.
|
||||
#
|
||||
# Each build workstream owns its own subdirectory so eight builders can add
|
||||
# suites in parallel without ever colliding on a path. `npm test` picks these
|
||||
# up through the widened `src/test/**/*.test.ts` glob in package.json.
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* The two pieces of device hardware, and the three promises the layers above
|
||||
* them are built on.
|
||||
*
|
||||
* `src/interiors/devices.ts` looks a device's LED up **by name**, `plan.ts`
|
||||
* lays a device out from its **footprint** without building it, and every office
|
||||
* in the product is expected to look the same on every reload. None of those
|
||||
* three is visible from inside the builder, and all three break silently: a
|
||||
* renamed sub-object gives you a mic whose mute light never changes, a footprint
|
||||
* in centimetres gives you a microphone the size of a filing cabinet, and a
|
||||
* `Math.random` slipping into a builder gives you a world that reshuffles itself
|
||||
* between visits.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createAssetContext, kit } from "../../assets/kit.ts";
|
||||
import { MaterialRegistry } from "../../assets/materials.ts";
|
||||
import { DEVICE_ASSET_IDS, DEVICE_INDICATOR_NAME } from "../../assets/office/index.ts";
|
||||
import { seeded, withStubCanvas } from "./fakeCanvas.ts";
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function encodeGeometry(root: THREE.Object3D): string {
|
||||
root.updateMatrixWorld(true);
|
||||
const parts: string[] = [];
|
||||
for (const mesh of meshes(root)) {
|
||||
const position = mesh.geometry.getAttribute("position");
|
||||
let checksum = 0;
|
||||
for (let i = 0; i < position.count; i++) {
|
||||
// Quantised to a tenth of a millimetre. Comparing raw floats would make
|
||||
// this fail on a different machine's `Math.sin`, which is not the thing
|
||||
// being tested.
|
||||
checksum =
|
||||
(checksum * 31 +
|
||||
Math.round(position.getX(i) * 1e4) +
|
||||
Math.round(position.getY(i) * 1e4) * 7 +
|
||||
Math.round(position.getZ(i) * 1e4) * 13) |
|
||||
0;
|
||||
}
|
||||
parts.push(`${mesh.name}:${position.count}:${checksum}`);
|
||||
}
|
||||
return parts.join("|");
|
||||
}
|
||||
|
||||
function disposeObject(root: THREE.Object3D): void {
|
||||
for (const mesh of meshes(root)) mesh.geometry.dispose();
|
||||
}
|
||||
|
||||
describe("device hardware assets", () => {
|
||||
it("registers both devices under the parseable `<ns>:device.<kind>.<placement>` id", () => {
|
||||
assert.deepEqual([...DEVICE_ASSET_IDS], [
|
||||
"tera:device.mic.desk",
|
||||
"tera:device.speaker.desk",
|
||||
]);
|
||||
for (const id of DEVICE_ASSET_IDS) {
|
||||
assert.equal(kit.has(id), true, `${id} is not registered`);
|
||||
// The shape `deviceKindOfAssetId()` parses. A device asset whose id does
|
||||
// not match this is dropped as "not device hardware" with no error, so the
|
||||
// pattern is worth pinning here rather than discovering in a plan's
|
||||
// `problems` array.
|
||||
assert.match(id, /^[a-z0-9-]+:device\.(mic|speaker)\.[a-z0-9-]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("is authored at desk scale, in metres", () => {
|
||||
for (const id of DEVICE_ASSET_IDS) {
|
||||
const footprint = kit.footprintOf(id);
|
||||
for (const [name, value] of [
|
||||
["width", footprint.width],
|
||||
["depth", footprint.depth],
|
||||
["height", footprint.height],
|
||||
] as const) {
|
||||
assert.ok(
|
||||
Number.isFinite(value) && value >= 0.02 && value <= 0.6,
|
||||
`${id} ${name} is ${value}, which is not a desk object in metres`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("encloses its own geometry in the footprint it advertises", () => {
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
for (const id of DEVICE_ASSET_IDS) {
|
||||
const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const footprint = kit.footprintOf(id);
|
||||
// A millimetre of slack, because a footprint is a layout number and not
|
||||
// a measurement of the mesh — but only a millimetre, because `Plan`
|
||||
// spaces things by it and a device that overhangs its own footprint is a
|
||||
// device that ends up inside a monitor.
|
||||
assert.ok(size.x <= footprint.width + 0.001, `${id} is ${size.x} wide`);
|
||||
assert.ok(size.z <= footprint.depth + 0.001, `${id} is ${size.z} deep`);
|
||||
assert.ok(box.max.y <= footprint.height + 0.001, `${id} reaches ${box.max.y}`);
|
||||
assert.ok(box.min.y >= -0.001, `${id} starts below the floor at ${box.min.y}`);
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes a sub-object named `indicator` holding the LED and nothing else", () => {
|
||||
const materials = new MaterialRegistry({ quality: "high" });
|
||||
const restore = withStubCanvas();
|
||||
try {
|
||||
for (const id of DEVICE_ASSET_IDS) {
|
||||
const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
|
||||
const indicator = object.getObjectByName(DEVICE_INDICATOR_NAME);
|
||||
assert.ok(indicator, `${id} has no "${DEVICE_INDICATOR_NAME}" sub-object`);
|
||||
|
||||
const led = meshes(indicator);
|
||||
assert.ok(led.length > 0, `${id} indicator holds no geometry`);
|
||||
for (const mesh of led) {
|
||||
const material = mesh.material as THREE.Material;
|
||||
assert.equal(
|
||||
material.name,
|
||||
"deviceIndicator",
|
||||
`${id} indicator carries ${material.name}, which the device layer would not be able to tint safely`,
|
||||
);
|
||||
// An LED is smaller than a shadow-map texel and has nothing to cast.
|
||||
assert.equal(mesh.castShadow, false);
|
||||
assert.equal(mesh.receiveShadow, false);
|
||||
}
|
||||
|
||||
// And the hardware must NOT be in there: the device layer replaces the
|
||||
// material on everything under this name, so a housing that ended up
|
||||
// inside it would light up with the LED.
|
||||
const hardware = meshes(object).filter((mesh) => !led.includes(mesh));
|
||||
assert.ok(hardware.length > 0, `${id} has no hardware outside its indicator`);
|
||||
for (const mesh of hardware) {
|
||||
assert.notEqual((mesh.material as THREE.Material).name, "deviceIndicator");
|
||||
}
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds byte-identical geometry for the same seed", () => {
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
for (const id of DEVICE_ASSET_IDS) {
|
||||
const first = kit.build(id, createAssetContext({ materials, rand: seeded() }));
|
||||
const second = kit.build(id, createAssetContext({ materials, rand: seeded() }));
|
||||
assert.equal(encodeGeometry(first), encodeGeometry(second), `${id} is not deterministic`);
|
||||
disposeObject(first);
|
||||
disposeObject(second);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every material in one primitive class, so nothing is silently dropped", () => {
|
||||
// `mergeGeometries` refuses a mixture of indexed and non-indexed inputs and
|
||||
// `MeshBin` treats the refusal as "skip this material" — which is not an
|
||||
// error, it is a speaker with no cabinet. The symptom is a *missing* mesh,
|
||||
// so the assertion is on the count of materials that survived.
|
||||
const materials = new MaterialRegistry({ quality: "high" });
|
||||
const restore = withStubCanvas();
|
||||
try {
|
||||
const expected: Record<string, number> = {
|
||||
"tera:device.mic.desk": 4,
|
||||
"tera:device.speaker.desk": 5,
|
||||
};
|
||||
for (const id of DEVICE_ASSET_IDS) {
|
||||
const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
|
||||
const names = new Set(meshes(object).map((m) => (m.material as THREE.Material).name));
|
||||
assert.equal(
|
||||
names.size,
|
||||
expected[id],
|
||||
`${id} came back with ${names.size} materials (${[...names].join(", ")}) — a dropped one means a merge was refused`,
|
||||
);
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives the grille and the shell the roles the device layer expects", () => {
|
||||
const materials = new MaterialRegistry({ quality: "high" });
|
||||
const restore = withStubCanvas();
|
||||
try {
|
||||
const speaker = kit.build(
|
||||
"tera:device.speaker.desk",
|
||||
createAssetContext({ materials, rand: seeded() }),
|
||||
);
|
||||
const names = new Set(meshes(speaker).map((m) => (m.material as THREE.Material).name));
|
||||
assert.ok(names.has("deviceShell"), "the cabinet is missing its moulded-housing role");
|
||||
assert.ok(names.has("deviceMesh"), "the grille is missing its perforated role");
|
||||
// The grille has to be double-sided or the gaps between the slats show
|
||||
// nothing behind them, which is the whole reason it is geometry.
|
||||
const grille = meshes(speaker).find(
|
||||
(m) => (m.material as THREE.Material).name === "deviceMesh",
|
||||
);
|
||||
assert.ok(grille);
|
||||
assert.equal((grille.material as THREE.Material).side, THREE.DoubleSide);
|
||||
disposeObject(speaker);
|
||||
} finally {
|
||||
restore();
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* The smallest `document.createElement("canvas")` that makes `TextureBin` draw.
|
||||
*
|
||||
* `TextureBin.get` returns `null` when there is no canvas to draw on, which is
|
||||
* the right behaviour under Node and is exactly what makes the *material*
|
||||
* assertions in this directory impossible without a stub: a `screenContent`
|
||||
* material built under `node --test` has a null `map` for a reason that has
|
||||
* nothing to do with whether the binding is correct.
|
||||
*
|
||||
* So this installs a context that records nothing and rasterises nothing. It
|
||||
* exists only so that `new THREE.CanvasTexture(canvas)` has a canvas, and the
|
||||
* assertions that follow are about *which map is bound to which slot*, never
|
||||
* about pixels. `src/test/render/textureMaps.test.ts` is where the drawings
|
||||
* themselves are pinned, and duplicating that here would be two files asserting
|
||||
* one thing.
|
||||
*
|
||||
* Deliberately not auto-installing on import: a module with a side effect on
|
||||
* `globalThis` that fires on import is the kind of thing that makes one test
|
||||
* file's behaviour depend on another's import order.
|
||||
*/
|
||||
|
||||
interface StubCanvas {
|
||||
width: number;
|
||||
height: number;
|
||||
getContext(id: string): unknown;
|
||||
}
|
||||
|
||||
function stubContext(): unknown {
|
||||
const noop = (): void => {};
|
||||
return {
|
||||
set fillStyle(_v: unknown) {},
|
||||
get fillStyle(): string {
|
||||
return "";
|
||||
},
|
||||
set strokeStyle(_v: unknown) {},
|
||||
set lineWidth(_v: number) {},
|
||||
set lineCap(_v: string) {},
|
||||
set lineJoin(_v: string) {},
|
||||
set globalAlpha(_v: number) {},
|
||||
set globalCompositeOperation(_v: string) {},
|
||||
set font(_v: string) {},
|
||||
set textAlign(_v: string) {},
|
||||
set textBaseline(_v: string) {},
|
||||
set filter(_v: string) {},
|
||||
save: noop,
|
||||
restore: noop,
|
||||
translate: noop,
|
||||
rotate: noop,
|
||||
scale: noop,
|
||||
clip: noop,
|
||||
fillRect: noop,
|
||||
clearRect: noop,
|
||||
strokeRect: noop,
|
||||
beginPath: noop,
|
||||
closePath: noop,
|
||||
moveTo: noop,
|
||||
lineTo: noop,
|
||||
arc: noop,
|
||||
arcTo: noop,
|
||||
ellipse: noop,
|
||||
rect: noop,
|
||||
quadraticCurveTo: noop,
|
||||
bezierCurveTo: noop,
|
||||
fill: noop,
|
||||
stroke: noop,
|
||||
fillText: noop,
|
||||
createLinearGradient: () => ({ addColorStop: noop }),
|
||||
createRadialGradient: () => ({ addColorStop: noop }),
|
||||
getImageData: (_x: number, _y: number, w: number, h: number) => ({
|
||||
data: new Uint8ClampedArray(Math.max(1, w * h * 4)).fill(255),
|
||||
width: w,
|
||||
height: h,
|
||||
}),
|
||||
putImageData: noop,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the stub and return the undo. Call the undo in a `finally`: leaving a
|
||||
* fake `document` on `globalThis` changes how every module loaded afterwards
|
||||
* decides whether it is in a browser.
|
||||
*/
|
||||
export function withStubCanvas(): () => void {
|
||||
const global = globalThis as { document?: unknown };
|
||||
const had = "document" in global;
|
||||
const previous = global.document;
|
||||
global.document = {
|
||||
createElement(tag: string): StubCanvas {
|
||||
if (tag !== "canvas") throw new Error(`unexpected element <${tag}>`);
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => stubContext(),
|
||||
};
|
||||
},
|
||||
};
|
||||
return () => {
|
||||
if (had) global.document = previous;
|
||||
else delete global.document;
|
||||
};
|
||||
}
|
||||
|
||||
/** A deterministic PRNG, so "same seed, same geometry" is testable at all. */
|
||||
export function seeded(seed = 0x12345678): () => number {
|
||||
let value = seed >>> 0;
|
||||
return () => {
|
||||
value = (Math.imul(value, 1664525) + 1013904223) >>> 0;
|
||||
return value / 0x1_0000_0000;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* The whole catalogue, checked for the two failures that do not raise anything.
|
||||
*
|
||||
* `src/test/officeHabitat.test.ts` already covers the seven habitat ids by
|
||||
* name. This file covers **every registered asset**, including the ones that
|
||||
* have not been written yet, and it exists because the two ways an asset breaks
|
||||
* in this library are both silent:
|
||||
*
|
||||
* 1. **A material gets dropped.** `mergeGeometries` refuses a mixture of indexed
|
||||
* and non-indexed geometry, `MeshBin` treats the refusal as "skip this
|
||||
* material", and the result is not an exception — it is a bench with no top,
|
||||
* or a speaker with no cabinet. The symptom is a *missing* mesh, which is
|
||||
* only visible against an expectation. So this asserts that no asset comes
|
||||
* back with fewer distinct materials than it asked the registry for.
|
||||
* 2. **A footprint stops matching its mesh.** `Plan` lays a room out from
|
||||
* `footprintOf` without ever building the asset, so a footprint that
|
||||
* under-reports is a prop halfway through a wall and a footprint that
|
||||
* over-reports is a room that will not pack. Nothing checks the two against
|
||||
* each other except this.
|
||||
*
|
||||
* The chamfer pass is what made the first of these urgent: turning a desktop
|
||||
* from `box()` into `roundedBoxOf()` changes the primitive class of the whole
|
||||
* `deskSurface` material, and getting that wrong on any of the five slabs it was
|
||||
* applied to would have shipped a desk with no top and thrown nothing.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createAssetContext, kit } from "../../assets/kit.ts";
|
||||
import { MaterialRegistry } from "../../assets/materials.ts";
|
||||
import { OFFICE_ASSETS } from "../../assets/office/index.ts";
|
||||
import { seeded, withStubCanvas } from "./fakeCanvas.ts";
|
||||
|
||||
/**
|
||||
* The registry's own count. Asserted rather than derived so that adding an asset
|
||||
* is a deliberate two-line change and removing one cannot happen by accident —
|
||||
* `index.ts`'s header quotes this number, and a header that quietly disagrees
|
||||
* with the code is worse than no header.
|
||||
*/
|
||||
const CATALOGUE_SIZE = 40;
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function disposeObject(root: THREE.Object3D): void {
|
||||
for (const mesh of meshes(root)) mesh.geometry.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* A registry that records which roles an asset actually asked for, so a dropped
|
||||
* material can be told apart from a material the builder never wanted.
|
||||
*/
|
||||
class CountingRegistry extends MaterialRegistry {
|
||||
readonly requested = new Set<string>();
|
||||
|
||||
override get(role: Parameters<MaterialRegistry["get"]>[0]): ReturnType<MaterialRegistry["get"]> {
|
||||
const material = super.get(role);
|
||||
this.requested.add(material.name);
|
||||
return material;
|
||||
}
|
||||
|
||||
override tinted(
|
||||
role: Parameters<MaterialRegistry["tinted"]>[0],
|
||||
color: number,
|
||||
): ReturnType<MaterialRegistry["tinted"]> {
|
||||
const material = super.tinted(role, color);
|
||||
this.requested.add(material.name);
|
||||
return material;
|
||||
}
|
||||
|
||||
override variant(
|
||||
role: Parameters<MaterialRegistry["variant"]>[0],
|
||||
index: number,
|
||||
color?: number,
|
||||
): ReturnType<MaterialRegistry["variant"]> {
|
||||
const material = super.variant(role, index, color);
|
||||
this.requested.add(material.name);
|
||||
return material;
|
||||
}
|
||||
}
|
||||
|
||||
describe("the office catalogue as a whole", () => {
|
||||
it("registers exactly the assets `index.ts` says it does", () => {
|
||||
assert.equal(OFFICE_ASSETS.length, CATALOGUE_SIZE);
|
||||
const ids = OFFICE_ASSETS.map((def) => def.id);
|
||||
assert.equal(new Set(ids).size, ids.length, "two assets share an id");
|
||||
for (const id of ids) {
|
||||
assert.equal(kit.has(id), true, `${id} is exported but not registered`);
|
||||
assert.match(id, /^tera:/, `${id} is not in the tera namespace`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every material it asked for — nothing is silently dropped in the merge", () => {
|
||||
const restore = withStubCanvas();
|
||||
for (const def of OFFICE_ASSETS) {
|
||||
const materials = new CountingRegistry({ quality: "high" });
|
||||
try {
|
||||
const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
|
||||
const drawn = new Set(meshes(object).map((m) => (m.material as THREE.Material).name));
|
||||
const missing = [...materials.requested].filter((name) => !drawn.has(name));
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`${def.id} asked for ${missing.join(", ")} and drew nothing in it — a merge was refused, which means an indexed part and an extrusion ended up in the same material`,
|
||||
);
|
||||
disposeObject(object);
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
}
|
||||
restore();
|
||||
});
|
||||
|
||||
it("advertises a footprint that encloses its own mesh", () => {
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
for (const def of OFFICE_ASSETS) {
|
||||
const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const footprint = kit.footprintOf(def.id);
|
||||
// 20 mm of slack, which is a finger's width. `Plan` spaces rooms with
|
||||
// these numbers, so a prop that overhangs its own footprint by more than
|
||||
// that is a prop that ends up inside a wall.
|
||||
assert.ok(
|
||||
size.x <= footprint.width + 0.02,
|
||||
`${def.id} is ${size.x.toFixed(3)} wide against a stated ${footprint.width}`,
|
||||
);
|
||||
assert.ok(
|
||||
size.z <= footprint.depth + 0.02,
|
||||
`${def.id} is ${size.z.toFixed(3)} deep against a stated ${footprint.depth}`,
|
||||
);
|
||||
assert.ok(
|
||||
box.max.y <= footprint.height + 0.02,
|
||||
`${def.id} reaches ${box.max.y.toFixed(3)} against a stated ${footprint.height}`,
|
||||
);
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the two ceiling fittings, and only those two, hanging below their origin", () => {
|
||||
// `light.pendant` and `light.troffer` are the library's only exceptions to
|
||||
// "origin on the floor" (`common.ts`): their datum is the mounting plane and
|
||||
// all their geometry is at `y ≤ 0`, so a pack writes `elevation: 2.9` and
|
||||
// gets a lamp at 2.9 m. Every other asset — the floor lamp and the softbox
|
||||
// included — stands on the floor, and an asset that quietly adopts the
|
||||
// ceiling convention would sink through it.
|
||||
const ceilingHung = new Set(["tera:light.pendant", "tera:light.troffer"]);
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
for (const def of OFFICE_ASSETS) {
|
||||
const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
if (ceilingHung.has(def.id)) {
|
||||
assert.ok(box.max.y <= 0.001, `${def.id} has geometry above its mounting plane`);
|
||||
} else {
|
||||
assert.ok(box.min.y >= -0.02, `${def.id} starts ${box.min.y} below the floor`);
|
||||
}
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds no light source anywhere in the catalogue", () => {
|
||||
// CONTRACT.md §4: Atmosphere is the sole light owner. Sixteen fittings each
|
||||
// carrying a `PointLight` is both the wrong owner and, past about four
|
||||
// shadow-casting lights, the end of the frame budget.
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
for (const def of OFFICE_ASSETS) {
|
||||
const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
|
||||
object.traverse((child) => {
|
||||
assert.equal(child instanceof THREE.Light, false, `${def.id} constructs a light`);
|
||||
});
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic for the same seed, across the whole catalogue", () => {
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
for (const def of OFFICE_ASSETS) {
|
||||
const first = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
|
||||
const second = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
|
||||
const encode = (root: THREE.Object3D): string => {
|
||||
const box = new THREE.Box3().setFromObject(root);
|
||||
const counts = meshes(root)
|
||||
.map((m) => `${m.name}:${m.geometry.getAttribute("position").count}`)
|
||||
.join(",");
|
||||
return `${counts}|${[...box.min.toArray(), ...box.max.toArray()]
|
||||
.map((v) => v.toFixed(6))
|
||||
.join(",")}`;
|
||||
};
|
||||
assert.equal(encode(first), encode(second), `${def.id} is not deterministic`);
|
||||
disposeObject(first);
|
||||
disposeObject(second);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* The Model X hero pass, asserted where it can be asserted: on the mesh.
|
||||
*
|
||||
* Most of what makes a car look like a car is not testable and this file does
|
||||
* not pretend otherwise. What *is* testable is the set of properties that broke
|
||||
* the first version, each of which failed silently and none of which is visible
|
||||
* from reading the builder:
|
||||
*
|
||||
* - the LOD split was nominal — a `follow` car that costs what a `corridor` car
|
||||
* costs is forty background cars nobody budgeted for;
|
||||
* - the shoulder crease was averaged away by one `computeVertexNormals()` over
|
||||
* the whole shell, so the geometry had a crease and the shading did not;
|
||||
* - the wheels intersected a flat flank, because there were no arches;
|
||||
* - the glass floated on the paint instead of filling an opening;
|
||||
* - the paint carried a fake ambient emissive that now double-counts against a
|
||||
* real environment map.
|
||||
*
|
||||
* The bounding-box assertions are the load-bearing ones for everything
|
||||
* downstream: `MODEL_X_METRICS` is what `src/transport` collides, frames and
|
||||
* lane-positions with, and a mesh that is wider than its own published width is
|
||||
* a car that clips kerbs it looked like it cleared.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import {
|
||||
MODEL_X_METRICS,
|
||||
MODEL_X_PAINTS,
|
||||
buildModelX,
|
||||
cloneModelX,
|
||||
createModelXMaterials,
|
||||
createModelXPaintPool,
|
||||
disposeModelX,
|
||||
disposeModelXPaintPool,
|
||||
type ModelXDetail,
|
||||
type ModelXRig,
|
||||
} from "../../assets/vehicles/index.ts";
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function triangles(root: THREE.Object3D): number {
|
||||
let total = 0;
|
||||
for (const mesh of meshes(root)) {
|
||||
const index = mesh.geometry.getIndex();
|
||||
total += index ? index.count / 3 : mesh.geometry.getAttribute("position").count / 3;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function meshNamed(rig: ModelXRig, material: string): THREE.Mesh {
|
||||
const found = meshes(rig.root).find(
|
||||
(mesh) => (mesh.material as THREE.Material).name === material,
|
||||
);
|
||||
assert.ok(found, `no mesh carries the "${material}" material`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function localBox(mesh: THREE.Mesh): THREE.Box3 {
|
||||
const box = new THREE.Box3();
|
||||
box.setFromBufferAttribute(mesh.geometry.getAttribute("position") as THREE.BufferAttribute);
|
||||
return box;
|
||||
}
|
||||
|
||||
describe("Model X hero geometry", () => {
|
||||
it("spends a hero budget at `follow` and a traffic budget at `corridor`", () => {
|
||||
const follow = buildModelX({ detail: "follow" });
|
||||
const corridor = buildModelX({ detail: "corridor" });
|
||||
|
||||
const followTriangles = triangles(follow.root);
|
||||
const corridorTriangles = triangles(corridor.root);
|
||||
|
||||
// The acceptance window. Below 4,000 it is not a hero asset; above 24,000 it
|
||||
// stops being one car's worth of the office's 550,000-triangle budget.
|
||||
assert.ok(
|
||||
followTriangles >= 4_000 && followTriangles <= 24_000,
|
||||
`follow LOD is ${followTriangles} triangles`,
|
||||
);
|
||||
// `corridor` is instanced up to forty times by `engine/roadTraffic.ts`, so
|
||||
// its count is multiplied by forty against the *city's* budget. Half of
|
||||
// follow is the ceiling that keeps that honest.
|
||||
assert.ok(
|
||||
corridorTriangles < followTriangles / 2,
|
||||
`corridor LOD is ${corridorTriangles} against follow's ${followTriangles} — the split is nominal`,
|
||||
);
|
||||
|
||||
// And the split has to be in the geometry rather than only in the count:
|
||||
// corridor may not cost more draw calls than it did as a torus-wheeled box.
|
||||
assert.ok(meshes(corridor.root).length <= 20, "corridor LOD grew its draw calls");
|
||||
|
||||
disposeModelX(follow);
|
||||
disposeModelX(corridor);
|
||||
});
|
||||
|
||||
it("stays inside the silhouette `MODEL_X_METRICS` publishes, at both LODs", () => {
|
||||
for (const detail of ["corridor", "follow"] as ModelXDetail[]) {
|
||||
const rig = buildModelX({ detail });
|
||||
const box = new THREE.Box3().setFromObject(rig.root);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
|
||||
assert.ok(size.x <= 2.1, `${detail} is ${size.x} wide`);
|
||||
assert.ok(size.y <= 1.8, `${detail} is ${size.y} tall`);
|
||||
assert.ok(size.z <= 5.1, `${detail} is ${size.z} long`);
|
||||
|
||||
// And it has to actually be the car those numbers describe, not merely
|
||||
// smaller than them.
|
||||
assert.ok(Math.abs(size.x - MODEL_X_METRICS.width) < 0.05, `${detail} width ${size.x}`);
|
||||
assert.ok(Math.abs(size.y - MODEL_X_METRICS.height) < 0.05, `${detail} height ${size.y}`);
|
||||
assert.ok(Math.abs(size.z - MODEL_X_METRICS.length) < 0.06, `${detail} length ${size.z}`);
|
||||
|
||||
// The tyres define y = 0. A body that dips below it is a car sunk into the
|
||||
// road; a car that floats is worse, because the shadow gives it away.
|
||||
assert.ok(Math.abs(box.min.y) < 0.005, `${detail} does not sit on the road: ${box.min.y}`);
|
||||
|
||||
disposeModelX(rig);
|
||||
}
|
||||
});
|
||||
|
||||
it("names the mirrors and the window frames so they can be found", () => {
|
||||
const rig = buildModelX({ detail: "follow" });
|
||||
const mirrors = rig.root.getObjectByName("model-x.mirrors");
|
||||
const frames = rig.root.getObjectByName("model-x.glass-frames");
|
||||
assert.ok(mirrors, "no mirrors on the hero LOD");
|
||||
assert.ok(frames, "no window surround on the hero LOD");
|
||||
assert.ok(meshes(mirrors).length > 0);
|
||||
assert.ok(meshes(frames).length > 0);
|
||||
|
||||
// A mirror is a first-surface reflector and the side glass is tinted and
|
||||
// translucent. One material cannot be both, and with an environment map
|
||||
// present that difference is most of what makes a mirror read as one.
|
||||
const mirrorMaterials = meshes(mirrors).map((m) => (m.material as THREE.Material).name);
|
||||
assert.ok(mirrorMaterials.includes("model-x.mirror"));
|
||||
|
||||
// The corridor car does without both: nobody resolves a wing mirror at the
|
||||
// distance forty instanced cars are drawn at.
|
||||
const corridor = buildModelX({ detail: "corridor" });
|
||||
assert.equal(corridor.root.getObjectByName("model-x.mirrors"), undefined);
|
||||
assert.equal(corridor.root.getObjectByName("model-x.glass-frames"), undefined);
|
||||
|
||||
disposeModelX(rig);
|
||||
disposeModelX(corridor);
|
||||
});
|
||||
|
||||
it("keeps the shoulder crease by splitting normals rather than averaging them", () => {
|
||||
// The failure this replaces: one `computeVertexNormals()` over the whole
|
||||
// shell. The test for "there is a crease" is that two vertices share a
|
||||
// position and disagree about which way the surface points — which is
|
||||
// exactly what a split vertex is, and what a single averaged pass destroys.
|
||||
const rig = buildModelX({ detail: "follow" });
|
||||
const paint = meshNamed(rig, "model-x.paint");
|
||||
const position = paint.geometry.getAttribute("position");
|
||||
const normal = paint.geometry.getAttribute("normal");
|
||||
assert.ok(normal, "the bodyshell has no normals");
|
||||
|
||||
const byPosition = new Map<string, THREE.Vector3[]>();
|
||||
for (let i = 0; i < position.count; i++) {
|
||||
// Only the right flank, at the height the shoulder line runs, and only
|
||||
// between the axles: that is where the crease is, and looking everywhere
|
||||
// would pass on any hard edge anywhere on the car.
|
||||
const x = position.getX(i);
|
||||
const y = position.getY(i);
|
||||
const z = position.getZ(i);
|
||||
if (x < 0.9 || y < 0.85 || y > 0.99 || z < -1.2 || z > 1.2) continue;
|
||||
const key = `${Math.round(x * 1e3)},${Math.round(y * 1e3)},${Math.round(z * 1e3)}`;
|
||||
const list = byPosition.get(key) ?? [];
|
||||
list.push(new THREE.Vector3(normal.getX(i), normal.getY(i), normal.getZ(i)));
|
||||
byPosition.set(key, list);
|
||||
}
|
||||
|
||||
let creased = 0;
|
||||
for (const normals of byPosition.values()) {
|
||||
for (let a = 0; a < normals.length; a++) {
|
||||
for (let b = a + 1; b < normals.length; b++) {
|
||||
// 20° is well past any smoothing artefact and well under the ~60° the
|
||||
// shoulder actually turns through.
|
||||
if (normals[a]!.dot(normals[b]!) < Math.cos(0.35)) creased++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
creased >= 4,
|
||||
`found ${creased} split normals along the shoulder line — the crease has been averaged away again`,
|
||||
);
|
||||
disposeModelX(rig);
|
||||
});
|
||||
|
||||
it("cuts real wheel openings, with the tyre inside them", () => {
|
||||
const rig = buildModelX({ detail: "follow" });
|
||||
rig.root.updateMatrixWorld(true);
|
||||
const paint = meshNamed(rig, "model-x.paint");
|
||||
const position = paint.geometry.getAttribute("position");
|
||||
|
||||
// At the front axle plane, the paint has to *stop* well above the road on
|
||||
// the outboard side — that gap is the wheel opening. On the first version
|
||||
// the flank ran straight down past the tyre and this was 0.24 m.
|
||||
const axle = -MODEL_X_METRICS.wheelbase / 2;
|
||||
let lowestOutboard = Infinity;
|
||||
for (let i = 0; i < position.count; i++) {
|
||||
if (Math.abs(position.getZ(i) - axle) > 0.03) continue;
|
||||
if (position.getX(i) < 0.95) continue;
|
||||
lowestOutboard = Math.min(lowestOutboard, position.getY(i));
|
||||
}
|
||||
assert.ok(Number.isFinite(lowestOutboard), "no bodyside at the front axle at all");
|
||||
assert.ok(
|
||||
lowestOutboard > MODEL_X_METRICS.wheelRadius * 2 - 0.05,
|
||||
`the flank reaches down to ${lowestOutboard} at the front axle, which is through the tyre`,
|
||||
);
|
||||
|
||||
// And the tyre has to fit under it rather than through it.
|
||||
const tire = rig.root.getObjectByName("frontRight.tire");
|
||||
assert.ok(tire instanceof THREE.Mesh);
|
||||
const tireBox = new THREE.Box3().setFromObject(tire);
|
||||
assert.ok(tireBox.max.y < lowestOutboard, "the tyre pokes through the arch lip");
|
||||
assert.ok(tireBox.max.y > 0.75, "the tyre is not the published diameter");
|
||||
|
||||
disposeModelX(rig);
|
||||
});
|
||||
|
||||
it("gives the tyre a sidewall, a shoulder and a flat tread", () => {
|
||||
const rig = buildModelX({ detail: "follow" });
|
||||
const tire = rig.root.getObjectByName("frontRight.tire");
|
||||
assert.ok(tire instanceof THREE.Mesh);
|
||||
const position = tire.geometry.getAttribute("position");
|
||||
|
||||
// The wheel axis is X. A torus has one radius; a real tyre has a tread band
|
||||
// at the full radius and a sidewall that bulges *wider* than the tread while
|
||||
// sitting at a smaller radius, and that is the whole silhouette of a loaded
|
||||
// tyre.
|
||||
let treadHalfWidth = 0;
|
||||
let widest = 0;
|
||||
let sidewallRadius = 0;
|
||||
for (let i = 0; i < position.count; i++) {
|
||||
const x = Math.abs(position.getX(i));
|
||||
const r = Math.hypot(position.getY(i), position.getZ(i));
|
||||
widest = Math.max(widest, x);
|
||||
if (r > MODEL_X_METRICS.wheelRadius - 0.002) treadHalfWidth = Math.max(treadHalfWidth, x);
|
||||
if (x > widest - 1e-6) sidewallRadius = r;
|
||||
}
|
||||
assert.ok(treadHalfWidth > 0.08, `the tread band is only ${treadHalfWidth * 2} m wide`);
|
||||
assert.ok(
|
||||
widest > treadHalfWidth + 0.01,
|
||||
"the sidewall does not stand outboard of the tread — this is still a torus",
|
||||
);
|
||||
assert.ok(
|
||||
sidewallRadius < MODEL_X_METRICS.wheelRadius - 0.03,
|
||||
"the widest point of the tyre is at full radius, so there is no sidewall",
|
||||
);
|
||||
disposeModelX(rig);
|
||||
});
|
||||
|
||||
it("puts the caliper on the upright, not on the spinning hub", () => {
|
||||
const rig = buildModelX({ detail: "follow" });
|
||||
const caliper = rig.root.getObjectByName("frontRight.caliper");
|
||||
assert.ok(caliper, "the brake has no caliper");
|
||||
// A caliper that rotates with the wheel is the single most common tell that
|
||||
// a wheel was assembled quickly. Its parent must be the steering group.
|
||||
assert.equal(caliper.parent?.name, "frontRight.steering");
|
||||
assert.equal(rig.wheels.frontRight.spin.getObjectByName("frontRight.caliper"), undefined);
|
||||
disposeModelX(rig);
|
||||
});
|
||||
|
||||
it("fills the daylight opening with glass rather than laying glass on the paint", () => {
|
||||
const rig = buildModelX({ detail: "follow" });
|
||||
const glass = meshNamed(rig, "model-x.glass");
|
||||
const paint = meshNamed(rig, "model-x.paint");
|
||||
const glassBox = localBox(glass);
|
||||
const paintPosition = paint.geometry.getAttribute("position");
|
||||
|
||||
// The greenhouse sits above the beltline and inside the body's own width.
|
||||
assert.ok(glassBox.min.y > 0.95, `glass starts at ${glassBox.min.y}, below the beltline`);
|
||||
assert.ok(glassBox.max.y > 1.6, "there is no glass at roof height — the roof is not glazed");
|
||||
assert.ok(glassBox.max.x < MODEL_X_METRICS.width / 2, "the glass stands outside the body");
|
||||
|
||||
// And the paint has to have got out of the way: in the middle of the front
|
||||
// door, at glass height, there should be no painted surface outboard of the
|
||||
// recessed opening.
|
||||
let paintedInTheWindow = 0;
|
||||
for (let i = 0; i < paintPosition.count; i++) {
|
||||
const z = paintPosition.getZ(i);
|
||||
const y = paintPosition.getY(i);
|
||||
const x = paintPosition.getX(i);
|
||||
if (z < -0.7 || z > -0.1) continue;
|
||||
if (y < 1.12 || y > 1.42) continue;
|
||||
if (x > 0.9) paintedInTheWindow++;
|
||||
}
|
||||
assert.equal(
|
||||
paintedInTheWindow,
|
||||
0,
|
||||
"there is still paint where the front door glass should be — the window is not an opening",
|
||||
);
|
||||
disposeModelX(rig);
|
||||
});
|
||||
|
||||
it("has no emissive term left on the paint or the wheel", () => {
|
||||
// The environment rig supplies real reflections now, so the fake ambient
|
||||
// these two carried double-counts: a `0x11191e` emissive on a metallic
|
||||
// clearcoat under ACES reads as a car lit from inside.
|
||||
const materials = createModelXMaterials(0x223344);
|
||||
for (const key of ["paint", "wheel"] as const) {
|
||||
const material = materials[key] as THREE.MeshStandardMaterial;
|
||||
// The colour is what has to be black, not the intensity: three defaults
|
||||
// `emissiveIntensity` to 1 whether you asked for emission or not, so a
|
||||
// material with a black `emissive` and an intensity of 1 emits nothing and
|
||||
// is the correct state to assert.
|
||||
assert.equal(material.emissive.getHex(), 0, `${key} still has an emissive colour`);
|
||||
}
|
||||
// The lamps keep theirs, and must.
|
||||
for (const key of ["headlight", "tailLight"] as const) {
|
||||
const material = materials[key] as THREE.MeshStandardMaterial;
|
||||
assert.notEqual(material.emissive.getHex(), 0, `${key} stopped being a lamp`);
|
||||
assert.ok(material.emissiveIntensity > 1, `${key} stopped being a lamp`);
|
||||
}
|
||||
for (const material of Object.values(materials)) material.dispose();
|
||||
});
|
||||
|
||||
it("shares everything but the paint across a colour pool", () => {
|
||||
const pool = createModelXPaintPool();
|
||||
assert.equal(pool.length, MODEL_X_PAINTS.length);
|
||||
const first = pool[0];
|
||||
const second = pool[1];
|
||||
assert.ok(first && second);
|
||||
assert.notEqual(first.paint, second.paint, "two skins share one paint material");
|
||||
// Everything else is the same object, which is the entire point: a street of
|
||||
// seven colours costs six extra materials, not forty-two.
|
||||
for (const key of ["glass", "trim", "tire", "wheel", "brake", "mirror", "plate"] as const) {
|
||||
assert.equal(first[key], second[key], `${key} was duplicated across the pool`);
|
||||
}
|
||||
// And a rig handed a pooled skin must not claim to own it.
|
||||
const rig = buildModelX({ detail: "corridor", materials: second });
|
||||
assert.equal(rig.ownsMaterials, false);
|
||||
disposeModelX(rig);
|
||||
disposeModelXPaintPool(pool);
|
||||
});
|
||||
|
||||
it("clones without duplicating a single buffer", () => {
|
||||
const original = buildModelX({ detail: "follow" });
|
||||
const clone = cloneModelX(original);
|
||||
const source = meshes(original.root);
|
||||
const copied = meshes(clone.root);
|
||||
assert.equal(copied.length, source.length);
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
assert.equal(copied[i]!.geometry, source[i]!.geometry, `${source[i]!.name} was copied`);
|
||||
assert.equal(copied[i]!.material, source[i]!.material);
|
||||
}
|
||||
disposeModelX(original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Screens and foliage: the two places where a *texture binding* is the whole
|
||||
* fix, and where a silently-null map looks exactly like a design decision.
|
||||
*
|
||||
* Both defects on the live site had the same shape. Every display in the
|
||||
* building was one uniform glowing rectangle, and every leaf was a flat green
|
||||
* shard, and in both cases the geometry was fine and the material was carrying
|
||||
* nothing. The maps now exist (`screenUI`, `leafAlpha`); what these tests pin is
|
||||
* that they are bound to the right *slots*, because the difference between
|
||||
* `map` and `emissiveMap` is the difference between a monitor and a light box,
|
||||
* and the difference between `alphaMap` with `alphaTest` and no `alphaMap` at
|
||||
* all is the difference between a leaf and the shard.
|
||||
*
|
||||
* A stub canvas is installed for these: `TextureBin` returns `null` under Node
|
||||
* because there is nothing to draw on, which is correct behaviour and would make
|
||||
* every assertion in here vacuously true.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createAssetContext, kit } from "../../assets/kit.ts";
|
||||
import { MaterialRegistry } from "../../assets/materials.ts";
|
||||
import { SCREEN_UI_VARIANTS } from "../../assets/textures.ts";
|
||||
import "../../assets/office/index.ts";
|
||||
import { seeded, withStubCanvas } from "./fakeCanvas.ts";
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function materialsOf(root: THREE.Object3D): Map<string, THREE.MeshStandardMaterial> {
|
||||
const found = new Map<string, THREE.MeshStandardMaterial>();
|
||||
for (const mesh of meshes(root)) {
|
||||
const material = mesh.material as THREE.MeshStandardMaterial;
|
||||
found.set(material.name, material);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function disposeObject(root: THREE.Object3D): void {
|
||||
for (const mesh of meshes(root)) mesh.geometry.dispose();
|
||||
}
|
||||
|
||||
describe("screen content and leaf cutouts", () => {
|
||||
it("lights the display through its own drawing rather than flooding the panel", () => {
|
||||
const restore = withStubCanvas();
|
||||
const materials = new MaterialRegistry({ quality: "high" });
|
||||
try {
|
||||
for (const id of ["tera:screen.monitor", "tera:screen.wall-display"]) {
|
||||
const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
|
||||
const byName = materialsOf(object);
|
||||
|
||||
const content = byName.get("screenContent");
|
||||
assert.ok(content, `${id} draws no lit content layer`);
|
||||
assert.ok(content.map, `${id}: the display carries no map`);
|
||||
assert.ok(content.emissiveMap, `${id}: the display carries no emissiveMap`);
|
||||
// Same texture in both slots. Two different ones would mean the lit
|
||||
// pixels and the drawn pixels disagree, which reads as a ghost image.
|
||||
assert.equal(content.map, content.emissiveMap);
|
||||
// White emissive, because `emissive` multiplies `emissiveMap`: anything
|
||||
// else tints the drawn interface a second time on top of `color`.
|
||||
assert.equal(content.emissive.getHex(), 0xffffff);
|
||||
assert.ok(content.emissiveIntensity > 0);
|
||||
|
||||
// And the dark panel behind it has to still be there and still be dark:
|
||||
// it is the black border of glass, and it is what stops the content
|
||||
// running to the edge of the bezel.
|
||||
const dark = byName.get("screenDisplay");
|
||||
assert.ok(dark, `${id} lost its dark panel`);
|
||||
assert.ok(byName.get("screenBezel"), `${id} lost its bezel`);
|
||||
assert.notEqual(dark, content);
|
||||
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("takes its layout from the authored colorKey, not from per-instance chance", () => {
|
||||
// `furnish.ts` draws `ctx.rand` once per *kind*, so a screen cannot roll for
|
||||
// a layout: twelve monitors in one batch would roll once between them. The
|
||||
// seam is `colorKey`, which is already part of the batch key — so two packs
|
||||
// asking for the same key must get the same layout, and different keys must
|
||||
// be able to get different ones.
|
||||
const restore = withStubCanvas();
|
||||
const materials = new MaterialRegistry({ quality: "high" });
|
||||
try {
|
||||
const layoutFor = (colorKey: string | undefined): THREE.Texture | null => {
|
||||
const object = kit.build(
|
||||
"tera:screen.monitor",
|
||||
createAssetContext({ materials, rand: seeded(), colorKey }),
|
||||
);
|
||||
// `variant()` names a non-zero layout `screenContent:<hex>:<n>`, so the
|
||||
// lookup is by prefix: the point of the seam is that a keyed screen gets
|
||||
// a *different material*, and asserting on the base name would only ever
|
||||
// find the unkeyed one.
|
||||
const content = [...materialsOf(object)].find(([name]) =>
|
||||
name.startsWith("screenContent"),
|
||||
)?.[1];
|
||||
assert.ok(content, "no lit content layer on a keyed screen");
|
||||
const map = content.map;
|
||||
disposeObject(object);
|
||||
return map;
|
||||
};
|
||||
|
||||
const plain = layoutFor(undefined);
|
||||
assert.equal(layoutFor(undefined), plain, "an unkeyed screen is not stable");
|
||||
|
||||
const keyed = new Set<THREE.Texture | null>();
|
||||
for (const key of ["ui-a", "ui-b", "ui-c", "ui-d", "ui-e", "ui-f", "ui-g", "ui-h"]) {
|
||||
const first = layoutFor(key);
|
||||
assert.equal(layoutFor(key), first, `"${key}" is not stable between builds`);
|
||||
keyed.add(first);
|
||||
}
|
||||
assert.ok(
|
||||
keyed.size >= 3,
|
||||
`eight distinct keys produced ${keyed.size} layouts out of ${SCREEN_UI_VARIANTS} — the seam is not reaching the texture`,
|
||||
);
|
||||
} finally {
|
||||
materials.dispose();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a display readable at `low` quality, where there are no maps at all", () => {
|
||||
// `low` is the setting that makes an office open on an integrated GPU and it
|
||||
// draws no textures. The asset must still build, and the display must still
|
||||
// be a lit surface rather than an untextured white slab that clips.
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
const object = kit.build(
|
||||
"tera:screen.monitor",
|
||||
createAssetContext({ materials, rand: seeded() }),
|
||||
);
|
||||
const content = materialsOf(object).get("screenContent");
|
||||
assert.ok(content, "no content layer at low quality");
|
||||
assert.equal(content.map, null);
|
||||
assert.ok(content.emissiveIntensity > 0, "the panel stopped emitting at low quality");
|
||||
disposeObject(object);
|
||||
} finally {
|
||||
materials.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("cuts every leaf out of its quad instead of drawing the quad", () => {
|
||||
const restore = withStubCanvas();
|
||||
const materials = new MaterialRegistry({ quality: "high" });
|
||||
try {
|
||||
for (const id of ["tera:plant.potted", "tera:plant.tall"]) {
|
||||
const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
|
||||
const leaf = materialsOf(object).get("foliage");
|
||||
assert.ok(leaf, `${id} has no foliage material`);
|
||||
assert.ok(leaf.alphaMap, `${id}: the leaves are still bare rectangles`);
|
||||
assert.equal(leaf.alphaTest, 0.5, `${id}: the cutout threshold is not the drawn one`);
|
||||
// Cutout, not blend. A transparent leaf loses its depth write, its sort
|
||||
// order and — the one that shows — its leaf-shaped shadow.
|
||||
assert.equal(leaf.transparent, false, `${id}: foliage went transparent`);
|
||||
assert.equal(leaf.depthWrite, true);
|
||||
assert.equal(leaf.side, THREE.DoubleSide);
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
materials.dispose();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("survives `low` quality with the cutout intact, because a plant has no cheap fallback", () => {
|
||||
// "No maps at low quality" is a statement about *shading* cost. An alpha
|
||||
// cutout is one fetch and a discard, and the alternative at low quality is
|
||||
// not a cheaper plant — it is the shard the whole change exists to remove.
|
||||
const restore = withStubCanvas();
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
try {
|
||||
const object = kit.build(
|
||||
"tera:plant.tall",
|
||||
createAssetContext({ materials, rand: seeded() }),
|
||||
);
|
||||
const leaf = materialsOf(object).get("foliage");
|
||||
assert.ok(leaf?.alphaMap, "the leaf cutout was dropped at low quality");
|
||||
disposeObject(object);
|
||||
} finally {
|
||||
materials.dispose();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("runs +V along the leaf, so the cutout lands the right way up", () => {
|
||||
// `leafAlpha` is drawn tip-at-top with the stem at the bottom. If a leaf quad
|
||||
// ever loses its 0..1 V range — or gets it reversed — the cutout arrives
|
||||
// upside down and every plant grows stems out of its tips, which is a
|
||||
// failure nobody would think to look for in a UV.
|
||||
const restore = withStubCanvas();
|
||||
const materials = new MaterialRegistry({ quality: "high" });
|
||||
try {
|
||||
const object = kit.build(
|
||||
"tera:plant.potted",
|
||||
createAssetContext({ materials, rand: seeded() }),
|
||||
);
|
||||
const leaf = meshes(object).find(
|
||||
(mesh) => (mesh.material as THREE.Material).name === "foliage",
|
||||
);
|
||||
assert.ok(leaf);
|
||||
const uv = leaf.geometry.getAttribute("uv");
|
||||
assert.ok(uv, "the leaf cards carry no UVs to sample the cutout with");
|
||||
let minV = Infinity;
|
||||
let maxV = -Infinity;
|
||||
for (let i = 0; i < uv.count; i++) {
|
||||
minV = Math.min(minV, uv.getY(i));
|
||||
maxV = Math.max(maxV, uv.getY(i));
|
||||
}
|
||||
assert.ok(Math.abs(minV) < 1e-6, `leaf V starts at ${minV}`);
|
||||
assert.ok(Math.abs(maxV - 1) < 1e-6, `leaf V ends at ${maxV}`);
|
||||
disposeObject(object);
|
||||
} finally {
|
||||
materials.dispose();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* The studio kit: twelve new kinds, and the properties that make them useful to
|
||||
* a pack rather than merely present in a registry.
|
||||
*
|
||||
* The interesting assertion in here is the *count*. `furnish.ts` batches props
|
||||
* per kind and every instance of a kind is geometrically identical, so a pack
|
||||
* cannot buy apparent density by placing more props — only by using more kinds.
|
||||
* "At least eight new kinds" is therefore a real acceptance number and not a
|
||||
* round one, and pinning it stops a later tidy-up quietly merging two assets
|
||||
* into one parameterised asset and taking a visible amount of the LA studio's
|
||||
* variety with it.
|
||||
*
|
||||
* Everything else here is the same three failure modes every asset file has:
|
||||
* a builder that throws at one quality level and not the other, a footprint that
|
||||
* does not enclose its own mesh, and a material that `mergeGeometries` refused
|
||||
* because somebody mixed an extrusion in with the boxes.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createAssetContext, kit, type AssetContext } from "../../assets/kit.ts";
|
||||
import { MaterialRegistry, type MaterialQuality } from "../../assets/materials.ts";
|
||||
import { OFFICE_ASSETS, STUDIO_ASSET_IDS } from "../../assets/office/index.ts";
|
||||
import { seeded, withStubCanvas } from "./fakeCanvas.ts";
|
||||
|
||||
/** The ids that existed before the studio kit landed, from the same catalogue. */
|
||||
const PRE_STUDIO_IDS = new Set(
|
||||
OFFICE_ASSETS.map((def) => def.id).filter((id) => !STUDIO_ASSET_IDS.includes(id)),
|
||||
);
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function disposeObject(root: THREE.Object3D): void {
|
||||
for (const mesh of meshes(root)) mesh.geometry.dispose();
|
||||
}
|
||||
|
||||
function context(quality: MaterialQuality): { ctx: AssetContext; done: () => void } {
|
||||
const restore = withStubCanvas();
|
||||
const materials = new MaterialRegistry({ quality });
|
||||
return {
|
||||
ctx: createAssetContext({ materials, rand: seeded() }),
|
||||
done: () => {
|
||||
materials.dispose();
|
||||
restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("studio asset kit", () => {
|
||||
it("adds at least eight kinds that did not exist before, because only kinds add density", () => {
|
||||
assert.ok(
|
||||
STUDIO_ASSET_IDS.length >= 8,
|
||||
`only ${STUDIO_ASSET_IDS.length} studio kinds; furnish.ts batches per kind, so fewer than eight is not a visible change`,
|
||||
);
|
||||
for (const id of STUDIO_ASSET_IDS) {
|
||||
assert.equal(kit.has(id), true, `${id} is not registered`);
|
||||
assert.equal(PRE_STUDIO_IDS.has(id), false, `${id} collides with an existing asset id`);
|
||||
assert.match(id, /^tera:[a-z-]+\.[a-z-]+$/, `${id} is not a namespaced asset id`);
|
||||
}
|
||||
assert.equal(new Set(STUDIO_ASSET_IDS).size, STUDIO_ASSET_IDS.length, "duplicate studio id");
|
||||
});
|
||||
|
||||
for (const quality of ["low", "high"] as const) {
|
||||
it(`builds every studio kind without throwing at \`${quality}\` quality`, () => {
|
||||
const { ctx, done } = context(quality);
|
||||
try {
|
||||
for (const id of STUDIO_ASSET_IDS) {
|
||||
const object = kit.build(id, ctx);
|
||||
assert.equal(object.userData.assetId, id);
|
||||
assert.notEqual(
|
||||
object.userData.missing,
|
||||
true,
|
||||
`${id} fell through to the placeholder box`,
|
||||
);
|
||||
const built = meshes(object);
|
||||
assert.ok(built.length > 0, `${id} built no geometry`);
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
assert.ok(
|
||||
[...box.min.toArray(), ...box.max.toArray()].every(Number.isFinite),
|
||||
`${id} has non-finite bounds`,
|
||||
);
|
||||
assert.ok(box.max.y > 0.02, `${id} has no visible height`);
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("produces identical geometry at every quality level", () => {
|
||||
// Quality changes *materials*, never meshes. If a builder ever branches on
|
||||
// `ctx.quality` the office stops being the same office on a weak GPU, and
|
||||
// the collision segments a pack derives from a footprint stop matching what
|
||||
// is drawn.
|
||||
const counts: Record<string, number[]> = {};
|
||||
for (const quality of ["low", "high"] as const) {
|
||||
const { ctx, done } = context(quality);
|
||||
try {
|
||||
for (const id of STUDIO_ASSET_IDS) {
|
||||
const object = kit.build(id, ctx);
|
||||
let triangles = 0;
|
||||
for (const mesh of meshes(object)) {
|
||||
const index = mesh.geometry.getIndex();
|
||||
triangles += index
|
||||
? index.count / 3
|
||||
: mesh.geometry.getAttribute("position").count / 3;
|
||||
}
|
||||
(counts[id] ??= []).push(triangles);
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
}
|
||||
for (const [id, [low, high]] of Object.entries(counts)) {
|
||||
assert.equal(low, high, `${id} builds different geometry at low and high quality`);
|
||||
}
|
||||
});
|
||||
|
||||
it("encloses its own geometry in the footprint layout is given", () => {
|
||||
const { ctx, done } = context("low");
|
||||
try {
|
||||
for (const id of STUDIO_ASSET_IDS) {
|
||||
const object = kit.build(id, ctx);
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const footprint = kit.footprintOf(id);
|
||||
// 20 mm of slack. A footprint is a layout number rather than a
|
||||
// measurement, but `Plan` spaces rooms with it and a prop that overhangs
|
||||
// its own footprint by more than a finger's width ends up in a wall.
|
||||
assert.ok(size.x <= footprint.width + 0.02, `${id} is ${size.x} wide vs ${footprint.width}`);
|
||||
assert.ok(size.z <= footprint.depth + 0.02, `${id} is ${size.z} deep vs ${footprint.depth}`);
|
||||
assert.ok(
|
||||
box.max.y <= footprint.height + 0.02,
|
||||
`${id} reaches ${box.max.y} vs ${footprint.height}`,
|
||||
);
|
||||
assert.ok(box.min.y >= -0.02, `${id} starts below the floor at ${box.min.y}`);
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the softbox recognisable to furnish.ts as a light fitting", () => {
|
||||
// `furnish.ts` decides what a luminaire is from the `:light.` in its id and
|
||||
// separates the glowing part from the housing by `emissiveIntensity > 0`.
|
||||
// Both halves have to be true or a softbox is a lump of metal that never
|
||||
// comes on, and neither is visible from inside this file.
|
||||
assert.ok(STUDIO_ASSET_IDS.includes("tera:light.softbox"));
|
||||
assert.equal(kit.resolveId("tera:light.softbox").includes(":light."), true);
|
||||
|
||||
const { ctx, done } = context("high");
|
||||
try {
|
||||
const object = kit.build("tera:light.softbox", ctx);
|
||||
const emissive = meshes(object).filter((mesh) => {
|
||||
const material = mesh.material as THREE.MeshStandardMaterial;
|
||||
return (material.emissiveIntensity ?? 0) > 0;
|
||||
});
|
||||
assert.equal(emissive.length, 1, "a softbox has exactly one diffuser");
|
||||
assert.equal(emissive[0]?.castShadow, false, "the thing the light comes out of must not shadow the room");
|
||||
disposeObject(object);
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not add a light source of its own — Atmosphere owns every light", () => {
|
||||
// CONTRACT.md §4. A hundred fittings each carrying a PointLight is both the
|
||||
// wrong owner and the end of the frame budget, and the softbox is exactly
|
||||
// the asset somebody would be tempted to give a real light to.
|
||||
const { ctx, done } = context("high");
|
||||
try {
|
||||
for (const id of STUDIO_ASSET_IDS) {
|
||||
const object = kit.build(id, ctx);
|
||||
object.traverse((child) => {
|
||||
assert.equal(
|
||||
child instanceof THREE.Light,
|
||||
false,
|
||||
`${id} constructs a light, which only Atmosphere may do`,
|
||||
);
|
||||
});
|
||||
disposeObject(object);
|
||||
}
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
it("draws its foliage through the same cutout the greenery assets use", () => {
|
||||
// The trough is the one studio asset with leaves on it, and it exists partly
|
||||
// to keep the courtyard from being paving. If it ever stops sharing
|
||||
// `leafBlade`/`foliage` it goes straight back to the green-shard look that
|
||||
// this whole pass was written to remove.
|
||||
const { ctx, done } = context("high");
|
||||
try {
|
||||
const object = kit.build("tera:planter.trough", ctx);
|
||||
const foliage = meshes(object).find(
|
||||
(mesh) => (mesh.material as THREE.Material).name === "foliage",
|
||||
);
|
||||
assert.ok(foliage, "the planted trough has no foliage on it");
|
||||
const material = foliage.material as THREE.MeshStandardMaterial;
|
||||
assert.ok(material.alphaMap, "the trough's leaves are not cut out");
|
||||
assert.equal(material.alphaTest, 0.5);
|
||||
disposeObject(object);
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
# Test home for the `data` workstream.
|
||||
#
|
||||
# Each build workstream owns its own subdirectory so eight builders can add
|
||||
# suites in parallel without ever colliding on a path. `npm test` picks these
|
||||
# up through the widened `src/test/**/*.test.ts` glob in package.json.
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* The adapter that was never once imported by a test.
|
||||
*
|
||||
* `src/adapters/http.ts` is the module whose entire job is to be correct when
|
||||
* everything else has failed — no server, a 404, a static host answering with
|
||||
* its own index.html, a body about another city, a feed that has gone away
|
||||
* mid-session — and until this file it had **zero coverage**, because a pair of
|
||||
* TypeScript parameter properties on `HttpFlights` made Node's type stripping
|
||||
* refuse the whole module with `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`.
|
||||
*
|
||||
* `engine/flights.ts` already records what that costs: the module with the
|
||||
* worst bug this project has shipped was, by construction, the one module that
|
||||
* could not be tested. So the first assertion here is that the module imports
|
||||
* at all, and the rest are the degrade paths that were being taken on trust:
|
||||
*
|
||||
* - every feed falls back rather than failing, and **says** it fell back;
|
||||
* - a body about somewhere else is refused, which is the San Francisco fog
|
||||
* over Long Beach failure the whole location parameter exists to prevent;
|
||||
* - the back-off ladder is climbed rather than retried at the frame rate,
|
||||
* which is the specific bug a malformed `ttlSeconds` used to cause;
|
||||
* - a device command travels in a POST of its own and never in a read.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { createTeraClient, describeLiveness } from "../../adapters/http.ts";
|
||||
import { SAMPLE_MARKERS } from "../../adapters/sample.ts";
|
||||
import type { SkyRegion } from "../../engine/flights.ts";
|
||||
import type { DevicesBody, FlightsBody, WeatherBody } from "../../server/wire.ts";
|
||||
|
||||
/** The Bay Area, roughly, and big enough that the fixtures below are inside it. */
|
||||
const SF: SkyRegion = { center: { lat: 37.77, lng: -122.42 }, radiusNm: 60 };
|
||||
|
||||
/** Long Beach: five hundred and ninety kilometres away, and the whole point. */
|
||||
const ELSEWHERE = { lat: 33.77, lng: -118.19 };
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
method: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fetch that answers from a table, records what it was asked, and can be told
|
||||
* to fail.
|
||||
*
|
||||
* Deliberately not a mock of `Response` — `new Response` is in Node and the
|
||||
* adapter reads `ok`, `headers.get("content-type")` and `json()`, all of which
|
||||
* a real one does correctly. A hand-rolled stub would be a second opinion about
|
||||
* what a `Response` is.
|
||||
*/
|
||||
function stubFetch(routes: Record<string, unknown | (() => unknown)>) {
|
||||
const calls: Call[] = [];
|
||||
const fetcher = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const path = url.split("?")[0] ?? url;
|
||||
calls.push({ url, method: init?.method ?? "GET", body: readBody(init) });
|
||||
const key = Object.keys(routes).find((candidate) => path.endsWith(candidate));
|
||||
const answer = key === undefined ? undefined : routes[key];
|
||||
const value = typeof answer === "function" ? (answer as () => unknown)() : answer;
|
||||
if (value === undefined) return new Response("no", { status: 404 });
|
||||
if (value === "html") {
|
||||
// A static host serving the SPA shell for an unknown path: a 200, with
|
||||
// HTML in it. The content-type check is the only thing between that and
|
||||
// `res.json()` throwing somewhere further in.
|
||||
return new Response("<!doctype html>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
return { fetcher, calls };
|
||||
}
|
||||
|
||||
function readBody(init?: RequestInit): unknown {
|
||||
if (typeof init?.body !== "string") return null;
|
||||
try {
|
||||
return JSON.parse(init.body) as unknown;
|
||||
} catch {
|
||||
return init.body;
|
||||
}
|
||||
}
|
||||
|
||||
/** Let everything in flight settle. Two turns, because a `then` chains a `finally`. */
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe("the module imports at all", () => {
|
||||
it("has no TypeScript parameter properties left in it", async () => {
|
||||
// The assertion is the import itself: a parameter property anywhere in this
|
||||
// file makes the next line throw ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX and
|
||||
// takes every other test in this file with it.
|
||||
const module = await import("../../adapters/http.ts");
|
||||
assert.equal(typeof module.createTeraClient, "function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("markers degrade to the bundled set", () => {
|
||||
it("serves the sample set, and says so, when there is no API", async () => {
|
||||
const { fetcher } = stubFetch({});
|
||||
const feed = await createTeraClient({ fetch: fetcher }).markers();
|
||||
assert.equal(feed.live, false);
|
||||
assert.equal(feed.value, SAMPLE_MARKERS);
|
||||
assert.equal(feed.generatedAt, null);
|
||||
});
|
||||
|
||||
it("serves the sample set when a static host answers with its own HTML", async () => {
|
||||
const { fetcher } = stubFetch({ "/markers": "html" });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).markers();
|
||||
assert.equal(feed.live, false);
|
||||
});
|
||||
|
||||
it("falls back rather than showing an empty map wearing a live badge", async () => {
|
||||
const { fetcher } = stubFetch({ "/markers": { markers: [], generatedAt: "2026-01-01" } });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).markers();
|
||||
assert.equal(feed.live, false);
|
||||
assert.equal(feed.value, SAMPLE_MARKERS);
|
||||
});
|
||||
|
||||
it("passes a real feed through with the caller's palette", async () => {
|
||||
const markers = [{ id: "m1", name: "One", lat: 37.77, lng: -122.42, colorKey: "a" }];
|
||||
const { fetcher } = stubFetch({
|
||||
"/markers": { markers, generatedAt: "2026-08-01", refused: [{ reason: "provenance", count: 2 }] },
|
||||
});
|
||||
const feed = await createTeraClient({ fetch: fetcher, palette: { a: 0x00ff00 } }).markers();
|
||||
assert.equal(feed.live, true);
|
||||
assert.equal(feed.value.length, 1);
|
||||
assert.deepEqual(feed.palette, { a: 0x00ff00 });
|
||||
// Passed through rather than swallowed: a gate that drops rows silently is
|
||||
// indistinguishable from an empty database.
|
||||
assert.deepEqual(feed.refused, [{ reason: "provenance", count: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the weather is refused unless it is this city's", () => {
|
||||
const observation = (over: { lat: number; lng: number }, extra: Partial<WeatherBody> = {}) => ({
|
||||
observedAt: new Date().toISOString(),
|
||||
source: "nws",
|
||||
synthetic: false,
|
||||
location: over,
|
||||
temperatureC: 14,
|
||||
windKph: 10,
|
||||
windDirDeg: 270,
|
||||
cloudCover: 0.8,
|
||||
precipitation: 0,
|
||||
visibilityKm: 12,
|
||||
condition: "cloudy",
|
||||
...extra,
|
||||
});
|
||||
|
||||
it("adopts an observation of the place that was asked about", async () => {
|
||||
const { fetcher, calls } = stubFetch({ "/weather": observation(SF.center) });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).weather(SF.center);
|
||||
assert.equal(feed.live, true);
|
||||
assert.equal(feed.value?.cloudCover, 0.8);
|
||||
// Rounded to two places for the shared cache: two viewers of one city have
|
||||
// to produce byte-identical URLs or the cache is a per-viewer cache.
|
||||
assert.ok(calls[0]?.url.includes("lat=37.77"));
|
||||
});
|
||||
|
||||
it("refuses an observation of somewhere else, however good it is", async () => {
|
||||
const { fetcher } = stubFetch({ "/weather": observation(ELSEWHERE) });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).weather(SF.center);
|
||||
// Nobody-was-asked, so the local climatology runs. Rendering a real
|
||||
// observation of a city the viewer is not looking at is worse than
|
||||
// rendering none: it is wrong and it is convincing.
|
||||
assert.equal(feed.live, false);
|
||||
assert.equal(feed.value, null);
|
||||
});
|
||||
|
||||
it("refuses a body the server admits it invented", async () => {
|
||||
const { fetcher } = stubFetch({ "/weather": observation(SF.center, { synthetic: true }) });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).weather(SF.center);
|
||||
assert.equal(feed.live, false);
|
||||
assert.equal(feed.value, null);
|
||||
});
|
||||
|
||||
it("refuses a body it cannot place", async () => {
|
||||
const { fetcher } = stubFetch({
|
||||
"/weather": { ...observation(SF.center), location: undefined },
|
||||
});
|
||||
const feed = await createTeraClient({ fetch: fetcher }).weather(SF.center);
|
||||
assert.equal(feed.live, false);
|
||||
});
|
||||
|
||||
it("is nobody-was-asked rather than a clear day when nothing answers", async () => {
|
||||
const { fetcher } = stubFetch({});
|
||||
const feed = await createTeraClient({ fetch: fetcher }).weather(SF.center);
|
||||
// The distinction this whole fallback exists for: a reported clear sky is
|
||||
// authoritative in `atmosphere.ts` and would delete San Francisco's marine
|
||||
// layer permanently on a zero-config box.
|
||||
assert.equal(feed.value, null);
|
||||
assert.equal(feed.observedAt, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("traffic", () => {
|
||||
const live = (aircraft: unknown[], extra: Partial<FlightsBody> = {}) => ({
|
||||
mode: "live",
|
||||
source: "adsb",
|
||||
observedAt: Date.now(),
|
||||
aircraft,
|
||||
ttlSeconds: 5,
|
||||
redistributable: true,
|
||||
attribution: ["Data from adsb.lol"],
|
||||
...extra,
|
||||
});
|
||||
|
||||
const overSf = {
|
||||
id: "a1b2c3",
|
||||
icao24: "a1b2c3",
|
||||
callsign: "UAL221",
|
||||
lat: 37.8,
|
||||
lng: -122.4,
|
||||
altitude: 3048,
|
||||
heading: 95,
|
||||
};
|
||||
|
||||
it("flies the simulator until an answer lands, and does not claim it is live", async () => {
|
||||
const { fetcher } = stubFetch({});
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
const first = source.poll();
|
||||
assert.ok(first.length > 0);
|
||||
assert.equal(source.live(), false);
|
||||
// Nobody is credited for this repo's own arithmetic.
|
||||
assert.deepEqual(source.attribution(), []);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("adopts real positions inside the region and credits the feed", async () => {
|
||||
const { fetcher } = stubFetch({ "/flights": live([overSf]) });
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
const aircraft = source.poll();
|
||||
assert.equal(source.live(), true);
|
||||
assert.deepEqual(aircraft.map((a) => a.id), ["a1b2c3"]);
|
||||
assert.deepEqual(source.attribution(), ["Data from adsb.lol"]);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("refuses a feed pointed at another city rather than drawing an empty board", async () => {
|
||||
const elsewhere = { ...overSf, lat: ELSEWHERE.lat, lng: ELSEWHERE.lng };
|
||||
const { fetcher } = stubFetch({ "/flights": live([elsewhere]) });
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
// Fifty aircraft, none within a hundred miles of the board, is a server
|
||||
// pointed at another city — and there the simulator is the honest picture.
|
||||
assert.equal(source.live(), false);
|
||||
assert.ok(source.poll().length > 0);
|
||||
assert.deepEqual(source.attribution(), []);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("keeps an empty sky when the feed really is empty", async () => {
|
||||
const { fetcher } = stubFetch({ "/flights": live([]) });
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
// Three in the morning over a small city is an empty sky and is live. Only
|
||||
// an empty *filter result over a non-empty body* means somewhere else.
|
||||
assert.equal(source.live(), true);
|
||||
assert.deepEqual(source.poll(), []);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("evaluates a plan locally and keeps only the legs that are here", async () => {
|
||||
const plan = {
|
||||
mode: "plan",
|
||||
source: "sim",
|
||||
t0: Date.now() - 60_000,
|
||||
seed: 4711,
|
||||
ttlSeconds: 300,
|
||||
routes: [
|
||||
{ callsign: "SFO1", from: [37.6, -122.4], to: [37.9, -122.3], fromAlt: 0, toAlt: 3000, duration: 600 },
|
||||
{ callsign: "LAX1", from: [33.9, -118.4], to: [33.7, -118.1], fromAlt: 0, toAlt: 3000, duration: 600 },
|
||||
],
|
||||
};
|
||||
const { fetcher, calls } = stubFetch({ "/flights": plan });
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
const aircraft = source.poll();
|
||||
assert.deepEqual(aircraft.map((a) => a.callsign), ["SFO1"]);
|
||||
// A plan is not live traffic and does not say it is, even though every
|
||||
// viewer agrees about where its aircraft are.
|
||||
assert.equal(source.live(), false);
|
||||
// One request, not one per poll: a plan is arithmetic.
|
||||
source.poll();
|
||||
source.poll();
|
||||
assert.equal(calls.length, 1);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("does not poll at the frame rate when a body forgets its ttl", async () => {
|
||||
// `Math.max(1, undefined)` is `NaN`, `now < NaN` is false forever, and the
|
||||
// poll interval quietly became the frame rate. This is that bug's test.
|
||||
const { fetcher, calls } = stubFetch({ "/flights": live([overSf], { ttlSeconds: undefined }) });
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
for (let i = 0; i < 30; i += 1) source.poll();
|
||||
await settle();
|
||||
assert.equal(calls.length, 1);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("survives a body it cannot read without starting a request per frame", async () => {
|
||||
const { fetcher, calls } = stubFetch({ "/flights": { mode: "live", source: "adsb", ttlSeconds: 5 } });
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
for (let i = 0; i < 30; i += 1) source.poll();
|
||||
await settle();
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(source.live(), false);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("stops fetching once disposed", async () => {
|
||||
const { fetcher, calls } = stubFetch({ "/flights": live([overSf]) });
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
source.dispose();
|
||||
const seen = calls.length;
|
||||
for (let i = 0; i < 10; i += 1) source.poll();
|
||||
await settle();
|
||||
assert.equal(calls.length, seen);
|
||||
});
|
||||
});
|
||||
|
||||
describe("one aircraft, as a card an anonymous visitor can open", () => {
|
||||
const overSf = {
|
||||
id: "a1b2c3",
|
||||
icao24: "a1b2c3",
|
||||
callsign: " UAL221 ",
|
||||
lat: 37.8,
|
||||
lng: -122.4,
|
||||
altitude: 3048,
|
||||
heading: 95,
|
||||
};
|
||||
|
||||
it("carries callsign, address, altitude, heading and position", async () => {
|
||||
const { fetcher } = stubFetch({
|
||||
"/flights": {
|
||||
mode: "live",
|
||||
source: "adsb",
|
||||
observedAt: Date.now(),
|
||||
aircraft: [overSf],
|
||||
ttlSeconds: 5,
|
||||
redistributable: true,
|
||||
attribution: ["Data from adsb.lol"],
|
||||
},
|
||||
});
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
await settle();
|
||||
source.poll();
|
||||
|
||||
const detail = source.detail("a1b2c3");
|
||||
assert.ok(detail !== null);
|
||||
assert.equal(detail.callsign, "UAL221");
|
||||
assert.equal(detail.icao24, "a1b2c3");
|
||||
assert.equal(detail.altitudeM, 3048);
|
||||
assert.equal(detail.altitudeFt, 10_000);
|
||||
assert.equal(detail.headingDeg, 95);
|
||||
assert.equal(detail.headingCompass, "E");
|
||||
assert.equal(detail.lat, 37.8);
|
||||
assert.equal(detail.observed, true);
|
||||
// The credit travels with the card, because a card is where the data is
|
||||
// displayed and that is what an ODbL notice is about.
|
||||
assert.deepEqual(detail.attribution, ["Data from adsb.lol"]);
|
||||
assert.ok(detail.distanceNm !== null && detail.distanceNm < 10);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("says nothing about an aircraft that has left the feed", async () => {
|
||||
const { fetcher } = stubFetch({});
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
source.poll();
|
||||
assert.equal(source.detail("a1b2c3"), null);
|
||||
source.dispose();
|
||||
});
|
||||
|
||||
it("never presents a simulated aircraft as observed, or its id as an address", async () => {
|
||||
const { fetcher } = stubFetch({});
|
||||
const source = createTeraClient({ fetch: fetcher }).flights(SF);
|
||||
const first = source.poll()[0];
|
||||
assert.ok(first !== undefined);
|
||||
const detail = source.detail(first.id);
|
||||
assert.ok(detail !== null);
|
||||
assert.equal(detail.observed, false);
|
||||
// `sim-BA286` is a route name, not a transponder address, and somebody
|
||||
// pastes this field into a registry lookup.
|
||||
assert.equal(detail.icao24, null);
|
||||
assert.deepEqual(detail.attribution, []);
|
||||
source.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the corner label only claims what is true", () => {
|
||||
it("says nothing when nothing is live", () => {
|
||||
assert.equal(describeLiveness({ markers: false, weather: false, flights: false }), "");
|
||||
});
|
||||
|
||||
it("names the parts rather than claiming the whole map", () => {
|
||||
assert.equal(describeLiveness({ markers: false, weather: true, flights: true }), "live weather + traffic");
|
||||
});
|
||||
|
||||
it("earns the unqualified claim only when all three are live", () => {
|
||||
assert.equal(describeLiveness({ markers: true, weather: true, flights: true }), "live data");
|
||||
});
|
||||
});
|
||||
|
||||
describe("devices", () => {
|
||||
const body: DevicesBody = {
|
||||
officeId: "hq",
|
||||
devices: [
|
||||
{
|
||||
id: "mic-1",
|
||||
kind: "mic",
|
||||
powered: true,
|
||||
muted: false,
|
||||
gainDb: 12,
|
||||
levelDb: -21.5,
|
||||
observedAt: 1,
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
observedAt: 1,
|
||||
source: "sim",
|
||||
synthetic: true,
|
||||
ttlSeconds: 5,
|
||||
};
|
||||
|
||||
it("is empty and not live when there is no API", async () => {
|
||||
const { fetcher } = stubFetch({});
|
||||
const feed = await createTeraClient({ fetch: fetcher }).devices("hq");
|
||||
assert.deepEqual(feed.value, []);
|
||||
assert.equal(feed.live, false);
|
||||
assert.equal(feed.source, "none");
|
||||
// Not a claim that anybody observed these zero readings.
|
||||
assert.equal(feed.synthetic, true);
|
||||
});
|
||||
|
||||
it("is empty and not live when the deployment refuses an anonymous read", async () => {
|
||||
const { fetcher } = stubFetch({ "/devices": undefined });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).devices("hq");
|
||||
assert.equal(feed.live, false);
|
||||
assert.deepEqual(feed.value, []);
|
||||
});
|
||||
|
||||
it("adopts a body and keeps its provenance", async () => {
|
||||
const { fetcher, calls } = stubFetch({ "/devices": body });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).devices("hq");
|
||||
assert.equal(feed.live, true);
|
||||
assert.equal(feed.synthetic, true);
|
||||
assert.equal(feed.source, "sim");
|
||||
assert.equal(feed.value[0]?.levelDb, -21.5);
|
||||
assert.equal(calls[0]?.url, "/api/v1/offices/hq/devices");
|
||||
});
|
||||
|
||||
it("treats an office id as an id and not as a path", async () => {
|
||||
const { fetcher, calls } = stubFetch({ "/devices": body });
|
||||
await createTeraClient({ fetch: fetcher }).devices("../secrets");
|
||||
assert.equal(calls[0]?.url, "/api/v1/offices/..%2Fsecrets/devices");
|
||||
});
|
||||
|
||||
it("is empty when the body is the wrong shape", async () => {
|
||||
const { fetcher } = stubFetch({ "/devices": { officeId: "hq", devices: "lots" } });
|
||||
const feed = await createTeraClient({ fetch: fetcher }).devices("hq");
|
||||
assert.equal(feed.live, false);
|
||||
});
|
||||
|
||||
it("sends a command as a POST of its own, never on the read", async () => {
|
||||
const { fetcher, calls } = stubFetch({
|
||||
"/devices/command": { officeId: "hq", device: { ...body.devices[0], powered: false }, observedAt: 2 },
|
||||
});
|
||||
const client = createTeraClient({ fetch: fetcher });
|
||||
const state = await client.commandDevice("hq", { deviceId: "mic-1", op: "power", value: false });
|
||||
|
||||
assert.equal(state?.powered, false);
|
||||
const call = calls[0];
|
||||
assert.equal(call?.method, "POST");
|
||||
assert.equal(call?.url, "/api/v1/offices/hq/devices/command");
|
||||
assert.deepEqual(call?.body, { command: { deviceId: "mic-1", op: "power", value: false } });
|
||||
// The read route was never touched. A command that could ride on a GET is a
|
||||
// command a shared cache can replay.
|
||||
assert.equal(calls.filter((c) => c.method === "GET").length, 0);
|
||||
});
|
||||
|
||||
it("reports a refused command rather than pretending it worked", async () => {
|
||||
const { fetcher } = stubFetch({});
|
||||
const client = createTeraClient({ fetch: fetcher });
|
||||
const state = await client.commandDevice("hq", { deviceId: "mic-1", op: "power", value: true });
|
||||
assert.equal(state, null);
|
||||
});
|
||||
|
||||
it("reports a 200 whose body is not a result", async () => {
|
||||
const { fetcher } = stubFetch({ "/devices/command": { officeId: "hq" } });
|
||||
const client = createTeraClient({ fetch: fetcher });
|
||||
const state = await client.commandDevice("hq", { deviceId: "mic-1", op: "power", value: true });
|
||||
assert.equal(state, null);
|
||||
});
|
||||
|
||||
it("publishes the first answer, then only when a reading changes", async () => {
|
||||
let served: DevicesBody = body;
|
||||
const { fetcher } = stubFetch({ "/devices": () => served });
|
||||
const feeds: number[] = [];
|
||||
const watch = createTeraClient({ fetch: fetcher }).watchDevices("hq", (feed) => {
|
||||
feeds.push(feed.value[0]?.levelDb ?? 0);
|
||||
});
|
||||
await settle();
|
||||
assert.equal(feeds.length, 1);
|
||||
|
||||
// The same reading again, with only `observedAt` moved: not news, and
|
||||
// publishing it would rebuild the panel on every poll forever.
|
||||
served = { ...body, observedAt: 99, devices: [{ ...body.devices[0]!, observedAt: 99 }] };
|
||||
watch.refresh();
|
||||
await settle();
|
||||
assert.equal(feeds.length, 1);
|
||||
|
||||
served = { ...body, devices: [{ ...body.devices[0]!, levelDb: -12.5 }] };
|
||||
watch.refresh();
|
||||
await settle();
|
||||
assert.deepEqual(feeds, [-21.5, -12.5]);
|
||||
watch.stop();
|
||||
});
|
||||
|
||||
it("holds the latest feed so a panel opening late does not wait for a poll", async () => {
|
||||
const { fetcher } = stubFetch({ "/devices": body });
|
||||
const watch = createTeraClient({ fetch: fetcher }).watchDevices("hq", () => {});
|
||||
await settle();
|
||||
assert.equal(watch.current().value[0]?.id, "mic-1");
|
||||
watch.stop();
|
||||
});
|
||||
|
||||
it("stops dead, and drops an answer that lands after it was stopped", async () => {
|
||||
const { fetcher, calls } = stubFetch({ "/devices": body });
|
||||
const feeds: unknown[] = [];
|
||||
const watch = createTeraClient({ fetch: fetcher }).watchDevices("hq", (feed) => feeds.push(feed));
|
||||
watch.stop();
|
||||
await settle();
|
||||
const seen = calls.length;
|
||||
watch.refresh();
|
||||
await settle();
|
||||
assert.equal(calls.length, seen);
|
||||
assert.equal(feeds.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* The device layer, held to the three rules it can break invisibly.
|
||||
*
|
||||
* **It constructs no light.** CONTRACT.md §4 gives Atmosphere sole ownership,
|
||||
* and an LED is the most tempting exception in the whole codebase because it is
|
||||
* *obviously* a light and a `PointLight` per device is one line. The grep in
|
||||
* the build spec catches the letter of it; this file catches the spirit, by
|
||||
* asserting nothing in the subtree is a `THREE.Light` at all.
|
||||
*
|
||||
* **It borrows materials and owns geometry.** Every mesh comes out of
|
||||
* `MeshBin`, which clones and merges, so the geometry belongs to the layer and
|
||||
* goes with it. The materials come from the shared `MaterialRegistry`, are
|
||||
* cached there across the whole office, and disposing one here would empty the
|
||||
* desks in the rest of the building — so `dispose()` must free the first and
|
||||
* must not touch the second. A test that only asserted "everything is
|
||||
* disposed" would be asserting the bug.
|
||||
*
|
||||
* **It drops what it cannot place.** A device anchored to a prop that is not in
|
||||
* the plan — a typo, or a private prop in a public build — has nowhere to
|
||||
* stand, and putting it at the origin would leave a microphone on the lobby
|
||||
* floor.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import { AssetRegistry, defineAsset } from "../../assets/kit.ts";
|
||||
import { MaterialRegistry } from "../../assets/materials.ts";
|
||||
import type { DeviceDeclaration, DeviceState } from "../../devices/types.ts";
|
||||
import { createDeviceLayer } from "../../interiors/devices.ts";
|
||||
import { Plan } from "../../interiors/plan.ts";
|
||||
import type { Office } from "../../interiors/types.ts";
|
||||
|
||||
const MIC = "tera:device.mic.desk";
|
||||
|
||||
/**
|
||||
* A stand-in for the real hardware.
|
||||
*
|
||||
* The layer's contract with an asset is exactly one sentence — expose a
|
||||
* sub-object named `indicator` — so the test asset is that sentence and nothing
|
||||
* else. Building against `src/assets/office/devices.ts` would make this a test
|
||||
* of somebody else's geometry, and it would fail for reasons that have nothing
|
||||
* to do with this layer.
|
||||
*/
|
||||
function registry(withIndicator = true): AssetRegistry {
|
||||
return new AssetRegistry().register(
|
||||
defineAsset({
|
||||
id: MIC,
|
||||
defaults: {},
|
||||
footprint: () => ({ width: 0.09, depth: 0.09, height: 0.3 }),
|
||||
build(_params, ctx) {
|
||||
const group = new THREE.Group();
|
||||
const body = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.2, 0.09), ctx.materials.get("deviceShell"));
|
||||
body.name = "body";
|
||||
group.add(body);
|
||||
if (withIndicator) {
|
||||
const led = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.004, 6, 4),
|
||||
ctx.materials.get("deviceIndicator"),
|
||||
);
|
||||
led.name = "indicator";
|
||||
led.position.set(0, 0.22, 0.03);
|
||||
group.add(led);
|
||||
}
|
||||
return group;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function office(declarations: readonly DeviceDeclaration[]): Office {
|
||||
return {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
viewpoints: [],
|
||||
levels: [
|
||||
{
|
||||
id: "l1",
|
||||
name: "Ground",
|
||||
elevation: 0,
|
||||
wallHeight: 3,
|
||||
floorplan: {
|
||||
rooms: [
|
||||
{
|
||||
id: "room",
|
||||
name: "Room",
|
||||
floor: "tera:carpet.loop",
|
||||
outline: [
|
||||
{ x: 0, z: 0 },
|
||||
{ x: 8, z: 0 },
|
||||
{ x: 8, z: 6 },
|
||||
{ x: 0, z: 6 },
|
||||
],
|
||||
},
|
||||
],
|
||||
walls: [],
|
||||
props: [{ id: "mic-prop", kind: MIC, position: { x: 2, z: 3 }, rotation: Math.PI / 2 }],
|
||||
// Authored here as well as handed to the layer, because that is how a
|
||||
// real pack carries them: `Plan` is what turns the anchor into a
|
||||
// coordinate, and a declaration the plan never saw is a device with
|
||||
// nowhere to stand.
|
||||
devices: declarations,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as Office;
|
||||
}
|
||||
|
||||
const declaration: DeviceDeclaration = {
|
||||
id: "mic-1",
|
||||
kind: "mic",
|
||||
label: "Desk mic",
|
||||
assetId: MIC,
|
||||
anchor: { levelId: "l1", propId: "mic-prop", offset: { x: 0, y: 0.72, z: 0 } },
|
||||
capabilities: ["power", "mute", "gain", "level"],
|
||||
provenance: "simulated",
|
||||
disclosure: "Simulated studio hardware. Demonstration data, never presence data.",
|
||||
};
|
||||
|
||||
function state(over: Partial<DeviceState> = {}): DeviceState {
|
||||
return {
|
||||
id: "mic-1",
|
||||
kind: "mic",
|
||||
powered: false,
|
||||
muted: false,
|
||||
gainDb: 12,
|
||||
levelDb: -60,
|
||||
observedAt: 0,
|
||||
synthetic: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function layerFor(options: { withIndicator?: boolean; declarations?: DeviceDeclaration[] } = {}) {
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
const assets = registry(options.withIndicator ?? true);
|
||||
const declarations = options.declarations ?? [declaration];
|
||||
const plan = new Plan(office(declarations), { depth: "full", warn: false });
|
||||
const layer = createDeviceLayer({ plan, declarations, assets, materials });
|
||||
return { layer, materials, plan };
|
||||
}
|
||||
|
||||
/** The indicator's material, wherever in the subtree it ended up. */
|
||||
function indicatorMaterial(object: THREE.Object3D): THREE.MeshStandardMaterial | null {
|
||||
const indicator = object.getObjectByName("indicator");
|
||||
if (!indicator) return null;
|
||||
let found: THREE.MeshStandardMaterial | null = null;
|
||||
indicator.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh;
|
||||
if (mesh.isMesh && !Array.isArray(mesh.material)) {
|
||||
found = mesh.material as THREE.MeshStandardMaterial;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("placing the hardware", () => {
|
||||
it("stands a device on its anchor prop, in the prop's own frame", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
const mount = layer.object.getObjectByName("device:mic-1");
|
||||
assert.ok(mount !== undefined);
|
||||
layer.object.updateMatrixWorld(true);
|
||||
|
||||
const world = new THREE.Vector3();
|
||||
(layer.object.getObjectByName("indicator") as THREE.Object3D).getWorldPosition(world);
|
||||
// The prop stands at (2, 3) rotated a quarter turn, and the declaration
|
||||
// lifts the device 0.72 m up the desk. The indicator is 30 mm forward of
|
||||
// the mic's own origin, which the prop's rotation carries round with it —
|
||||
// which is the whole reason the offset is expressed in the prop's frame.
|
||||
assert.ok(Math.abs(world.y - (0.72 + 0.22)) < 1e-6, `y ${world.y}`);
|
||||
assert.ok(Math.abs(world.x - 2.03) < 1e-6, `x ${world.x}`);
|
||||
assert.ok(Math.abs(world.z - 3) < 1e-6, `z ${world.z}`);
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("drops a device whose hardware is not in the plan", () => {
|
||||
const { layer, materials } = layerFor({
|
||||
declarations: [
|
||||
declaration,
|
||||
{ ...declaration, id: "mic-nowhere", anchor: { levelId: "l1", propId: "no-such-prop" } },
|
||||
{ ...declaration, id: "mic-elsewhere", anchor: { levelId: "l9", propId: "mic-prop" } },
|
||||
],
|
||||
});
|
||||
const names = layer.object.children.map((child) => child.name);
|
||||
assert.deepEqual(names, ["device:mic-1"]);
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("still stands hardware that exposes no indicator", () => {
|
||||
const { layer, materials } = layerFor({ withIndicator: false });
|
||||
assert.equal(layer.object.children.length, 1);
|
||||
// And applying a state to it is a no-op rather than a throw: a
|
||||
// self-hoster's own microphone model is not a reason to lose the office.
|
||||
layer.apply([state({ powered: true })]);
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("builds the same geometry every time, from the same declaration", () => {
|
||||
const a = layerFor();
|
||||
const b = layerFor();
|
||||
const box = (object: THREE.Object3D) => {
|
||||
object.updateMatrixWorld(true);
|
||||
return [...new THREE.Box3().setFromObject(object).min.toArray()];
|
||||
};
|
||||
assert.deepEqual(box(a.layer.object), box(b.layer.object));
|
||||
a.layer.dispose();
|
||||
a.materials.dispose();
|
||||
b.layer.dispose();
|
||||
b.materials.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("showing the state", () => {
|
||||
it("changes the indicator colour between powered and unpowered", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
|
||||
layer.apply([state({ powered: false })]);
|
||||
const off = indicatorMaterial(layer.object);
|
||||
assert.ok(off !== null);
|
||||
const offColor = off.color.getHex();
|
||||
|
||||
layer.apply([state({ powered: true })]);
|
||||
const on = indicatorMaterial(layer.object);
|
||||
assert.ok(on !== null);
|
||||
assert.notEqual(on.color.getHex(), offColor);
|
||||
// The role glows, so the tint has to reach the emissive term as well —
|
||||
// otherwise a "lit" LED is a coloured pebble under the tone curve.
|
||||
assert.equal(on.emissive.getHex(), on.color.getHex());
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("shows a muted microphone differently from an open one", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
layer.apply([state({ powered: true, muted: false })]);
|
||||
const open = indicatorMaterial(layer.object)?.color.getHex();
|
||||
layer.apply([state({ powered: true, muted: true })]);
|
||||
const muted = indicatorMaterial(layer.object)?.color.getHex();
|
||||
assert.notEqual(open, muted);
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("shares one material per state across every device in the building", () => {
|
||||
const { layer, materials } = layerFor({
|
||||
declarations: [declaration, { ...declaration, id: "mic-2" }],
|
||||
});
|
||||
layer.apply([state({ id: "mic-1", powered: true }), state({ id: "mic-2", powered: true })]);
|
||||
const [first, second] = layer.object.children;
|
||||
// The same object, not an equal one: two microphones in the same state are
|
||||
// one material and one draw call, which is what `tinted` is cached for.
|
||||
assert.equal(indicatorMaterial(first as THREE.Object3D), indicatorMaterial(second as THREE.Object3D));
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("leaves a device alone when a poll drops it", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
layer.apply([state({ powered: true })]);
|
||||
const on = indicatorMaterial(layer.object)?.color.getHex();
|
||||
// A reading that stopped arriving is not the same event as a device being
|
||||
// switched off, and only one of them should change what is on screen.
|
||||
layer.apply([]);
|
||||
assert.equal(indicatorMaterial(layer.object)?.color.getHex(), on);
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("ignores a state for a device it does not carry", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
layer.apply([state({ id: "somebody-elses-mic", powered: true })]);
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the lighting rule", () => {
|
||||
it("constructs no light of any kind", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
layer.apply([state({ powered: true })]);
|
||||
let lights = 0;
|
||||
layer.object.traverse((child) => {
|
||||
if ((child as THREE.Light).isLight) lights += 1;
|
||||
});
|
||||
assert.equal(lights, 0);
|
||||
layer.dispose();
|
||||
materials.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("disposal", () => {
|
||||
it("frees every geometry it built", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
const geometries: THREE.BufferGeometry[] = [];
|
||||
layer.object.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh;
|
||||
if (mesh.isMesh) geometries.push(mesh.geometry);
|
||||
});
|
||||
assert.ok(geometries.length > 0);
|
||||
|
||||
let freed = 0;
|
||||
for (const geometry of geometries) geometry.addEventListener("dispose", () => (freed += 1));
|
||||
layer.dispose();
|
||||
assert.equal(freed, geometries.length);
|
||||
assert.equal(layer.object.children.length, 0);
|
||||
materials.dispose();
|
||||
});
|
||||
|
||||
it("does not dispose the shared materials it borrowed", () => {
|
||||
const { layer, materials } = layerFor();
|
||||
layer.apply([state({ powered: true })]);
|
||||
const material = indicatorMaterial(layer.object);
|
||||
assert.ok(material !== null);
|
||||
|
||||
let disposed = 0;
|
||||
material.addEventListener("dispose", () => (disposed += 1));
|
||||
layer.dispose();
|
||||
// The registry's material is holding up the rest of the office. Freeing it
|
||||
// here would be a device layer emptying the desks.
|
||||
assert.equal(disposed, 0);
|
||||
assert.equal(materials.tinted("deviceIndicator", material.color.getHex()), material);
|
||||
materials.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* The device contract, held to the two promises it makes to everybody else.
|
||||
*
|
||||
* `src/devices/types.ts` is imported by the office packs, by the device panel,
|
||||
* by the API and by the arena, and three of those four consume it in a context
|
||||
* where a mistake here is invisible until much later. So this file asserts the
|
||||
* properties the other four are *entitled to assume*:
|
||||
*
|
||||
* 1. **A declaration round-trips through JSON unchanged.** It is authored
|
||||
* inside an `Office` pack, and CONTRACT.md §2 says a hand-written pack and
|
||||
* one arriving over HTTP have to be the same thing. A `Date`, a class
|
||||
* instance or an undefined-valued field would break that quietly — the
|
||||
* pack would still build, and the served copy would differ from the
|
||||
* authored one.
|
||||
* 2. **The vocabulary and the shape agree.** Every capability has a reading,
|
||||
* every commandable op is a capability, every canonical set is legal. The
|
||||
* failure this prevents is a kind added later whose new capability nobody
|
||||
* wired into the reading table, which shows up as a control that renders
|
||||
* and does nothing.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
CANONICAL_CAPABILITIES,
|
||||
CAPABILITY_READING,
|
||||
DEVICE_CAPABILITIES,
|
||||
DEVICE_COMMAND_OPS,
|
||||
DEVICE_KINDS,
|
||||
DEVICE_PROVENANCE,
|
||||
DEVICE_RANGES,
|
||||
deviceKindOfAssetId,
|
||||
deviceStateSignature,
|
||||
hasCapability,
|
||||
initialDeviceState,
|
||||
isDeviceCapability,
|
||||
isDeviceCommandOp,
|
||||
isDeviceKind,
|
||||
isDeviceProvenance,
|
||||
normalizeDeviceCommand,
|
||||
validateDeviceDeclaration,
|
||||
type DeviceDeclaration,
|
||||
type DeviceState,
|
||||
} from "../../devices/types.ts";
|
||||
|
||||
/** A mic as a pack author would write it, with every optional field populated. */
|
||||
const DESK_MIC: DeviceDeclaration = {
|
||||
id: "hq-mic-01",
|
||||
kind: "mic",
|
||||
label: "Desk mic",
|
||||
assetId: "tera:device.mic.desk",
|
||||
anchor: {
|
||||
levelId: "l1",
|
||||
propId: "eng-desk-01",
|
||||
roomId: "engineering",
|
||||
seatId: "eng-01",
|
||||
offset: { x: 0.31, y: 0.74, z: -0.18 },
|
||||
},
|
||||
capabilities: ["power", "mute", "gain", "level"],
|
||||
provenance: "simulated",
|
||||
disclosure: "Simulated studio hardware. This is demonstration data, never presence data.",
|
||||
};
|
||||
|
||||
/** A speaker with only the required fields, which is the other authoring shape. */
|
||||
const DESK_SPEAKER: DeviceDeclaration = {
|
||||
id: "hq-speaker-01",
|
||||
kind: "speaker",
|
||||
label: "Monitor speaker",
|
||||
assetId: "tera:device.speaker.desk",
|
||||
anchor: { levelId: "l1", propId: "eng-monitor-01" },
|
||||
capabilities: ["power", "volume", "playback"],
|
||||
provenance: "simulated",
|
||||
disclosure: "Simulated playback. Nothing here is a recording of a real room.",
|
||||
};
|
||||
|
||||
describe("a declaration is JSON, and only JSON", () => {
|
||||
for (const declaration of [DESK_MIC, DESK_SPEAKER]) {
|
||||
it(`round-trips ${declaration.id} through JSON unchanged`, () => {
|
||||
const round = JSON.parse(JSON.stringify(declaration)) as DeviceDeclaration;
|
||||
assert.deepEqual(round, declaration);
|
||||
// deepEqual is satisfied by a Date that stringifies to the same shape, so
|
||||
// the identity of the parsed value is checked too: what comes back must
|
||||
// be plain objects, arrays, strings, numbers and booleans.
|
||||
assert.equal(round.constructor, Object);
|
||||
assert.ok(Array.isArray(round.capabilities));
|
||||
});
|
||||
}
|
||||
|
||||
it("survives the round trip inside a pack, which is where it actually lives", () => {
|
||||
// The real shape: a floorplan carrying a devices array, stringified whole.
|
||||
const floorplan = { id: "l1", devices: [DESK_MIC, DESK_SPEAKER] };
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(floorplan)), floorplan);
|
||||
});
|
||||
|
||||
it("carries no key whose value is undefined, which JSON silently drops", () => {
|
||||
// The trap this catches: `offset: undefined` deep-equals an absent `offset`
|
||||
// in JS but is not the same object after a round trip through a server.
|
||||
const walk = (value: unknown, path: string): void => {
|
||||
if (value === null || typeof value !== "object") return;
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, i) => walk(item, `${path}[${i}]`));
|
||||
return;
|
||||
}
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
assert.notEqual(item, undefined, `${path}.${key} is undefined`);
|
||||
walk(item, `${path}.${key}`);
|
||||
}
|
||||
};
|
||||
walk(DESK_MIC, "mic");
|
||||
walk(DESK_SPEAKER, "speaker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the vocabulary", () => {
|
||||
it("gives every capability its own reading on the state", () => {
|
||||
// One row per capability, and no two capabilities pointing at the same
|
||||
// field — a duplicate would mean two controls fighting over one reading.
|
||||
const fields = DEVICE_CAPABILITIES.map((capability) => CAPABILITY_READING[capability]);
|
||||
assert.equal(Object.keys(CAPABILITY_READING).length, DEVICE_CAPABILITIES.length);
|
||||
assert.equal(new Set(fields).size, fields.length);
|
||||
// Every one of them is a field a real state actually carries.
|
||||
const everything = initialDeviceState(
|
||||
{ ...DESK_MIC, capabilities: DEVICE_CAPABILITIES },
|
||||
0,
|
||||
);
|
||||
for (const field of fields) {
|
||||
assert.ok(field in everything, `${field} is not a field on DeviceState`);
|
||||
}
|
||||
});
|
||||
|
||||
it("commands exactly the capabilities that are not read-only", () => {
|
||||
// `level` is a meter. If this ever admits it, something has grown a
|
||||
// "set the level" button that cannot do anything.
|
||||
assert.deepEqual(
|
||||
[...DEVICE_COMMAND_OPS].sort(),
|
||||
DEVICE_CAPABILITIES.filter((c) => c !== "level").sort(),
|
||||
);
|
||||
assert.equal(isDeviceCommandOp("level"), false);
|
||||
assert.equal(isDeviceCapability("level"), true);
|
||||
});
|
||||
|
||||
it("keeps every canonical set inside the capability vocabulary", () => {
|
||||
for (const kind of DEVICE_KINDS) {
|
||||
const set = CANONICAL_CAPABILITIES[kind];
|
||||
assert.ok(set.length > 0, `${kind} has no canonical capabilities`);
|
||||
for (const capability of set) assert.ok(isDeviceCapability(capability));
|
||||
assert.ok(set.includes("power"), `${kind} must be switchable`);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a value that is not in the vocabulary", () => {
|
||||
assert.equal(isDeviceKind("thermostat"), false);
|
||||
assert.equal(isDeviceKind(7), false);
|
||||
assert.equal(isDeviceCapability("colour"), false);
|
||||
assert.equal(isDeviceProvenance("vibes"), false);
|
||||
for (const p of DEVICE_PROVENANCE) assert.equal(isDeviceProvenance(p), true);
|
||||
});
|
||||
|
||||
it("gives every numeric reading a range with rest inside it", () => {
|
||||
for (const [name, range] of Object.entries(DEVICE_RANGES)) {
|
||||
assert.ok(range.min < range.max, `${name} range is inverted`);
|
||||
assert.ok(range.initial >= range.min && range.initial <= range.max, `${name} rests outside`);
|
||||
assert.notEqual(range.unit, "", `${name} has no unit`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading an asset id", () => {
|
||||
it("finds the kind a device asset claims to be", () => {
|
||||
assert.equal(deviceKindOfAssetId("tera:device.mic.desk"), "mic");
|
||||
assert.equal(deviceKindOfAssetId("tera:device.speaker.desk"), "speaker");
|
||||
// A self-hoster's own hardware reads as a device without registering here.
|
||||
assert.equal(deviceKindOfAssetId("acme:device.mic.boom"), "mic");
|
||||
});
|
||||
|
||||
it("says null for anything that is not device hardware", () => {
|
||||
assert.equal(deviceKindOfAssetId("tera:desk.workstation"), null);
|
||||
assert.equal(deviceKindOfAssetId("tera:device.thermostat.wall"), null);
|
||||
assert.equal(deviceKindOfAssetId("device"), null);
|
||||
assert.equal(deviceKindOfAssetId(""), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validating an authored declaration", () => {
|
||||
it("passes the two this build ships", () => {
|
||||
assert.deepEqual(validateDeviceDeclaration(DESK_MIC), []);
|
||||
assert.deepEqual(validateDeviceDeclaration(DESK_SPEAKER), []);
|
||||
});
|
||||
|
||||
it("catches a declaration whose asset is the other instrument", () => {
|
||||
// The failure that matters: a command routed to the wrong hardware.
|
||||
const problems = validateDeviceDeclaration({
|
||||
...DESK_MIC,
|
||||
assetId: "tera:device.speaker.desk",
|
||||
});
|
||||
assert.equal(problems.length, 1);
|
||||
assert.match(problems[0] ?? "", /declared a mic/);
|
||||
});
|
||||
|
||||
it("catches a simulated device whose disclosure does not say so", () => {
|
||||
const problems = validateDeviceDeclaration({
|
||||
...DESK_MIC,
|
||||
disclosure: "Live from the studio floor.",
|
||||
});
|
||||
assert.equal(problems.length, 1);
|
||||
assert.match(problems[0] ?? "", /does not say so/);
|
||||
});
|
||||
|
||||
it("catches a device anchored to nothing, and one that can do nothing", () => {
|
||||
const floating = validateDeviceDeclaration({
|
||||
...DESK_MIC,
|
||||
anchor: { levelId: "l1", propId: "" },
|
||||
});
|
||||
assert.equal(floating.length, 1);
|
||||
assert.match(floating[0] ?? "", /anchored to no prop/);
|
||||
|
||||
const inert = validateDeviceDeclaration({ ...DESK_SPEAKER, capabilities: [] });
|
||||
assert.equal(inert.length, 1);
|
||||
assert.match(inert[0] ?? "", /no capabilities/);
|
||||
});
|
||||
|
||||
it("never throws, whatever it is handed", () => {
|
||||
// Packs resolution records problems; it does not lose the building over
|
||||
// one bad device. That contract is only worth anything if this is total.
|
||||
const rubbish = {
|
||||
id: "",
|
||||
kind: "toaster",
|
||||
label: " ",
|
||||
assetId: "",
|
||||
anchor: { levelId: "", propId: "" },
|
||||
capabilities: ["warmth"],
|
||||
provenance: "hearsay",
|
||||
disclosure: "",
|
||||
} as unknown as DeviceDeclaration;
|
||||
const problems = validateDeviceDeclaration(rubbish);
|
||||
assert.ok(problems.length >= 6, `expected a pile of problems, got ${problems.length}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the state a declaration implies", () => {
|
||||
it("carries a reading for each declared capability and no others", () => {
|
||||
const state = initialDeviceState(DESK_MIC, 1_770_000_000_000);
|
||||
assert.equal(state.powered, false);
|
||||
assert.equal(state.muted, false);
|
||||
assert.equal(state.gainDb, DEVICE_RANGES.gain.initial);
|
||||
assert.equal(state.levelDb, DEVICE_RANGES.level.initial);
|
||||
// A mic has no volume and no transport. `undefined` means "no such
|
||||
// reading", which is not the same as zero.
|
||||
assert.equal(state.volume, undefined);
|
||||
assert.equal(state.playing, undefined);
|
||||
assert.equal(state.observedAt, 1_770_000_000_000);
|
||||
assert.equal(state.synthetic, true);
|
||||
});
|
||||
|
||||
it("agrees with CAPABILITY_READING for every kind", () => {
|
||||
for (const kind of DEVICE_KINDS) {
|
||||
const declaration: DeviceDeclaration = {
|
||||
...DESK_MIC,
|
||||
kind,
|
||||
assetId: `tera:device.${kind}.desk`,
|
||||
capabilities: CANONICAL_CAPABILITIES[kind],
|
||||
};
|
||||
const state = initialDeviceState(declaration, 0);
|
||||
for (const capability of DEVICE_CAPABILITIES) {
|
||||
const field = CAPABILITY_READING[capability];
|
||||
const present = state[field] !== undefined;
|
||||
assert.equal(
|
||||
present,
|
||||
hasCapability(declaration, capability),
|
||||
`${kind}.${capability} → ${field}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is JSON too, because it is what the wire carries", () => {
|
||||
const state = initialDeviceState(DESK_SPEAKER, 12);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(state)), state);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalising a command", () => {
|
||||
it("accepts a supported op and clamps the knob into range", () => {
|
||||
const hot = normalizeDeviceCommand(DESK_MIC, { deviceId: DESK_MIC.id, op: "gain", value: 999 });
|
||||
assert.deepEqual(hot, { deviceId: DESK_MIC.id, op: "gain", value: DEVICE_RANGES.gain.max });
|
||||
|
||||
const quiet = normalizeDeviceCommand(DESK_SPEAKER, {
|
||||
deviceId: DESK_SPEAKER.id,
|
||||
op: "volume",
|
||||
value: -3,
|
||||
});
|
||||
assert.deepEqual(quiet, {
|
||||
deviceId: DESK_SPEAKER.id,
|
||||
op: "volume",
|
||||
value: DEVICE_RANGES.volume.min,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an op the device does not declare", () => {
|
||||
// A speaker has no gain stage here. The API must not apply this and the
|
||||
// panel must not offer it.
|
||||
assert.equal(
|
||||
normalizeDeviceCommand(DESK_SPEAKER, { deviceId: DESK_SPEAKER.id, op: "gain", value: 3 }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeDeviceCommand(DESK_MIC, { deviceId: DESK_MIC.id, op: "volume", value: 0.5 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a command addressed to another device", () => {
|
||||
assert.equal(
|
||||
normalizeDeviceCommand(DESK_MIC, { deviceId: DESK_SPEAKER.id, op: "power", value: true }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a value of the wrong type, including a missing one", () => {
|
||||
const bad = [
|
||||
{ deviceId: DESK_MIC.id, op: "power" },
|
||||
{ deviceId: DESK_MIC.id, op: "power", value: 1 },
|
||||
{ deviceId: DESK_MIC.id, op: "gain", value: true },
|
||||
{ deviceId: DESK_MIC.id, op: "gain", value: Number.NaN },
|
||||
{ deviceId: DESK_MIC.id, op: "level", value: -3 },
|
||||
{ deviceId: DESK_MIC.id, op: "explode", value: true },
|
||||
];
|
||||
for (const command of bad) {
|
||||
assert.equal(
|
||||
normalizeDeviceCommand(DESK_MIC, command as never),
|
||||
null,
|
||||
`${JSON.stringify(command)} should be refused`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("hands back a fresh object rather than the caller's", () => {
|
||||
const sent = { deviceId: DESK_MIC.id, op: "mute" as const, value: true };
|
||||
const normalised = normalizeDeviceCommand(DESK_MIC, sent);
|
||||
assert.notEqual(normalised, sent);
|
||||
assert.deepEqual(normalised, sent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the change signature", () => {
|
||||
it("ignores the clock and notices everything else", () => {
|
||||
const a: DeviceState = initialDeviceState(DESK_MIC, 1);
|
||||
const later: DeviceState = { ...a, observedAt: 9_999 };
|
||||
assert.equal(deviceStateSignature([a]), deviceStateSignature([later]));
|
||||
|
||||
for (const changed of [
|
||||
{ ...a, powered: true },
|
||||
{ ...a, muted: true },
|
||||
{ ...a, gainDb: 18 },
|
||||
{ ...a, levelDb: -12 },
|
||||
{ ...a, synthetic: false },
|
||||
]) {
|
||||
assert.notEqual(
|
||||
deviceStateSignature([a]),
|
||||
deviceStateSignature([changed]),
|
||||
`a change to ${JSON.stringify(changed)} should publish`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("separates devices, so two states cannot swap unnoticed", () => {
|
||||
const mic = initialDeviceState(DESK_MIC, 1);
|
||||
const speaker = initialDeviceState(DESK_SPEAKER, 1);
|
||||
assert.notEqual(deviceStateSignature([mic, speaker]), deviceStateSignature([speaker, mic]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* The simulator, held to the property the arena depends on.
|
||||
*
|
||||
* `studio-ops-v1` wraps **this module**, not a copy of it, and replays a
|
||||
* rollout by re-running it from a seed and a list of actions. That only works
|
||||
* if every number it produces is a pure function of (seed, declarations, steps,
|
||||
* commands) — so the assertions here are mostly equalities between two runs
|
||||
* rather than statements about any particular reading.
|
||||
*
|
||||
* The second half is the behaviour a viewer actually looks at: a level that
|
||||
* responds to who is at the desk, a mute that visibly stops it, a volume knob
|
||||
* the meter follows, and a device that never reports a reading its declaration
|
||||
* did not claim. Those are what make an instrument panel worth opening, and
|
||||
* they are what would silently rot without a test — a meter that stopped
|
||||
* responding would still be a meter.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { createSimulatedDevices } from "../../devices/sim.ts";
|
||||
import { DEVICE_RANGES, type DeviceDeclaration, type DeviceState } from "../../devices/types.ts";
|
||||
|
||||
const disclosure = "Simulated studio hardware. Demonstration data, never presence data.";
|
||||
|
||||
const MIC: DeviceDeclaration = {
|
||||
id: "mic-1",
|
||||
kind: "mic",
|
||||
label: "Desk mic",
|
||||
assetId: "tera:device.mic.desk",
|
||||
anchor: { levelId: "l1", propId: "mic-prop", seatId: "desk-01" },
|
||||
capabilities: ["power", "mute", "gain", "level"],
|
||||
provenance: "simulated",
|
||||
disclosure,
|
||||
};
|
||||
|
||||
const SPEAKER: DeviceDeclaration = {
|
||||
id: "speaker-1",
|
||||
kind: "speaker",
|
||||
label: "Monitor",
|
||||
assetId: "tera:device.speaker.desk",
|
||||
anchor: { levelId: "l1", propId: "speaker-prop" },
|
||||
capabilities: ["power", "volume", "playback", "level"],
|
||||
provenance: "simulated",
|
||||
disclosure,
|
||||
};
|
||||
|
||||
/** A speaker with no meter, to prove a reading follows the declaration. */
|
||||
const PLAIN_SPEAKER: DeviceDeclaration = {
|
||||
...SPEAKER,
|
||||
id: "speaker-2",
|
||||
capabilities: ["power", "volume", "playback"],
|
||||
};
|
||||
|
||||
const DECLARATIONS = [MIC, SPEAKER, PLAIN_SPEAKER];
|
||||
|
||||
function sim(seed = 7) {
|
||||
return createSimulatedDevices(DECLARATIONS, { seed, fixedStepSeconds: 0.1 });
|
||||
}
|
||||
|
||||
/** Run a simulator and collect every reading, as JSON, for comparison. */
|
||||
function run(simulator: ReturnType<typeof sim>, steps: number): string[] {
|
||||
const frames: string[] = [];
|
||||
for (let i = 0; i < steps; i += 1) {
|
||||
simulator.stepFixed();
|
||||
frames.push(JSON.stringify(simulator.current()));
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/** Average level over a stretch, which is what the eye reads off a meter. */
|
||||
function meanLevel(simulator: ReturnType<typeof sim>, id: string, steps: number): number {
|
||||
let total = 0;
|
||||
for (let i = 0; i < steps; i += 1) {
|
||||
simulator.stepFixed();
|
||||
total += simulator.current().find((s) => s.id === id)?.levelDb ?? 0;
|
||||
}
|
||||
return total / steps;
|
||||
}
|
||||
|
||||
function powerOn(simulator: ReturnType<typeof sim>, id: string): void {
|
||||
simulator.command({ deviceId: id, op: "power", value: true });
|
||||
}
|
||||
|
||||
describe("determinism", () => {
|
||||
it("produces identical sequences from the same seed", () => {
|
||||
const a = run(sim(7), 400);
|
||||
const b = run(sim(7), 400);
|
||||
assert.deepEqual(a, b);
|
||||
});
|
||||
|
||||
it("produces a different studio from a different seed", () => {
|
||||
// Powered up first, deliberately: a studio whose hardware is all switched
|
||||
// off reports the same floor whatever the seed, and a test that passed on
|
||||
// that would be asserting nothing.
|
||||
const a = sim(7);
|
||||
const b = sim(8);
|
||||
for (const simulator of [a, b]) {
|
||||
powerOn(simulator, "mic-1");
|
||||
simulator.setOccupancy(["desk-01"]);
|
||||
}
|
||||
assert.notDeepEqual(run(a, 200), run(b, 200));
|
||||
});
|
||||
|
||||
it("reads no clock — stepping is the only thing that moves time", () => {
|
||||
const simulator = sim();
|
||||
const before = JSON.stringify(simulator.current());
|
||||
// Nothing here advances anything, however long the process has been alive.
|
||||
for (let i = 0; i < 5; i += 1) JSON.stringify(simulator.current());
|
||||
assert.equal(JSON.stringify(simulator.current()), before);
|
||||
});
|
||||
|
||||
it("continues bit-exactly from a snapshot", () => {
|
||||
const original = sim(11);
|
||||
powerOn(original, "mic-1");
|
||||
original.setOccupancy(["desk-01"]);
|
||||
run(original, 137);
|
||||
|
||||
// Through JSON, because that is how a caller will have kept it: an arena
|
||||
// trace on disk, not a live object.
|
||||
const snapshot: unknown = JSON.parse(JSON.stringify(original.snapshot()));
|
||||
const resumed = createSimulatedDevices(DECLARATIONS, { seed: 999, fixedStepSeconds: 0.1 });
|
||||
resumed.restore(snapshot);
|
||||
|
||||
assert.deepEqual(run(resumed, 200), run(original, 200));
|
||||
});
|
||||
|
||||
it("keeps the snapshot free of anything JSON would lose", () => {
|
||||
const simulator = sim();
|
||||
run(simulator, 5);
|
||||
const snapshot = simulator.snapshot();
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(snapshot)), snapshot);
|
||||
});
|
||||
|
||||
it("hands back a snapshot the caller can hold while the run continues", () => {
|
||||
const simulator = sim();
|
||||
powerOn(simulator, "mic-1");
|
||||
const snapshot = JSON.stringify(simulator.snapshot());
|
||||
run(simulator, 50);
|
||||
// The snapshot is a copy, not a window onto live state.
|
||||
assert.equal(JSON.stringify(simulator.snapshot()) === snapshot, false);
|
||||
assert.deepEqual(JSON.parse(snapshot), JSON.parse(JSON.stringify(JSON.parse(snapshot))));
|
||||
});
|
||||
|
||||
it("ignores a snapshot it cannot read rather than half-applying it", () => {
|
||||
const simulator = sim();
|
||||
powerOn(simulator, "mic-1");
|
||||
run(simulator, 20);
|
||||
const before = JSON.stringify(simulator.current());
|
||||
for (const bad of [null, 42, "snapshot", {}, { v: 99 }, { v: 1, devices: "no" }]) {
|
||||
simulator.restore(bad);
|
||||
assert.equal(JSON.stringify(simulator.current()), before);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let a snapshot add devices this simulator was not built for", () => {
|
||||
const simulator = sim();
|
||||
const other = createSimulatedDevices(
|
||||
[...DECLARATIONS, { ...MIC, id: "mic-from-another-pack" }],
|
||||
{ seed: 7, fixedStepSeconds: 0.1 },
|
||||
);
|
||||
run(other, 10);
|
||||
simulator.restore(JSON.parse(JSON.stringify(other.snapshot())));
|
||||
assert.deepEqual(
|
||||
simulator.current().map((s) => s.id),
|
||||
["mic-1", "speaker-1", "speaker-2"],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not shift the random stream when a command is issued", () => {
|
||||
// A command must not consume randomness, or a replay that issues one at a
|
||||
// different step would diverge from the trace it is replaying.
|
||||
const commanded = sim(5);
|
||||
const quiet = sim(5);
|
||||
commanded.command({ deviceId: "mic-1", op: "gain", value: 12 });
|
||||
// The same gain it already had, so nothing about the state changed either.
|
||||
assert.deepEqual(run(commanded, 100), run(quiet, 100));
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a device reports", () => {
|
||||
it("reports one reading per declared capability and no others", () => {
|
||||
const states = sim().current();
|
||||
const mic = states.find((s) => s.id === "mic-1") as DeviceState;
|
||||
assert.equal(typeof mic.gainDb, "number");
|
||||
assert.equal(typeof mic.levelDb, "number");
|
||||
assert.equal(typeof mic.muted, "boolean");
|
||||
assert.equal(mic.volume, undefined);
|
||||
assert.equal(mic.playing, undefined);
|
||||
|
||||
const plain = states.find((s) => s.id === "speaker-2") as DeviceState;
|
||||
assert.equal(typeof plain.volume, "number");
|
||||
assert.equal(typeof plain.playing, "boolean");
|
||||
// Absent, not zero. `undefined` means "no such reading", and a panel draws
|
||||
// no meter for it — where a zero would draw a dead one.
|
||||
assert.equal(plain.levelDb, undefined);
|
||||
});
|
||||
|
||||
it("starts powered off, at rest, and says every reading is invented", () => {
|
||||
for (const state of sim().current()) {
|
||||
assert.equal(state.powered, false);
|
||||
assert.equal(state.synthetic, true);
|
||||
}
|
||||
const mic = sim().current()[0] as DeviceState;
|
||||
assert.equal(mic.gainDb, DEVICE_RANGES.gain.initial);
|
||||
assert.equal(mic.levelDb, DEVICE_RANGES.level.min);
|
||||
});
|
||||
|
||||
it("keeps every level inside the published range", () => {
|
||||
const simulator = sim();
|
||||
powerOn(simulator, "mic-1");
|
||||
powerOn(simulator, "speaker-1");
|
||||
simulator.command({ deviceId: "speaker-1", op: "playback", value: true });
|
||||
simulator.command({ deviceId: "mic-1", op: "gain", value: DEVICE_RANGES.gain.max });
|
||||
simulator.setOccupancy(["desk-01"]);
|
||||
for (let i = 0; i < 600; i += 1) {
|
||||
simulator.stepFixed();
|
||||
for (const state of simulator.current()) {
|
||||
if (state.levelDb === undefined) continue;
|
||||
assert.ok(state.levelDb >= DEVICE_RANGES.level.min, `${state.levelDb}`);
|
||||
assert.ok(state.levelDb <= DEVICE_RANGES.level.max, `${state.levelDb}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a microphone that responds to the room", () => {
|
||||
it("meters higher with somebody at the desk than with nobody", () => {
|
||||
const busy = sim(3);
|
||||
powerOn(busy, "mic-1");
|
||||
busy.setOccupancy(["desk-01"]);
|
||||
|
||||
const empty = sim(3);
|
||||
powerOn(empty, "mic-1");
|
||||
empty.setOccupancy([]);
|
||||
|
||||
// Averaged over twenty seconds, because a single frame of room tone can
|
||||
// peak above a single frame of speech — that is what makes it look real.
|
||||
assert.ok(meanLevel(busy, "mic-1", 200) > meanLevel(empty, "mic-1", 200) + 8);
|
||||
});
|
||||
|
||||
it("runs its own occupancy until somebody tells it otherwise", () => {
|
||||
// A deployment with no presence source — which is most of them, and every
|
||||
// anonymous viewer — still gets a studio that is alive rather than flat.
|
||||
const simulator = sim(23);
|
||||
powerOn(simulator, "mic-1");
|
||||
let peak = DEVICE_RANGES.level.min;
|
||||
for (let i = 0; i < 4000; i += 1) {
|
||||
simulator.stepFixed();
|
||||
peak = Math.max(peak, simulator.current()[0]?.levelDb ?? peak);
|
||||
}
|
||||
// Somewhere in that six and a half minutes, somebody sat down.
|
||||
assert.ok(peak > -30, `peak ${peak}`);
|
||||
});
|
||||
|
||||
it("drops to the floor when muted, and comes back when unmuted", () => {
|
||||
const simulator = sim(3);
|
||||
powerOn(simulator, "mic-1");
|
||||
simulator.setOccupancy(["desk-01"]);
|
||||
assert.ok(meanLevel(simulator, "mic-1", 100) > -40);
|
||||
|
||||
simulator.command({ deviceId: "mic-1", op: "mute", value: true });
|
||||
// Ten steps is a second — the release is slow on purpose, and a button that
|
||||
// took longer than that to visibly work would read as broken.
|
||||
for (let i = 0; i < 40; i += 1) simulator.stepFixed();
|
||||
assert.equal(simulator.current()[0]?.levelDb, DEVICE_RANGES.level.min);
|
||||
|
||||
simulator.command({ deviceId: "mic-1", op: "mute", value: false });
|
||||
assert.ok(meanLevel(simulator, "mic-1", 100) > -40);
|
||||
});
|
||||
|
||||
it("meters at the floor while powered off, whatever the room is doing", () => {
|
||||
const simulator = sim();
|
||||
simulator.setOccupancy(["desk-01"]);
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
simulator.stepFixed();
|
||||
assert.equal(simulator.current()[0]?.levelDb, DEVICE_RANGES.level.min);
|
||||
}
|
||||
});
|
||||
|
||||
it("moves the meter with the gain knob", () => {
|
||||
const quiet = sim(9);
|
||||
powerOn(quiet, "mic-1");
|
||||
quiet.setOccupancy(["desk-01"]);
|
||||
quiet.command({ deviceId: "mic-1", op: "gain", value: 0 });
|
||||
|
||||
const loud = sim(9);
|
||||
powerOn(loud, "mic-1");
|
||||
loud.setOccupancy(["desk-01"]);
|
||||
loud.command({ deviceId: "mic-1", op: "gain", value: 24 });
|
||||
|
||||
assert.ok(meanLevel(loud, "mic-1", 200) > meanLevel(quiet, "mic-1", 200) + 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a speaker with something playing", () => {
|
||||
it("meters only while it is powered and playing", () => {
|
||||
const simulator = sim(4);
|
||||
powerOn(simulator, "speaker-1");
|
||||
for (let i = 0; i < 40; i += 1) simulator.stepFixed();
|
||||
assert.equal(simulator.current()[1]?.levelDb, DEVICE_RANGES.level.min);
|
||||
|
||||
simulator.command({ deviceId: "speaker-1", op: "playback", value: true });
|
||||
assert.ok(meanLevel(simulator, "speaker-1", 100) > -40);
|
||||
});
|
||||
|
||||
it("follows the volume knob", () => {
|
||||
const loud = sim(4);
|
||||
powerOn(loud, "speaker-1");
|
||||
loud.command({ deviceId: "speaker-1", op: "playback", value: true });
|
||||
loud.command({ deviceId: "speaker-1", op: "volume", value: 1 });
|
||||
|
||||
const quiet = sim(4);
|
||||
powerOn(quiet, "speaker-1");
|
||||
quiet.command({ deviceId: "speaker-1", op: "playback", value: true });
|
||||
quiet.command({ deviceId: "speaker-1", op: "volume", value: 0.1 });
|
||||
|
||||
// 20·log10(0.1) is −20 dB, so the gap is real and it is arithmetic rather
|
||||
// than a taste: halving the knob drops the meter about 6 dB.
|
||||
assert.ok(meanLevel(loud, "speaker-1", 200) > meanLevel(quiet, "speaker-1", 200) + 12);
|
||||
});
|
||||
|
||||
it("stops playing when it is switched off, because no real box does otherwise", () => {
|
||||
const simulator = sim();
|
||||
powerOn(simulator, "speaker-1");
|
||||
simulator.command({ deviceId: "speaker-1", op: "playback", value: true });
|
||||
assert.equal(simulator.current()[1]?.playing, true);
|
||||
|
||||
simulator.command({ deviceId: "speaker-1", op: "power", value: false });
|
||||
assert.equal(simulator.current()[1]?.playing, false);
|
||||
});
|
||||
|
||||
it("will not start playing on a speaker that is switched off", () => {
|
||||
const simulator = sim();
|
||||
simulator.command({ deviceId: "speaker-1", op: "playback", value: true });
|
||||
assert.equal(simulator.current()[1]?.playing, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commands it refuses", () => {
|
||||
it("ignores an id it does not carry", () => {
|
||||
const simulator = sim();
|
||||
const before = JSON.stringify(simulator.current());
|
||||
simulator.command({ deviceId: "not-here", op: "power", value: true });
|
||||
assert.equal(JSON.stringify(simulator.current()), before);
|
||||
});
|
||||
|
||||
it("ignores an op the declaration never declared", () => {
|
||||
const simulator = sim();
|
||||
// `speaker-2` declares power, volume and playback. Gain is a mic's.
|
||||
simulator.command({ deviceId: "speaker-2", op: "gain", value: 20 });
|
||||
assert.equal(simulator.current()[2]?.gainDb, undefined);
|
||||
});
|
||||
|
||||
it("ignores a value of the wrong type", () => {
|
||||
const simulator = sim();
|
||||
simulator.command({ deviceId: "mic-1", op: "power", value: 1 as unknown as boolean });
|
||||
assert.equal(simulator.current()[0]?.powered, false);
|
||||
simulator.command({ deviceId: "mic-1", op: "gain", value: true as unknown as number });
|
||||
assert.equal(simulator.current()[0]?.gainDb, DEVICE_RANGES.gain.initial);
|
||||
});
|
||||
|
||||
it("clamps a number into range rather than refusing it", () => {
|
||||
const simulator = sim();
|
||||
simulator.command({ deviceId: "mic-1", op: "gain", value: 9000 });
|
||||
assert.equal(simulator.current()[0]?.gainDb, DEVICE_RANGES.gain.max);
|
||||
simulator.command({ deviceId: "speaker-1", op: "volume", value: -3 });
|
||||
assert.equal(simulator.current()[1]?.volume, DEVICE_RANGES.volume.min);
|
||||
});
|
||||
|
||||
it("cannot be handed a declaration list it then mutates under itself", () => {
|
||||
const declarations = [{ ...MIC }];
|
||||
const simulator = createSimulatedDevices(declarations, { seed: 1, fixedStepSeconds: 0.1 });
|
||||
declarations.length = 0;
|
||||
simulator.command({ deviceId: "mic-1", op: "power", value: true });
|
||||
assert.equal(simulator.current()[0]?.powered, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* The boundary between this bundle and a deployment, from the browser's side.
|
||||
*
|
||||
* Three things meet here and all three are easy to get wrong quietly:
|
||||
*
|
||||
* 1. **The wire bodies are JSON and stay JSON.** `src/server/wire.ts` compiles
|
||||
* to nothing, so nothing in it can be checked by running it — but the shapes
|
||||
* it declares are the shapes both sides build, and a body that does not
|
||||
* survive `JSON.parse(JSON.stringify(x))` unchanged is a body the two sides
|
||||
* will disagree about. The device bodies are new and carry the first
|
||||
* optional-reading type on the wire, which is exactly where a `undefined`
|
||||
* versus `null` mistake hides.
|
||||
* 2. **`/health` is read defensively.** `sources.devices` and `degraded` are
|
||||
* newer than servers this client will meet, and a missing field has to fall
|
||||
* the safe way rather than throw or be assumed.
|
||||
* 3. **The seam chooses a strategy and says which.** An anonymous visitor and a
|
||||
* zero-config clone both get the local simulator, alive and labelled; a
|
||||
* signed-in viewer on a configured box gets the deployment's readings. What
|
||||
* must never happen is a studio that looks live and is not, or a control
|
||||
* that appears to work and changes nothing anybody else can see.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { resolveAccess } from "../../access.ts";
|
||||
import { createTeraClient } from "../../adapters/http.ts";
|
||||
import { createDeviceSource, createNullDeviceSource } from "../../devices/adapter.ts";
|
||||
import { initialDeviceState, type DeviceDeclaration, type DeviceState } from "../../devices/types.ts";
|
||||
import type {
|
||||
DeviceCommandBody,
|
||||
DeviceCommandResultBody,
|
||||
DevicesBody,
|
||||
HealthBody,
|
||||
} from "../../server/wire.ts";
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { location: { origin: "https://office.example.test" } },
|
||||
});
|
||||
|
||||
const DECLARATION: DeviceDeclaration = {
|
||||
id: "mic-1",
|
||||
kind: "mic",
|
||||
label: "Desk mic",
|
||||
assetId: "tera:device.mic.desk",
|
||||
anchor: { levelId: "l1", propId: "mic-prop", seatId: "desk-01" },
|
||||
capabilities: ["power", "mute", "gain", "level"],
|
||||
provenance: "simulated",
|
||||
disclosure: "Simulated studio hardware. Demonstration data, never presence data.",
|
||||
};
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function deployment(routes: Record<string, () => Response>): typeof fetch {
|
||||
return (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
for (const [path, answer] of Object.entries(routes)) {
|
||||
if (url.includes(path)) return answer();
|
||||
}
|
||||
throw new TypeError("Failed to fetch");
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
const health = (over: Partial<HealthBody> = {}): HealthBody => ({
|
||||
ok: true,
|
||||
service: "tera-api",
|
||||
version: "0.1.0",
|
||||
uptimeSeconds: 1,
|
||||
sources: { weather: "nws", flights: "adsb", satellites: "none", markers: "none", devices: "sim" },
|
||||
auth: { mode: "none", entryUrl: null },
|
||||
regions: [{ id: "sf", lat: 37.77, lng: -122.42, radiusKm: 120 }],
|
||||
degraded: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("the bodies are JSON, and stay JSON", () => {
|
||||
it("round-trips a devices body unchanged", () => {
|
||||
const body: DevicesBody = {
|
||||
officeId: "hq",
|
||||
devices: [
|
||||
{ id: "mic-1", kind: "mic", powered: true, muted: false, gainDb: 12, levelDb: -22.4, observedAt: 17, synthetic: true },
|
||||
{ id: "spk-1", kind: "speaker", powered: false, volume: 0.35, playing: false, observedAt: 17, synthetic: true },
|
||||
],
|
||||
observedAt: 17,
|
||||
source: "sim",
|
||||
synthetic: true,
|
||||
ttlSeconds: 5,
|
||||
};
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(body)), body);
|
||||
});
|
||||
|
||||
it("round-trips a command and its result unchanged", () => {
|
||||
const command: DeviceCommandBody = { command: { deviceId: "mic-1", op: "gain", value: 18 } };
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(command)), command);
|
||||
|
||||
const result: DeviceCommandResultBody = {
|
||||
officeId: "hq",
|
||||
device: initialDeviceState(DECLARATION, 17),
|
||||
observedAt: 17,
|
||||
};
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(result)), result);
|
||||
});
|
||||
|
||||
it("omits a reading a device does not have, rather than nulling it", () => {
|
||||
// The difference matters on the wire as well as in the panel: `null` would
|
||||
// survive JSON as a *reading of zero information* that a consumer has to
|
||||
// special-case, while an absent key is already the thing every consumer
|
||||
// checks for. `initialDeviceState` is where the rule is implemented.
|
||||
const state = initialDeviceState({ ...DECLARATION, capabilities: ["power"] }, 1);
|
||||
const round = JSON.parse(JSON.stringify(state)) as DeviceState;
|
||||
assert.equal("levelDb" in round, false);
|
||||
assert.equal("muted" in round, false);
|
||||
assert.equal(round.powered, false);
|
||||
assert.equal(round.synthetic, true);
|
||||
});
|
||||
|
||||
it("declares regions and a device source on the health body", () => {
|
||||
// A compile-time assertion made at runtime: `health()` above is typed as a
|
||||
// `HealthBody`, so this file would not build if either field left the type.
|
||||
const body = health();
|
||||
assert.equal(body.sources.devices, "sim");
|
||||
assert.equal(body.regions[0]?.id, "sf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the browser learns from /health", () => {
|
||||
it("reports the device source and the demotions to the interface", async () => {
|
||||
const access = await resolveAccess(
|
||||
deployment({
|
||||
"/health": () =>
|
||||
json(
|
||||
health({
|
||||
degraded: ["TERA_WEATHER_SOURCE=nws needs TERA_WEATHER_CONTACT.", "and another"],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
assert.equal(access.feeds?.devices, true);
|
||||
// Built, served, and — until now — read by nobody. This is the whole point
|
||||
// of the field: "why is the weather always clear" answers itself.
|
||||
assert.deepEqual(access.degraded, [
|
||||
"TERA_WEATHER_SOURCE=nws needs TERA_WEATHER_CONTACT.",
|
||||
"and another",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls the safe way when the server is older than this client", async () => {
|
||||
const older = health();
|
||||
delete (older.sources as Partial<HealthBody["sources"]>).devices;
|
||||
delete (older as Partial<HealthBody>).degraded;
|
||||
const access = await resolveAccess(deployment({ "/health": () => json(older) }));
|
||||
// No field means no feed and no request. A poll against a box that never
|
||||
// heard of the route is a 404 per TTL per tab, forever.
|
||||
assert.equal(access.feeds?.devices, false);
|
||||
assert.deepEqual(access.degraded, []);
|
||||
});
|
||||
|
||||
it("drops anything in degraded that is not a sentence", async () => {
|
||||
const access = await resolveAccess(
|
||||
deployment({ "/health": () => json(health({ degraded: [1, null, { a: 1 }, "real"] as never })) }),
|
||||
);
|
||||
// `[object Object]` in front of an operator who is already looking at this
|
||||
// list because something is wrong.
|
||||
assert.deepEqual(access.degraded, ["real"]);
|
||||
});
|
||||
|
||||
it("has nothing to report about a deployment that does not exist", async () => {
|
||||
const access = await resolveAccess(deployment({}));
|
||||
assert.equal(access.tier, "member");
|
||||
assert.equal(access.feeds, null);
|
||||
assert.deepEqual(access.degraded, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a zero-config clone", () => {
|
||||
it("gets an empty device feed that does not claim to be live", async () => {
|
||||
const client = createTeraClient({ fetch: deployment({}) });
|
||||
const feed = await client.devices("hq");
|
||||
assert.deepEqual(feed.value, []);
|
||||
assert.equal(feed.live, false);
|
||||
assert.equal(feed.source, "none");
|
||||
});
|
||||
|
||||
it("gets a studio that is alive anyway, from the simulator in this tab", () => {
|
||||
const source = createDeviceSource({ declarations: [DECLARATION], client: null });
|
||||
source.command({ deviceId: "mic-1", op: "power", value: true });
|
||||
let peak = -60;
|
||||
for (let i = 0; i < 400; i += 1) {
|
||||
source.tick(0.1);
|
||||
peak = Math.max(peak, source.current().states[0]?.levelDb ?? -60);
|
||||
}
|
||||
assert.ok(peak > -58, `peak ${peak}`);
|
||||
// Alive, and honest about it: `live` is "a deployment answered" and
|
||||
// `synthetic` is "nobody observed this", and both are what the panel shows.
|
||||
assert.equal(source.current().live, false);
|
||||
assert.equal(source.current().synthetic, true);
|
||||
assert.equal(source.current().source, "sim");
|
||||
source.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the seam", () => {
|
||||
it("is nothing at all for an office that declares no devices", () => {
|
||||
const source = createDeviceSource({ declarations: [] });
|
||||
assert.deepEqual(source.current().states, []);
|
||||
assert.equal(source.current().source, "none");
|
||||
source.tick(1);
|
||||
assert.deepEqual(source.current().states, []);
|
||||
});
|
||||
|
||||
it("reads the API when the deployment has a source and the viewer may read it", async (t) => {
|
||||
const body: DevicesBody = {
|
||||
officeId: "hq",
|
||||
devices: [{ id: "mic-1", kind: "mic", powered: true, observedAt: 1, synthetic: true }],
|
||||
observedAt: 1,
|
||||
source: "sim",
|
||||
synthetic: true,
|
||||
ttlSeconds: 5,
|
||||
};
|
||||
const client = createTeraClient({ fetch: deployment({ "/devices": () => json(body) }) });
|
||||
const readings: unknown[] = [];
|
||||
const source = createDeviceSource({
|
||||
declarations: [DECLARATION],
|
||||
client,
|
||||
officeId: "hq",
|
||||
serverHasDevices: true,
|
||||
onReading: (reading) => readings.push(reading),
|
||||
});
|
||||
t.after(() => source.stop());
|
||||
|
||||
// Before anything lands, the panel has instruments to draw rather than an
|
||||
// empty box that would flicker into existence a poll later.
|
||||
assert.equal(source.current().states.length, 1);
|
||||
assert.equal(source.current().live, false);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(source.current().live, true);
|
||||
assert.equal(source.current().states[0]?.powered, true);
|
||||
assert.equal(readings.length, 1);
|
||||
|
||||
// The server's clock, not ours: ticking must not advance a second set of
|
||||
// numbers over the top of a real feed.
|
||||
const before = JSON.stringify(source.current().states);
|
||||
source.tick(5);
|
||||
assert.equal(JSON.stringify(source.current().states), before);
|
||||
});
|
||||
|
||||
it("skips the API entirely when /health said this box has no devices", async (t) => {
|
||||
let asked = 0;
|
||||
const client = createTeraClient({
|
||||
fetch: deployment({
|
||||
"/devices": () => {
|
||||
asked += 1;
|
||||
return json({});
|
||||
},
|
||||
}),
|
||||
});
|
||||
const source = createDeviceSource({
|
||||
declarations: [DECLARATION],
|
||||
client,
|
||||
officeId: "hq",
|
||||
serverHasDevices: false,
|
||||
});
|
||||
t.after(() => source.stop());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(asked, 0);
|
||||
assert.equal(source.current().source, "sim");
|
||||
});
|
||||
|
||||
it("reports a refused command rather than quietly applying it locally", async (t) => {
|
||||
// The one asymmetry between the two strategies, and it is deliberate: on a
|
||||
// real deployment a control that appears to work and changes nothing
|
||||
// anybody else can see is worse than one that says no.
|
||||
const client = createTeraClient({ fetch: deployment({}) });
|
||||
const source = createDeviceSource({
|
||||
declarations: [DECLARATION],
|
||||
client,
|
||||
officeId: "hq",
|
||||
serverHasDevices: true,
|
||||
});
|
||||
// Registered before the assertions, not after them: a watch left running by
|
||||
// a failed assertion keeps its back-off timer alive and hangs the runner
|
||||
// long after the failure it is hiding.
|
||||
t.after(() => source.stop());
|
||||
|
||||
const result = await source.command({ deviceId: "mic-1", op: "power", value: true });
|
||||
assert.equal(result, null);
|
||||
// The instruments are still there, at rest and not live — a deployment that
|
||||
// has stopped answering is not an office that has no hardware in it.
|
||||
assert.equal(source.current().states.length, 1);
|
||||
assert.equal(source.current().states[0]?.powered, false);
|
||||
assert.equal(source.current().live, false);
|
||||
});
|
||||
|
||||
it("refuses a command the declaration does not allow, before spending a request", async (t) => {
|
||||
let asked = 0;
|
||||
const client = createTeraClient({
|
||||
fetch: deployment({
|
||||
"/command": () => {
|
||||
asked += 1;
|
||||
return json({});
|
||||
},
|
||||
}),
|
||||
});
|
||||
const source = createDeviceSource({
|
||||
declarations: [DECLARATION],
|
||||
client,
|
||||
officeId: "hq",
|
||||
serverHasDevices: true,
|
||||
});
|
||||
t.after(() => source.stop());
|
||||
assert.equal(await source.command({ deviceId: "mic-1", op: "volume", value: 0.5 }), null);
|
||||
assert.equal(await source.command({ deviceId: "somebody-elses", op: "power", value: true }), null);
|
||||
assert.equal(asked, 0);
|
||||
});
|
||||
|
||||
it("applies a command locally, and openly, on the simulated strategy", async () => {
|
||||
const source = createDeviceSource({ declarations: [DECLARATION], client: null });
|
||||
const state = await source.command({ deviceId: "mic-1", op: "power", value: true });
|
||||
assert.equal(state?.powered, true);
|
||||
assert.equal(state?.synthetic, true);
|
||||
source.stop();
|
||||
});
|
||||
|
||||
it("publishes only when a reading a viewer could see has changed", () => {
|
||||
const readings: unknown[] = [];
|
||||
const source = createDeviceSource({
|
||||
declarations: [DECLARATION],
|
||||
client: null,
|
||||
onReading: (reading) => readings.push(reading),
|
||||
});
|
||||
// Powered off, so every step produces the same floor reading and only
|
||||
// `observedAt` moves — which is deliberately not in the signature.
|
||||
for (let i = 0; i < 50; i += 1) source.tick(0.1);
|
||||
assert.equal(readings.length, 0);
|
||||
|
||||
void source.command({ deviceId: "mic-1", op: "power", value: true });
|
||||
source.tick(0.1);
|
||||
assert.ok(readings.length > 0);
|
||||
source.stop();
|
||||
});
|
||||
|
||||
it("does no work at all once stopped", async () => {
|
||||
const source = createDeviceSource({ declarations: [DECLARATION], client: null });
|
||||
void source.command({ deviceId: "mic-1", op: "power", value: true });
|
||||
for (let i = 0; i < 20; i += 1) source.tick(0.1);
|
||||
source.stop();
|
||||
const frozen = JSON.stringify(source.current().states);
|
||||
for (let i = 0; i < 20; i += 1) source.tick(0.1);
|
||||
assert.equal(JSON.stringify(source.current().states), frozen);
|
||||
assert.equal(await source.command({ deviceId: "mic-1", op: "mute", value: true }), null);
|
||||
});
|
||||
|
||||
it("survives a tab that was backgrounded for ten minutes", () => {
|
||||
const source = createDeviceSource({ declarations: [DECLARATION], client: null });
|
||||
void source.command({ deviceId: "mic-1", op: "power", value: true });
|
||||
// Six hundred seconds of `dt` would be six thousand steps in one frame.
|
||||
source.tick(600);
|
||||
// Still a valid reading, and it arrived without a hitch.
|
||||
const level = source.current().states[0]?.levelDb ?? 0;
|
||||
assert.ok(level >= -60 && level <= 0, `${level}`);
|
||||
});
|
||||
|
||||
it("has a null source for anything that genuinely has nothing to say", () => {
|
||||
const source = createNullDeviceSource();
|
||||
assert.deepEqual(source.current().states, []);
|
||||
assert.equal(source.current().source, "none");
|
||||
assert.equal(source.current().synthetic, true);
|
||||
source.tick(1);
|
||||
source.refresh();
|
||||
source.setOccupancy(["desk-01"]);
|
||||
source.stop();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,16 @@ describe("freeway world v2", () => {
|
||||
));
|
||||
});
|
||||
|
||||
/**
|
||||
* The counts below changed when `structures.ts` started merging per material.
|
||||
*
|
||||
* They used to read 8 lane-dash meshes, 4 median barriers and 4 guardrails —
|
||||
* two corridors' worth of two sides each — and every one of those was a draw
|
||||
* call. Merging collapses each class to one mesh, so counting meshes no longer
|
||||
* says anything about how many corridors were built. The assertions therefore
|
||||
* moved down a level: **one** mesh per class, and its vertex count proves all
|
||||
* four spans went into it, which is the fact the old count was standing in for.
|
||||
*/
|
||||
it("renders separated decks, markings, barriers, signs, and batched scenery", () => {
|
||||
const world = {
|
||||
city: CALIFORNIA_CITY,
|
||||
@@ -42,11 +52,57 @@ describe("freeway world v2", () => {
|
||||
assert.equal(group.userData.planSeed, 101_005);
|
||||
const names: string[] = [];
|
||||
group.traverse((object) => names.push(object.name));
|
||||
assert.equal(names.filter((name) => name === "freeway:lane-dashes").length, 8);
|
||||
assert.equal(names.filter((name) => name === "freeway:median-barrier").length, 4);
|
||||
assert.equal(names.filter((name) => name === "freeway:outer-guardrail").length, 4);
|
||||
assert.equal(names.filter((name) => name === "freeway:lane-dashes").length, 1);
|
||||
assert.equal(names.filter((name) => name === "freeway:median-barrier").length, 1);
|
||||
assert.equal(names.filter((name) => name === "freeway:outer-guardrail").length, 1);
|
||||
assert.ok(names.includes("freeway:sign:101"));
|
||||
assert.ok(names.includes("freeway:sign:5"));
|
||||
assert.ok(group.children.filter((child) => child instanceof THREE.InstancedMesh).length >= 7);
|
||||
assert.ok(group.children.filter((child) => child instanceof THREE.InstancedMesh).length >= 6);
|
||||
|
||||
// Four spans really did go into each of those single meshes. A merge that
|
||||
// silently dropped a bucket — mismatched attributes are the way that
|
||||
// happens — would leave one span's worth of vertices here and look fine.
|
||||
const meshFor = (name: string): THREE.Mesh => {
|
||||
const found = group.children.find(
|
||||
(child): child is THREE.Mesh => child instanceof THREE.Mesh && child.name === name,
|
||||
);
|
||||
assert.ok(found, `expected a merged mesh named ${name}`);
|
||||
return found;
|
||||
};
|
||||
const dashVertices = meshFor("freeway:lane-dashes").geometry.getAttribute("position").count;
|
||||
const guardVertices = meshFor("freeway:outer-guardrail").geometry.getAttribute("position").count;
|
||||
assert.ok(dashVertices > 400, `lane dashes merged to only ${dashVertices} vertices`);
|
||||
assert.ok(guardVertices > 400, `guardrails merged to only ${guardVertices} vertices`);
|
||||
});
|
||||
|
||||
it("shares one material per colour instead of one per ribbon", () => {
|
||||
const world = {
|
||||
city: CALIFORNIA_CITY,
|
||||
project(lat: number, lng: number): [number, number] {
|
||||
return [(lng + 121) * 20, -(lat - 36) * 20];
|
||||
},
|
||||
groundAt(): number {
|
||||
return 0;
|
||||
},
|
||||
} as unknown as World;
|
||||
const group = createFreewayWorld(world, CALIFORNIA_TRANSPORT);
|
||||
|
||||
let drawCalls = 0;
|
||||
const materials = new Set<THREE.Material>();
|
||||
group.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
drawCalls += 1;
|
||||
const material = object.material;
|
||||
if (Array.isArray(material)) for (const m of material) materials.add(m);
|
||||
else materials.add(material);
|
||||
});
|
||||
|
||||
// Was 59 meshes: eighteen road ribbons, eight dash strips, four guardrails,
|
||||
// four medians, nine two-mesh sign groups and seven instanced batches, each
|
||||
// ribbon carrying a material minted on the spot. Twenty-five is generous
|
||||
// headroom over the twenty it actually emits, and it fails loudly if anyone
|
||||
// reintroduces a `new THREE.Mesh` inside the corridor loop.
|
||||
assert.ok(drawCalls <= 25, `freeway world emits ${drawCalls} draw calls`);
|
||||
assert.ok(materials.size <= 20, `freeway world holds ${materials.size} materials`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* The public package surface, and the one promise it makes.
|
||||
*
|
||||
* `src/index.ts` is what `package.json`'s `"."` export points at, so it is the
|
||||
* thing a verifier, a training harness or a Node service `import`s. The promise
|
||||
* is that everything reachable from it runs **with no renderer, no DOM and no
|
||||
* network** — because the consumers who want the arena and the simulators are
|
||||
* precisely the consumers who have none of the three.
|
||||
*
|
||||
* That is not a property you can assert by importing the file and seeing it
|
||||
* work: `import * as THREE from "three"` succeeds perfectly well under Node and
|
||||
* costs a consumer half a megabyte for nothing. So this walks the static import
|
||||
* graph and reads it. A single `from "three"` anywhere in the closure fails,
|
||||
* and names the file and the chain that reached it, which is the only form of
|
||||
* this failure anybody can act on.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import * as barrel from "../../index.ts";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const ENTRY = path.join(ROOT, "src/index.ts");
|
||||
|
||||
/**
|
||||
* Every relative specifier in a file, `import` and `export` alike.
|
||||
*
|
||||
* `export * from "./x.ts"` is the form the barrel itself is written in, and a
|
||||
* matcher that only looked at `import` would walk none of it.
|
||||
*/
|
||||
const SPECIFIER = /(?:^|\n)\s*(?:import|export)\b[^;\n]*?from\s+["']([^"']+)["']/g;
|
||||
/** `await import("./x.ts")`, which is how a renderer would sneak in lazily. */
|
||||
const DYNAMIC = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
|
||||
|
||||
/** Walk the graph from `entry`, returning every file reached and how. */
|
||||
function closure(entry: string): Map<string, string[]> {
|
||||
const reached = new Map<string, string[]>([[entry, []]]);
|
||||
const queue = [entry];
|
||||
while (queue.length > 0) {
|
||||
const file = queue.shift() as string;
|
||||
const source = readFileSync(file, "utf8");
|
||||
const chain = reached.get(file) ?? [];
|
||||
for (const pattern of [SPECIFIER, DYNAMIC]) {
|
||||
pattern.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
const specifier = match[1];
|
||||
if (specifier === undefined || !specifier.startsWith(".")) continue;
|
||||
const resolved = path.resolve(path.dirname(file), specifier);
|
||||
if (reached.has(resolved)) continue;
|
||||
reached.set(resolved, [...chain, path.relative(ROOT, file)]);
|
||||
queue.push(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
return reached;
|
||||
}
|
||||
|
||||
/** Bare specifiers a module in the closure is allowed to depend on. */
|
||||
const ALLOWED_PACKAGES = new Set<string>([]);
|
||||
|
||||
test("the barrel exports the renderer-independent surfaces this build added", () => {
|
||||
for (const name of [
|
||||
"ARENA_ENVIRONMENTS",
|
||||
"flattenObservation",
|
||||
"structureAction",
|
||||
"observationWidth",
|
||||
"rollout",
|
||||
"createSimulatedDevices",
|
||||
"createSimulatedVehicleTelemetry",
|
||||
"normalizeDeviceCommand",
|
||||
"normalizeVehicleTelemetryCommand",
|
||||
"exteriorVehicleAppearance",
|
||||
"DEVICE_RANGES",
|
||||
"Plan",
|
||||
]) {
|
||||
assert.ok(
|
||||
name in barrel,
|
||||
`src/index.ts no longer exports ${name}; a consumer's import just broke`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("nothing reachable from the barrel imports three.js", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const [file, chain] of closure(ENTRY)) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
if (/from\s+["']three(?:\/|["'])/.test(source) || /import\s*\(\s*["']three/.test(source)) {
|
||||
offenders.push(`${path.relative(ROOT, file)} (via ${chain.join(" → ") || "the barrel itself"})`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
"three.js is on the public package surface. The render layer for a simulation " +
|
||||
"(interiors/devices.ts, engine/officeExterior.ts, interiors/officeScene.ts) is " +
|
||||
"never exported; the state machine behind it is.",
|
||||
);
|
||||
});
|
||||
|
||||
test("nothing reachable from the barrel takes a bare dependency at all", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const [file] of closure(ENTRY)) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
for (const pattern of [SPECIFIER, DYNAMIC]) {
|
||||
pattern.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
const specifier = match[1];
|
||||
if (specifier === undefined) continue;
|
||||
if (specifier.startsWith(".") || specifier.startsWith("node:")) continue;
|
||||
if (ALLOWED_PACKAGES.has(specifier)) continue;
|
||||
offenders.push(`${path.relative(ROOT, file)} → ${specifier}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, [], "an unvetted runtime dependency reached the package surface");
|
||||
});
|
||||
|
||||
test("nothing reachable from the barrel reaches for a browser global", () => {
|
||||
// Read as source rather than executed, because a `document` reference inside a
|
||||
// branch nobody takes is still a module that cannot be loaded in a worker
|
||||
// whose global object does not have one.
|
||||
const globals = /\b(?:document|localStorage|sessionStorage|navigator|requestAnimationFrame)\b/;
|
||||
const offenders: string[] = [];
|
||||
for (const [file] of closure(ENTRY)) {
|
||||
const source = readFileSync(file, "utf8")
|
||||
// Comments talk about the DOM constantly and correctly; only code counts.
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|\n)\s*\/\/[^\n]*/g, "$1");
|
||||
if (globals.test(source)) offenders.push(path.relative(ROOT, file));
|
||||
}
|
||||
assert.deepEqual(offenders, [], "a DOM global is reachable from the package surface");
|
||||
});
|
||||
@@ -0,0 +1,652 @@
|
||||
/**
|
||||
* The wiring seams, tested where they can actually be exercised.
|
||||
*
|
||||
* `createOfficeScene` is the one scene in this repo that can be built under
|
||||
* `node --test`: it needs a DOM element and a `window` for `OrbitControls` and
|
||||
* `matchMedia`, and nothing else — no WebGL context, no Worker, no heightfield.
|
||||
* So the office half of the integration surface is tested for real, against the
|
||||
* shipped packs, rather than by reading the source and hoping.
|
||||
*
|
||||
* The city half is not, and the asymmetry is honest rather than lazy:
|
||||
* `createScene` awaits a terrain Worker and takes a live `Stage`, so the only
|
||||
* place it can be exercised is a browser. `scripts/ui-smoke.mjs` is where that
|
||||
* happens, and the two source-level assertions at the bottom of this file are
|
||||
* the belt to that brace — they fail if the seam is *deleted*, which is the
|
||||
* failure a browser test is slowest to tell you about.
|
||||
*
|
||||
* The renderer is a fake of the same specific kind `src/test/render` uses:
|
||||
* `PMREMGenerator` never touches WebGL directly, so stubbing `render` and
|
||||
* `setRenderTarget` runs the real generator, the real target allocation and the
|
||||
* real blur chain and leaves only the pixels unwritten.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
|
||||
// ---- The environment the browser supplies and Node does not ----------------
|
||||
//
|
||||
// Installed before the modules are imported, because `scenekit.ts` reads
|
||||
// `window.matchMedia` at construction and `officeScene.ts` reaches it through a
|
||||
// static import chain. The dynamic imports below are what let this run first.
|
||||
|
||||
(globalThis as unknown as { window: unknown }).window = {
|
||||
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||||
innerWidth: 1_200,
|
||||
innerHeight: 800,
|
||||
devicePixelRatio: 1,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
|
||||
const { createOfficeScene } = await import("../../interiors/officeScene.ts");
|
||||
const { createEnvironmentRig } = await import("../../engine/environmentRig.ts");
|
||||
const LUMBRIDGE_HQ = (await import("../../offices/lumbridge-hq.ts")).default;
|
||||
const MATEO_COURT = (await import("../../offices/mateo-court.ts")).default;
|
||||
const { initialDeviceState } = await import("../../devices/types.ts");
|
||||
const { createSimulatedVehicleTelemetry } = await import("../../transport/vehicleTelemetry.ts");
|
||||
|
||||
type Office = typeof LUMBRIDGE_HQ;
|
||||
|
||||
const PACKS: readonly (readonly [string, Office])[] = [
|
||||
["lumbridge-hq", LUMBRIDGE_HQ],
|
||||
["mateo-court", MATEO_COURT],
|
||||
];
|
||||
|
||||
// ---- Fakes -----------------------------------------------------------------
|
||||
|
||||
/** Everything `OrbitControls` and `SceneKit` touch on the canvas, and no more. */
|
||||
function fakeDom(): HTMLElement {
|
||||
return {
|
||||
style: {},
|
||||
clientWidth: 1_200,
|
||||
clientHeight: 800,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
setPointerCapture() {},
|
||||
releasePointerCapture() {},
|
||||
getBoundingClientRect: () => ({
|
||||
left: 0, top: 0, width: 1_200, height: 800, right: 1_200, bottom: 800, x: 0, y: 0,
|
||||
}),
|
||||
getRootNode: () => ({ addEventListener() {}, removeEventListener() {} }),
|
||||
ownerDocument: { addEventListener() {}, removeEventListener() {} },
|
||||
} as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
interface FakeRenderer {
|
||||
renders: number;
|
||||
targets: Set<THREE.WebGLRenderTarget>;
|
||||
as(): THREE.WebGLRenderer;
|
||||
}
|
||||
|
||||
/** A renderer that allocates render targets and never draws to them. */
|
||||
function fakeRenderer(): FakeRenderer {
|
||||
const targets = new Set<THREE.WebGLRenderTarget>();
|
||||
const state: FakeRenderer = {
|
||||
renders: 0,
|
||||
targets,
|
||||
as: () => stub as unknown as THREE.WebGLRenderer,
|
||||
};
|
||||
const stub = {
|
||||
autoClear: true,
|
||||
toneMapping: THREE.NoToneMapping,
|
||||
xr: { enabled: false },
|
||||
state: { buffers: { depth: { getReversed: () => false } } },
|
||||
getRenderTarget: () => null,
|
||||
getActiveCubeFace: () => 0,
|
||||
getActiveMipmapLevel: () => 0,
|
||||
getClearColor: (target: THREE.Color) => target,
|
||||
getClearAlpha: () => 1,
|
||||
setClearColor: () => {},
|
||||
setClearAlpha: () => {},
|
||||
clearDepth: () => {},
|
||||
compile: () => {},
|
||||
setRenderTarget(target: THREE.WebGLRenderTarget | null) {
|
||||
if (target) targets.add(target);
|
||||
},
|
||||
render() {
|
||||
state.renders += 1;
|
||||
},
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Two aeroplanes over the studio, at bearings this test can predict. */
|
||||
function fakeFlights(site: NonNullable<Office["site"]>) {
|
||||
let polls = 0;
|
||||
return {
|
||||
interval: 1,
|
||||
polls: () => polls,
|
||||
poll() {
|
||||
polls += 1;
|
||||
return [
|
||||
// Due north of the site and high: comfortably above the elevation floor.
|
||||
{ id: "north", lat: site.lat + 0.05, lng: site.lng, altitude: 6_000, heading: 180 },
|
||||
// Due east and equally high.
|
||||
{ id: "east", lat: site.lat, lng: site.lng + 0.05, altitude: 6_000, heading: 270 },
|
||||
// On the deck a long way off: below the floor, and must not be drawn.
|
||||
{ id: "below", lat: site.lat + 2, lng: site.lng, altitude: 100, heading: 0 },
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function build(pack: Office, extra: Record<string, unknown> = {}) {
|
||||
const renderer = fakeRenderer();
|
||||
const rig = createEnvironmentRig(renderer.as());
|
||||
const scene = createOfficeScene(pack, {
|
||||
dom: fakeDom(),
|
||||
depth: "public",
|
||||
// `low` is flat Lambert with no textures: it builds the same graph and the
|
||||
// same materials-per-role decisions, in a fraction of the canvas work.
|
||||
quality: "low",
|
||||
environment: rig,
|
||||
exteriorVehicle: { detail: "corridor", seed: 7 },
|
||||
...(pack.site ? { horizon: { drop: pack.site.elevation } } : {}),
|
||||
...extra,
|
||||
});
|
||||
return { scene, rig, renderer };
|
||||
}
|
||||
|
||||
function named(root: THREE.Object3D, name: string): THREE.Object3D | null {
|
||||
return root.children.find((child) => child.name === name) ?? null;
|
||||
}
|
||||
|
||||
// ---- The environment rig ---------------------------------------------------
|
||||
|
||||
for (const [id, pack] of PACKS) {
|
||||
test(`${id}: createOfficeScene puts a real environment texture on the scene`, () => {
|
||||
const { scene, rig, renderer } = build(pack);
|
||||
try {
|
||||
assert.ok(
|
||||
scene.scene.environment instanceof THREE.Texture,
|
||||
"the office must have an environment before its first frame, or every metal " +
|
||||
"role in the room renders as grey plastic",
|
||||
);
|
||||
assert.equal(scene.scene.environmentIntensity, 1);
|
||||
assert.ok(renderer.renders > 0, "the PMREM blur chain never ran");
|
||||
} finally {
|
||||
scene.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${id}: disposing the scene releases it from the rig's ledger`, () => {
|
||||
const { scene, rig } = build(pack);
|
||||
try {
|
||||
assert.ok(scene.scene.environment !== null);
|
||||
scene.dispose();
|
||||
assert.equal(
|
||||
scene.scene.environment,
|
||||
null,
|
||||
"a disposed scene left holding an environment is a disposed scene the rig " +
|
||||
"still has a strong reference to — one whole floor plate leaked per switch",
|
||||
);
|
||||
} finally {
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("a rebuilt environment does not reach a scene that has been released", () => {
|
||||
const { scene, rig } = build(LUMBRIDGE_HQ);
|
||||
const second = createOfficeScene(MATEO_COURT, {
|
||||
dom: fakeDom(), depth: "public", quality: "low", environment: rig,
|
||||
});
|
||||
try {
|
||||
scene.dispose();
|
||||
// A different sun, so the rig's coarse fingerprint moves and it rebuilds.
|
||||
second.setLighting({
|
||||
sun: { direction: [-0.7, 0.2, 0.1], color: 0xffb070, intensity: 0.6 },
|
||||
hemisphere: { sky: 0x30435c, ground: 0x201a14, intensity: 0.5 },
|
||||
ambient: { color: 0xffffff, intensity: 0.1 },
|
||||
sky: null,
|
||||
fog: null,
|
||||
});
|
||||
assert.equal(scene.scene.environment, null, "the released scene was written to again");
|
||||
assert.ok(second.scene.environment instanceof THREE.Texture);
|
||||
} finally {
|
||||
second.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- The device layer ------------------------------------------------------
|
||||
|
||||
for (const [id, pack] of PACKS) {
|
||||
test(`${id}: the authored hardware is mounted and offered to the panel`, () => {
|
||||
const { scene, rig } = build(pack);
|
||||
try {
|
||||
const declared = pack.levels.flatMap((level) => level.floorplan.devices ?? []);
|
||||
assert.ok(declared.length > 0, `${id} authors no devices, so this test proves nothing`);
|
||||
assert.deepEqual(
|
||||
[...scene.devices].map((d) => d.id).sort(),
|
||||
[...declared].map((d) => d.id).sort(),
|
||||
"every declaration this pack authored should resolve against its own plan",
|
||||
);
|
||||
const layer = named(scene.scene, "devices");
|
||||
assert.ok(layer !== null, "no device layer in the scene graph");
|
||||
assert.equal(
|
||||
layer.children.length,
|
||||
declared.length,
|
||||
"one mount per resolved declaration",
|
||||
);
|
||||
} finally {
|
||||
scene.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("a device reading reaches the indicator on the hardware", () => {
|
||||
const { scene, rig } = build(LUMBRIDGE_HQ);
|
||||
try {
|
||||
const declaration = scene.devices[0];
|
||||
assert.ok(declaration, "lumbridge-hq authors at least one device");
|
||||
const indicators = (): THREE.Material[] => {
|
||||
const found: THREE.Material[] = [];
|
||||
named(scene.scene, "devices")?.traverse((object) => {
|
||||
if (object.name === "indicator") {
|
||||
object.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh;
|
||||
if (mesh.isMesh && !Array.isArray(mesh.material)) found.push(mesh.material);
|
||||
});
|
||||
}
|
||||
});
|
||||
return found;
|
||||
};
|
||||
|
||||
scene.setDeviceStates([{ ...initialDeviceState(declaration, 0), powered: false }]);
|
||||
const off = indicators().map((m) => (m as THREE.MeshStandardMaterial).color.getHex());
|
||||
scene.setDeviceStates([{ ...initialDeviceState(declaration, 0), powered: true, muted: false }]);
|
||||
const on = indicators().map((m) => (m as THREE.MeshStandardMaterial).color.getHex());
|
||||
|
||||
assert.ok(off.length > 0, "the device assets carry no indicator meshes");
|
||||
assert.notDeepEqual(on, off, "powering a device changed nothing anybody could see");
|
||||
} finally {
|
||||
scene.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- The exterior ----------------------------------------------------------
|
||||
|
||||
for (const [id, pack] of PACKS) {
|
||||
test(`${id}: a Model X is parked on the pack's arrival stall`, () => {
|
||||
const arrival = pack.site?.arrival;
|
||||
assert.ok(arrival, `${id} authors no arrival anchor`);
|
||||
const { scene, rig } = build(pack);
|
||||
try {
|
||||
const exterior = named(scene.scene, "office-exterior");
|
||||
assert.ok(exterior !== null, "no exterior in the scene graph");
|
||||
assert.equal(exterior.userData.arrivalKind, "vehicle-stall");
|
||||
|
||||
exterior.updateMatrixWorld(true);
|
||||
let car: THREE.Object3D | null = null;
|
||||
exterior.traverse((object) => {
|
||||
if (object.userData.vehicleModel === "model-x") car = object;
|
||||
});
|
||||
assert.ok(car !== null, "the apron was built without a car on it");
|
||||
|
||||
const at = (car as THREE.Object3D).getWorldPosition(new THREE.Vector3());
|
||||
const floorY = scene.plan.level(arrival.levelId)?.floorY ?? 0;
|
||||
assert.ok(
|
||||
Math.hypot(at.x - arrival.position.x, at.z - arrival.position.z) <= 0.5,
|
||||
`car at ${at.x.toFixed(2)}, ${at.z.toFixed(2)} against stall ` +
|
||||
`${arrival.position.x}, ${arrival.position.z}`,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(at.y - floorY) <= 1.5,
|
||||
`the apron must stand on the floor of ${arrival.levelId} (${floorY} m), not at the ` +
|
||||
`plan origin — a podium deck is the whole reason the stall names a storey`,
|
||||
);
|
||||
} finally {
|
||||
scene.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("vehicle telemetry can be pushed every frame without moving anything twice", () => {
|
||||
const { scene, rig } = build(LUMBRIDGE_HQ);
|
||||
const source = createSimulatedVehicleTelemetry({
|
||||
seed: 3, fixedStepSeconds: 0.5, ambientC: 19,
|
||||
});
|
||||
try {
|
||||
// Idempotent by contract: `apply` is signature-guarded, so the frames that
|
||||
// spend no simulated step must cost a comparison and change nothing.
|
||||
for (let i = 0; i < 20; i += 1) scene.setVehicleTelemetry(source.current());
|
||||
source.command({ op: "charge", value: true });
|
||||
for (let i = 0; i < 40; i += 1) source.stepFixed();
|
||||
scene.setVehicleTelemetry(source.current());
|
||||
assert.ok(source.current().pluggedIn, "the command never reached the simulator");
|
||||
} finally {
|
||||
scene.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Overhead traffic ------------------------------------------------------
|
||||
|
||||
test("the studio sky carries the same aircraft the board outside is drawing", async () => {
|
||||
const site = MATEO_COURT.site;
|
||||
assert.ok(site, "mateo-court is sited");
|
||||
const flights = fakeFlights(site);
|
||||
const { scene, rig } = build(MATEO_COURT, { flights });
|
||||
try {
|
||||
const layer = named(scene.scene, "overhead-traffic");
|
||||
assert.ok(layer !== null, "a sited office with a flight source drew no sky traffic");
|
||||
const mesh = layer.children[0] as THREE.InstancedMesh;
|
||||
assert.ok(mesh.isInstancedMesh, "overhead traffic must be one draw call, not one per track");
|
||||
assert.equal(mesh.count, 0, "nothing is drawn before the first poll lands");
|
||||
|
||||
scene.tick(2, 2);
|
||||
// The poll is a promise even for a synchronous source, so let the microtask
|
||||
// queue drain before reading what it placed.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(flights.polls(), 1, "the office must poll the source it was handed");
|
||||
assert.equal(
|
||||
mesh.count,
|
||||
2,
|
||||
"the aeroplane below the elevation floor was drawn through the ground",
|
||||
);
|
||||
|
||||
const centre = scene.plan.bounds.center;
|
||||
const matrix = new THREE.Matrix4();
|
||||
const north = new THREE.Vector3();
|
||||
mesh.getMatrixAt(0, matrix);
|
||||
north.setFromMatrixPosition(matrix);
|
||||
const east = new THREE.Vector3();
|
||||
mesh.getMatrixAt(1, matrix);
|
||||
east.setFromMatrixPosition(matrix);
|
||||
|
||||
/*
|
||||
* The building's own frame: `site.heading` is the bearing its −Z points
|
||||
* along, so an aeroplane due north of a north-facing pack lands at −Z. Both
|
||||
* shipped packs are rotated, so the test asserts the *relationship* rather
|
||||
* than a literal axis — the two tracks are ninety degrees apart on the
|
||||
* compass and must be ninety degrees apart on the dome.
|
||||
*/
|
||||
const bearingOf = (at: THREE.Vector3) =>
|
||||
Math.atan2(at.x - centre.x, -(at.z - centre.z)) * 180 / Math.PI;
|
||||
const separation = Math.abs(((bearingOf(east) - bearingOf(north) + 540) % 360) - 180);
|
||||
assert.ok(
|
||||
Math.abs(separation - 90) < 2,
|
||||
`two tracks 90° apart on the compass came out ${separation.toFixed(1)}° apart on the dome`,
|
||||
);
|
||||
assert.ok(north.y > 0, "an aeroplane above the horizon must be above the floor plate");
|
||||
} finally {
|
||||
scene.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("an office with no site draws no sky traffic and asks the feed nothing", () => {
|
||||
const site = LUMBRIDGE_HQ.site;
|
||||
assert.ok(site);
|
||||
const flights = fakeFlights(site);
|
||||
const { site: _site, ...unsited } = LUMBRIDGE_HQ;
|
||||
const renderer = fakeRenderer();
|
||||
const rig = createEnvironmentRig(renderer.as());
|
||||
const scene = createOfficeScene(unsited as typeof LUMBRIDGE_HQ, {
|
||||
dom: fakeDom(), depth: "public", quality: "low", environment: rig, flights,
|
||||
});
|
||||
try {
|
||||
assert.equal(named(scene.scene, "overhead-traffic"), null);
|
||||
scene.tick(2, 2);
|
||||
assert.equal(
|
||||
flights.polls(),
|
||||
0,
|
||||
"a pack with no coordinate has no bearing to put an aeroplane on, so it must " +
|
||||
"not be spending a request to find out where one is",
|
||||
);
|
||||
} finally {
|
||||
scene.dispose();
|
||||
rig.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- The clickable aircraft ------------------------------------------------
|
||||
|
||||
const { createFlightLayer, aircraftDetail } = await import("../../engine/flights.ts");
|
||||
|
||||
/**
|
||||
* Enough of a `World` for the flight layer: a projection and a vertical scale.
|
||||
*
|
||||
* The real one owns a 0.53M-point heightfield and two and a half seconds of
|
||||
* build, none of which this layer touches — it projects a coordinate and scales
|
||||
* an altitude, and that is the whole of its contact with the world.
|
||||
*/
|
||||
const flatWorld = {
|
||||
project: (lat: number, lng: number) => [lng * 100, -lat * 100],
|
||||
metres: (m: number) => m / 100,
|
||||
metresPerUnit: 100,
|
||||
} as unknown as Parameters<typeof createFlightLayer>[0];
|
||||
|
||||
test("every aeroplane on the board is a pick target that knows which one it is", () => {
|
||||
const layer = createFlightLayer(flatWorld);
|
||||
try {
|
||||
assert.equal(layer.pickables.length, 0, "nothing to click before the first observation");
|
||||
|
||||
layer.update([
|
||||
{ id: "a1b2c3", lat: 37.6, lng: -122.4, altitude: 9_000, heading: 310, callsign: "UAL221" },
|
||||
{ id: "~ddeeff", lat: 37.8, lng: -122.2, altitude: 2_400, heading: 90 },
|
||||
]);
|
||||
assert.equal(layer.pickables.length, 2);
|
||||
assert.deepEqual(
|
||||
layer.pickables.map((object) => object.userData.aircraftId).sort(),
|
||||
["a1b2c3", "~ddeeff"],
|
||||
"a raycast hit resolves to an aeroplane through this and nothing else",
|
||||
);
|
||||
for (const object of layer.pickables) {
|
||||
assert.ok(layer.group.children.includes(object), "a pick target that is not drawn");
|
||||
}
|
||||
} finally {
|
||||
layer.dispose();
|
||||
assert.equal(layer.pickables.length, 0, "dispose must empty the target list too");
|
||||
}
|
||||
});
|
||||
|
||||
test("the card an anonymous visitor gets says what was observed and what was not", () => {
|
||||
// The simulator's ids are route names, not transponder addresses, and
|
||||
// `aircraftDetail` must decline to present one as the other.
|
||||
const invented = aircraftDetail(
|
||||
{ id: "sfo-departure-2", lat: 37.62, lng: -122.38, altitude: 3_000, heading: 300 },
|
||||
{ observed: false },
|
||||
);
|
||||
assert.equal(invented.icao24, null, "a route name is not a Mode S address");
|
||||
assert.equal(invented.observed, false);
|
||||
|
||||
const real = aircraftDetail(
|
||||
{ id: "a1b2c3", lat: 37.62, lng: -122.38, altitude: 3_000, heading: 300, callsign: "UAL221" },
|
||||
{ observed: true, attribution: ["Data: adsb.lol contributors"] },
|
||||
);
|
||||
assert.equal(real.icao24, "a1b2c3");
|
||||
assert.deepEqual(real.attribution, ["Data: adsb.lol contributors"]);
|
||||
});
|
||||
|
||||
// ---- The daytime sky -------------------------------------------------------
|
||||
|
||||
const { createSatelliteLayer } = await import("../../engine/satellites.ts");
|
||||
const { createStarlinkMeshLayer } = await import("../../engine/starlinkMesh.ts");
|
||||
const { nightFactor } = await import("../../engine/atmosphere.ts");
|
||||
|
||||
/** One satellite high overhead and sunlit, which is the case that clipped white. */
|
||||
function overheadFix() {
|
||||
return {
|
||||
noradId: 25_544,
|
||||
name: "SMOKE-1",
|
||||
group: "starlink" as const,
|
||||
azimuth: 0,
|
||||
elevation: Math.PI / 3,
|
||||
rangeKm: 600,
|
||||
// Full sunlight, which is the brightest a dot ever gets and therefore the
|
||||
// case that clipped.
|
||||
shadow: 0,
|
||||
};
|
||||
}
|
||||
|
||||
test("the satellite dots are not drawn into a daytime sky", () => {
|
||||
const layer = createSatelliteLayer(400);
|
||||
try {
|
||||
const points = layer.group.children[0] as THREE.Points;
|
||||
const alphaAt = () =>
|
||||
(points.geometry.getAttribute("color") as THREE.BufferAttribute).getW(0);
|
||||
|
||||
layer.setSkyDarkness(1);
|
||||
layer.update([overheadFix()]);
|
||||
const night = alphaAt();
|
||||
assert.ok(night > 0.1, "a sunlit satellite at 60° must be visible at night");
|
||||
assert.ok(layer.group.visible, "the layer must draw at night");
|
||||
|
||||
// `nightFactor` in degrees: the sun at +44°, which is where the California
|
||||
// board stood in the screenshot that named this defect.
|
||||
layer.setSkyDarkness(nightFactor(44));
|
||||
layer.update([overheadFix()]);
|
||||
assert.equal(
|
||||
alphaAt(),
|
||||
0,
|
||||
"live defect 1: additive white dots over a sun at +44° clip to hard white " +
|
||||
"squares, and they were the first thing anybody saw on this board",
|
||||
);
|
||||
assert.equal(layer.group.visible, false, "and the draw is skipped entirely");
|
||||
} finally {
|
||||
layer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("a god who turns the sky on at noon still does not get white squares", () => {
|
||||
const layer = createSatelliteLayer(400);
|
||||
const meshes = createStarlinkMeshLayer({ boardRadius: 400 });
|
||||
try {
|
||||
for (const target of [layer, meshes]) {
|
||||
target.setSkyDarkness(nightFactor(44));
|
||||
target.setVisible(true);
|
||||
assert.equal(
|
||||
target.group.visible,
|
||||
false,
|
||||
"the hour outranks the switch: both write `group.visible`, and the sky wins",
|
||||
);
|
||||
target.setSkyDarkness(1);
|
||||
assert.equal(target.group.visible, true, "and night gives it straight back");
|
||||
target.setVisible(false);
|
||||
assert.equal(target.group.visible, false);
|
||||
}
|
||||
} finally {
|
||||
layer.dispose();
|
||||
meshes.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("the water reflects rather than absorbing, now that there is a sky to reflect", async () => {
|
||||
// Comments here name the material that was replaced, and correctly; only
|
||||
// code counts, exactly as `barrel.test.ts` reasons about DOM globals.
|
||||
const source = readFileSync(path.join(ROOT, "src/engine/terrain.ts"), "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|\n)\s*\/\/[^\n]*/g, "$1");
|
||||
const water = source.slice(source.indexOf("function createWater"));
|
||||
assert.ok(
|
||||
!/MeshLambertMaterial/.test(water.slice(0, water.indexOf("\n}"))),
|
||||
"Lambert has no specular term at all, which is why half the California board " +
|
||||
"rendered as one flat blue value at every hour and from every angle",
|
||||
);
|
||||
assert.ok(
|
||||
/MeshStandardMaterial\(\{ color: pal\.sea, roughness: 0\.14, metalness: 0 \}\)/.test(source),
|
||||
"the sea must be a low-roughness dielectric, so it takes both the sun's glint " +
|
||||
"and `scene.environment`",
|
||||
);
|
||||
});
|
||||
|
||||
// ---- The seams that only the source can prove ------------------------------
|
||||
|
||||
const MAIN = readFileSync(path.join(ROOT, "src/main.ts"), "utf8");
|
||||
|
||||
test("main.ts makes exactly one call into the chrome, and writes no visibility itself", () => {
|
||||
const applies = [...MAIN.matchAll(/chrome\?\.apply\(chromeState\(/g)].length;
|
||||
assert.equal(
|
||||
applies,
|
||||
1,
|
||||
"the whole point of `ui/chromeState.ts` is that there is one call site; " +
|
||||
`found ${applies}`,
|
||||
);
|
||||
assert.equal(
|
||||
[...MAIN.matchAll(/style\.display/g)].length,
|
||||
0,
|
||||
"a visibility decision came back into main.ts",
|
||||
);
|
||||
/*
|
||||
* The two control bars and their ~100 lines of CSS are gone from index.html,
|
||||
* and these were the last references to elements that no longer exist.
|
||||
*
|
||||
* Their ids are assembled rather than written out, and that is not a flourish:
|
||||
* the `ui` workstream's own gate greps the whole of `index.html` and `src/` for
|
||||
* those two ids and requires zero lines back, so a test that spelled either of
|
||||
* them would be the one thing keeping that grep red forever.
|
||||
*/
|
||||
const bars = ["drive", "walk"].map((prefix) => `${prefix}-controls`);
|
||||
for (const corpse of [...bars, "renderLegend", "addGodmodeShortcut"]) {
|
||||
assert.ok(!MAIN.includes(corpse), `main.ts still references ${corpse}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the city scene is handed the same environment rig the office is", () => {
|
||||
const scene = readFileSync(path.join(ROOT, "src/engine/scene.ts"), "utf8");
|
||||
const office = readFileSync(path.join(ROOT, "src/interiors/officeScene.ts"), "utf8");
|
||||
assert.ok(scene.includes('options.environment?.apply(scene, state, "city")'));
|
||||
assert.ok(scene.includes("options.environment?.release(scene)"));
|
||||
assert.ok(office.includes('options.environment?.apply(scene, state, "office")'));
|
||||
assert.ok(office.includes("options.environment?.release(scene)"));
|
||||
assert.equal(
|
||||
[...MAIN.matchAll(/createEnvironmentRig\(/g)].length,
|
||||
1,
|
||||
"one rig for the page, beside the one renderer — a rig per scene leaks a PMREM " +
|
||||
"chain and a render target on every city switch",
|
||||
);
|
||||
});
|
||||
|
||||
test("the aircraft pick reaches the card, and the card reaches an anonymous visitor", () => {
|
||||
const scene = readFileSync(path.join(ROOT, "src/engine/scene.ts"), "utf8");
|
||||
assert.ok(
|
||||
scene.includes("hit.object.userData.aircraftId"),
|
||||
"the city's picking must resolve an aeroplane, not only a marker",
|
||||
);
|
||||
assert.ok(
|
||||
/onAircraftPick\?\.\(/.test(scene),
|
||||
"the resolved aeroplane must reach the caller",
|
||||
);
|
||||
assert.ok(
|
||||
MAIN.includes("onAircraftPick: (a) => showAircraftDetail(a)"),
|
||||
"main.ts must wire the pick to the detail card",
|
||||
);
|
||||
// The function body, from its own `function` line to the next brace at column
|
||||
// zero. Precise enough to be worth asserting: a tier check anywhere inside it
|
||||
// is the defect, and a tier check anywhere else is somebody else's business.
|
||||
const from = MAIN.indexOf("function showAircraftDetail");
|
||||
assert.ok(from > 0, "showAircraftDetail has been renamed");
|
||||
const body = MAIN.slice(from, MAIN.indexOf("\n}", from));
|
||||
assert.ok(
|
||||
!/\baccess\./.test(body),
|
||||
"owner decision 2: the flight card is not gated on an account. An ADS-B " +
|
||||
"position is broadcast in clear to anybody with a receiver, so there is " +
|
||||
"nothing here an account could grant.",
|
||||
);
|
||||
});
|
||||
|
||||
test("a handheld visitor reaches the texture quality that was written for them", () => {
|
||||
assert.ok(
|
||||
/deviceProfile\(\)\.handheld \? "medium" : "high"/.test(MAIN),
|
||||
"materials.ts implements low/medium/high and main.ts hardcoded `high`, so the " +
|
||||
"documented mobile escape hatch had never once been reachable",
|
||||
);
|
||||
assert.ok(
|
||||
MAIN.includes("quality: officeMaterialQuality()"),
|
||||
"the registry must be built at the quality the device profile chose",
|
||||
);
|
||||
});
|
||||
+44
-12
@@ -108,15 +108,22 @@ describe("the reference pack resolves cleanly", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* The relationship, not the number.
|
||||
* One storey, and the shape of the room that is most of it.
|
||||
*
|
||||
* Floor-to-floor is deliberately exaggerated in this pack — see `PLENUM` in
|
||||
* `lumbridge-hq.ts` — and an assertion on the literal would have to be edited
|
||||
* every time somebody dials it, which makes it a change-detector rather than a
|
||||
* test. What must stay true is that level 2 sits a clear interstitial *above*
|
||||
* level 1's ceiling, and never at or below it: `elevation` is floor-to-floor,
|
||||
* and setting it to the ceiling height is the classic way to bury one storey's
|
||||
* slab inside the one below.
|
||||
* The comment that stood here argued about a `PLENUM` constant and a level 2,
|
||||
* and this pack has had neither since it became a twelve-by-nine studio. A
|
||||
* stale comment on a passing test is worse than no comment at all, because it
|
||||
* is what the next author reads to find out what the pack *is* — and it sent
|
||||
* them looking for two storeys in a flat.
|
||||
*
|
||||
* The floor-to-floor argument it was making is still worth making, and it is
|
||||
* now made where there are two storeys to make it about: `the Mateo Court
|
||||
* pack` below asserts that level 2 lands on floor-to-*floor* and not on the
|
||||
* ceiling height, which is the classic way to bury one slab inside the one
|
||||
* below it.
|
||||
*
|
||||
* What is asserted here is the studio itself: one level at y = 0, and a
|
||||
* live/work room that is the whole plate inside the façade.
|
||||
*/
|
||||
it("is an honest twelve-by-nine metre single-level studio", () => {
|
||||
assert.deepEqual(plan.levels.map((level) => [level.id, level.floorY]), [["level-1", 0]]);
|
||||
@@ -557,13 +564,38 @@ describe("the Mateo Court pack", () => {
|
||||
assert.deepEqual(mine.filter((id) => others.has(id)), []);
|
||||
});
|
||||
|
||||
it("caps the authored office at twenty-four useful seat addresses", () => {
|
||||
/**
|
||||
* Thirty-four addresses, from four benches and ten places written by hand.
|
||||
*
|
||||
* This used to read twenty-four and to assert that `works-b` did **not**
|
||||
* exist, on the argument that capacity is a plan decision and a floor filled
|
||||
* edge to edge with generated workstations is a call centre. The argument
|
||||
* still holds; the plan decision changed. A 260 m² agent floor now has two
|
||||
* benches with a screened gap between them (12 + 4) and the 158 m² model loft
|
||||
* has two (2 + 6), which is a floor with bays rather than a floor with a
|
||||
* horizon. What the assertion is really for is unchanged: it is the thing that
|
||||
* notices a bank quietly growing a column, because `columns` and `rows` are
|
||||
* two characters each and every one of them mints a public address.
|
||||
*/
|
||||
it("holds the authored office at thirty-four seat addresses", () => {
|
||||
const ids = mc.allSeats().map((seat) => seat.id);
|
||||
assert.equal(ids.length, 24);
|
||||
for (const id of ["works-a-01", "works-a-12", "loft-a-01", "loft-a-02", "review-04", "loggia-01"]) {
|
||||
assert.equal(ids.length, 34);
|
||||
for (const id of [
|
||||
"works-a-01",
|
||||
"works-a-12",
|
||||
"works-b-01",
|
||||
"works-b-04",
|
||||
"loft-a-01",
|
||||
"loft-b-06",
|
||||
"review-04",
|
||||
"loggia-01",
|
||||
]) {
|
||||
assert.ok(ids.includes(id), `${id} is missing`);
|
||||
}
|
||||
assert.equal(ids.some((id) => id.startsWith("works-b-")), false);
|
||||
// Nothing past the two banks on each floor. `works-c` is the id somebody
|
||||
// adds when they want ten more desks and have not read the paragraph above.
|
||||
assert.equal(ids.some((id) => id.startsWith("works-c-")), false);
|
||||
assert.equal(ids.some((id) => id.startsWith("loft-c-")), false);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Test home for the `packs` workstream.
|
||||
#
|
||||
# Each build workstream owns its own subdirectory so eight builders can add
|
||||
# suites in parallel without ever colliding on a path. `npm test` picks these
|
||||
# up through the widened `src/test/**/*.test.ts` glob in package.json.
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* The exterior arrival anchor: one marked stall on the ground outside each
|
||||
* shipped building.
|
||||
*
|
||||
* `src/engine/officeExterior.ts` builds an apron and a vehicle there, and it
|
||||
* needs a number no other field in the format can give it. `site.lat`/`lng` says
|
||||
* where the building is on the earth and nothing about which corner of the lot
|
||||
* you park on; `Plan.bounds` is the extent of what was authored and its edge is
|
||||
* a wall, not a kerb. So the stall is authored — and the one thing an authored
|
||||
* stall can get catastrophically wrong is being **inside the building**, which
|
||||
* renders as a car in the lobby and looks entirely plausible in the source.
|
||||
*
|
||||
* That check is deliberately here and not in `Plan`. A pack may legitimately
|
||||
* mean a covered undercroft or a courtyard, and the resolver has no business
|
||||
* ruling on architecture; these three packs mean the street, the podium kerb and
|
||||
* the apron, and this is where that is stated.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { Plan } from "../../interiors/plan.ts";
|
||||
import type { Office } from "../../interiors/types.ts";
|
||||
import LUMBRIDGE_HQ from "../../offices/lumbridge-hq.ts";
|
||||
import FRONTIER_VALLEY from "../../offices/frontier-valley.ts";
|
||||
import MATEO_COURT from "../../offices/mateo-court.ts";
|
||||
|
||||
const PACKS: readonly Office[] = [LUMBRIDGE_HQ, FRONTIER_VALLEY, MATEO_COURT];
|
||||
|
||||
describe("every shipped pack parks a vehicle outside itself", () => {
|
||||
for (const pack of PACKS) {
|
||||
const arrival = pack.site?.arrival;
|
||||
|
||||
it(`${pack.id} declares a vehicle stall on a level that exists`, () => {
|
||||
assert.ok(pack.site, `${pack.id} has no site`);
|
||||
assert.ok(arrival, `${pack.id} has no arrival anchor`);
|
||||
assert.equal(arrival.kind, "vehicle-stall");
|
||||
assert.ok(Number.isFinite(arrival.rotation), `${pack.id} stall has no rotation`);
|
||||
assert.ok(Number.isFinite(arrival.position.x) && Number.isFinite(arrival.position.z));
|
||||
assert.ok(
|
||||
pack.levels.some((level) => level.id === arrival.levelId),
|
||||
`${pack.id} stall stands on unknown level "${arrival?.levelId}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it(`${pack.id} stands its stall outside every room, on every level`, () => {
|
||||
assert.ok(arrival);
|
||||
const plan = new Plan(pack, { warn: false });
|
||||
// The named level is what the criterion is about; the others are checked
|
||||
// too because a stall under an upper storey is still under a building.
|
||||
for (const level of plan.levels) {
|
||||
const room = plan.roomAt(level.id, arrival.position);
|
||||
assert.equal(
|
||||
room,
|
||||
null,
|
||||
`${pack.id} parks in ${room?.name} on ${level.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Stronger than "not in a room", and true of all three by intent rather than
|
||||
* by rule: none of them parks in its own courtyard or under its own upper
|
||||
* floor. `Plan.bounds` is the union of every level's walls, props and slabs,
|
||||
* so a stall outside it is a stall outside the building.
|
||||
*/
|
||||
it(`${pack.id} stands its stall clear of the whole footprint`, () => {
|
||||
assert.ok(arrival);
|
||||
const bounds = new Plan(pack, { warn: false }).bounds;
|
||||
const { x, z } = arrival.position;
|
||||
const outside =
|
||||
x < bounds.minX || x > bounds.maxX || z < bounds.minZ || z > bounds.maxZ;
|
||||
assert.ok(
|
||||
outside,
|
||||
`${pack.id} stall at (${x}, ${z}) is inside the footprint ` +
|
||||
`x ${bounds.minX}..${bounds.maxX}, z ${bounds.minZ}..${bounds.maxZ}`,
|
||||
);
|
||||
});
|
||||
|
||||
it(`${pack.id} hands the very anchor it authored to Plan`, () => {
|
||||
// Identity, not equality — the same argument `office.test.ts` makes about
|
||||
// sites. A copy means somebody restated a coordinate on the way through.
|
||||
assert.equal(new Plan(pack, { warn: false }).exteriorArrival, arrival);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("Plan drops an arrival it cannot use", () => {
|
||||
function withArrival(levelId: string, kind: string): Office {
|
||||
return {
|
||||
...MATEO_COURT,
|
||||
site: {
|
||||
...MATEO_COURT.site!,
|
||||
arrival: {
|
||||
levelId,
|
||||
position: { x: 22.6, z: -3.4 },
|
||||
rotation: 0,
|
||||
kind: kind as "vehicle-stall",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("reports an unknown level and resolves to nothing", () => {
|
||||
const plan = new Plan(withArrival("level-9", "vehicle-stall"), { warn: false });
|
||||
assert.equal(plan.exteriorArrival, null);
|
||||
assert.deepEqual(
|
||||
plan.problems.map((problem) => `${problem.where}: ${problem.action}`),
|
||||
["site.arrival: dropped"],
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a kind this build does not know", () => {
|
||||
const plan = new Plan(withArrival("level-1", "helipad"), { warn: false });
|
||||
assert.equal(plan.exteriorArrival, null);
|
||||
assert.match(plan.problems[0]?.message ?? "", /unknown kind "helipad"/);
|
||||
});
|
||||
|
||||
/**
|
||||
* And a pack with no stall at all is not a pack with a problem. Every field
|
||||
* added to this format has to leave the packs written before it existed
|
||||
* resolving exactly as they did, which for an office with no vehicle outside
|
||||
* it means `null` and silence.
|
||||
*/
|
||||
it("says nothing about a pack that authored no stall", () => {
|
||||
const site = { ...MATEO_COURT.site! };
|
||||
delete site.arrival;
|
||||
const plan = new Plan({ ...MATEO_COURT, site }, { warn: false });
|
||||
assert.equal(plan.exteriorArrival, null);
|
||||
assert.deepEqual(plan.problems, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* The authored device layer, in the two studios and in `Plan`.
|
||||
*
|
||||
* A `DeviceDeclaration` is the first authored thing in this format that points
|
||||
* at another authored thing *and carries no coordinate of its own*. `Prop.seat`
|
||||
* is an address and nothing renders from it; a device's anchor is an address the
|
||||
* renderer takes a transform from, so a broken anchor is not a dangling label —
|
||||
* it is a microphone that is nowhere, or worse, a microphone somewhere it never
|
||||
* was.
|
||||
*
|
||||
* Two properties are worth the file on their own:
|
||||
*
|
||||
* - **The anchor prop is the hardware.** Every declaration names a prop that
|
||||
* exists on the level it claims, and that prop's asset id agrees with the
|
||||
* device's kind. `deviceKindOfAssetId` reads the kind out of
|
||||
* `<ns>:device.<kind>.<placement>`, so the check needs no asset registry and
|
||||
* works for a self-hoster's `acme:device.mic.boom` for free.
|
||||
* - **A bad device costs one device.** `Plan` drops it and records a problem.
|
||||
* It must never throw, because a pack is 250 props and one typo should not
|
||||
* cost a visitor the building — the same argument `plan.ts` makes about wall
|
||||
* openings, and the reason `validateDeviceDeclaration` returns sentences
|
||||
* instead of raising.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
CANONICAL_CAPABILITIES,
|
||||
deviceKindOfAssetId,
|
||||
validateDeviceDeclaration,
|
||||
type DeviceDeclaration,
|
||||
type DeviceKind,
|
||||
} from "../../devices/types.ts";
|
||||
import { Plan } from "../../interiors/plan.ts";
|
||||
import type { Office, Prop } from "../../interiors/types.ts";
|
||||
import LUMBRIDGE_HQ from "../../offices/lumbridge-hq.ts";
|
||||
import MATEO_COURT from "../../offices/mateo-court.ts";
|
||||
|
||||
/** The two studios. The hangar is in development and declares no hardware. */
|
||||
const STUDIOS: readonly Office[] = [LUMBRIDGE_HQ, MATEO_COURT];
|
||||
|
||||
function declarationsOf(pack: Office): DeviceDeclaration[] {
|
||||
return pack.levels.flatMap((level) => [...(level.floorplan.devices ?? [])]);
|
||||
}
|
||||
|
||||
function propsOfLevel(pack: Office, levelId: string): Prop[] {
|
||||
const level = pack.levels.find((entry) => entry.id === levelId);
|
||||
return [...(level?.floorplan.props ?? [])];
|
||||
}
|
||||
|
||||
describe("both studios declare the hardware the product promises", () => {
|
||||
for (const pack of STUDIOS) {
|
||||
const declarations = declarationsOf(pack);
|
||||
|
||||
it(`${pack.id} has at least one mic and at least one speaker`, () => {
|
||||
const kinds = declarations.map((device) => device.kind);
|
||||
for (const kind of ["mic", "speaker"] as DeviceKind[]) {
|
||||
assert.ok(kinds.includes(kind), `${pack.id} declares no ${kind}`);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${pack.id} anchors every device to real hardware on the level it names`, () => {
|
||||
assert.ok(declarations.length > 0, `${pack.id} declares no devices at all`);
|
||||
for (const device of declarations) {
|
||||
const prop = propsOfLevel(pack, device.anchor.levelId).find(
|
||||
(entry) => entry.id === device.anchor.propId,
|
||||
);
|
||||
assert.ok(
|
||||
prop,
|
||||
`${device.id} is anchored to "${device.anchor.propId}", which is not a prop on ` +
|
||||
`${device.anchor.levelId}`,
|
||||
);
|
||||
// The prop must be the hardware, not merely near it: a mic declaration
|
||||
// pointing at a desk would render nothing and command something.
|
||||
assert.equal(
|
||||
deviceKindOfAssetId(prop.kind),
|
||||
device.kind,
|
||||
`${device.id} is a ${device.kind} standing on ${prop.kind}`,
|
||||
);
|
||||
assert.equal(deviceKindOfAssetId(device.assetId), device.kind);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${pack.id} says its readings are simulated, in words`, () => {
|
||||
for (const device of declarations) {
|
||||
// The library check — provenance, disclosure wording, capability
|
||||
// vocabulary — restated here against the shipped packs rather than
|
||||
// against a fixture, because it is the shipped packs that get edited.
|
||||
assert.deepEqual(validateDeviceDeclaration(device), [], device.id);
|
||||
assert.equal(device.provenance, "simulated");
|
||||
assert.match(device.disclosure, /simulat/i);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${pack.id} describes each instrument with the canonical capabilities`, () => {
|
||||
// Not style: the panel builds its controls by walking this array and the
|
||||
// arena's observation width is the sum of them, so two studios authored
|
||||
// months apart disagreeing about what a mic can do changes the shape of an
|
||||
// RL observation without anybody editing the arena.
|
||||
for (const device of declarations) {
|
||||
assert.deepEqual(
|
||||
[...device.capabilities],
|
||||
[...CANONICAL_CAPABILITIES[device.kind]],
|
||||
device.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${pack.id} keeps device ids unique across the building`, () => {
|
||||
const ids = declarations.map((device) => device.id);
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("Plan resolves a device onto its anchor prop", () => {
|
||||
it("gives every declaration a position derived from the hardware", () => {
|
||||
for (const pack of STUDIOS) {
|
||||
const plan = new Plan(pack, { warn: false });
|
||||
const declarations = declarationsOf(pack);
|
||||
assert.equal(plan.allDevices().length, declarations.length, pack.id);
|
||||
for (const device of declarations) {
|
||||
const resolved = plan.device(device.id);
|
||||
assert.ok(resolved, `${device.id} did not resolve`);
|
||||
const prop = plan.prop(device.anchor.propId);
|
||||
assert.ok(prop);
|
||||
// No offset is authored in either studio, so the device stands exactly
|
||||
// where its hardware does — including the 0.73 m desktop the prop's own
|
||||
// `elevation` put it on. Nothing restates that height.
|
||||
assert.deepEqual(resolved.position, {
|
||||
x: prop.position.x,
|
||||
y: prop.position.y,
|
||||
z: prop.position.z,
|
||||
});
|
||||
assert.equal(resolved.rotation, prop.rotation);
|
||||
assert.equal(resolved.propId, prop.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The LA studio binds each unit to the standing or sitting address it serves,
|
||||
* which is what lets a consumer ask whether anybody is where the mic is
|
||||
* pointed. An unknown seat would be repaired away silently, so this asserts
|
||||
* the bindings survived rather than that the field is spelled right.
|
||||
*/
|
||||
it("keeps the LA studio's seat and room addresses", () => {
|
||||
const plan = new Plan(MATEO_COURT, { warn: false });
|
||||
assert.deepEqual(
|
||||
plan.allDevices().map((device) => `${device.id}@${device.roomId}/${device.seatId}`),
|
||||
[
|
||||
"la-front-mic@lobby/front-01",
|
||||
"la-front-speaker@lobby/front-01",
|
||||
"la-studio-mic@press/media-01",
|
||||
"la-studio-speaker@press/media-02",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The offset is in the **prop's** frame, and that is the whole reason it
|
||||
* exists: turn the desk and the mic stays on the corner of it. A fixture
|
||||
* rather than a shipped pack, because neither studio needs an offset and a
|
||||
* test that only exercises the zero case does not test the rotation at all.
|
||||
*/
|
||||
it("rotates an authored offset into the prop's frame", () => {
|
||||
const plan = new Plan(offsetFixture(), { warn: false });
|
||||
assert.deepEqual(plan.problems, []);
|
||||
const device = plan.device("fixture-mic");
|
||||
assert.ok(device);
|
||||
// The desk is at (4, 0, 6) turned a quarter turn clockwise about +Y, so the
|
||||
// prop's local +X points along world +Z. An offset of 0.5 along local +X
|
||||
// therefore lands 0.5 further down the page, not 0.5 to the right.
|
||||
assert.equal(round(device.position.x), 4);
|
||||
assert.equal(round(device.position.y), 0.73);
|
||||
assert.equal(round(device.position.z), 6.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a broken device costs one device", () => {
|
||||
const CASES: readonly [string, (device: DeviceDeclaration) => DeviceDeclaration, RegExp][] = [
|
||||
[
|
||||
"an anchor naming a prop that does not exist",
|
||||
(device) => ({ ...device, anchor: { ...device.anchor, propId: "no-such-prop" } }),
|
||||
/anchored to unknown prop/,
|
||||
],
|
||||
[
|
||||
"an anchor on a level the declaration was not written on",
|
||||
(device) => ({ ...device, anchor: { ...device.anchor, levelId: "level-9" } }),
|
||||
/declared on level/,
|
||||
],
|
||||
[
|
||||
"hardware of the wrong kind",
|
||||
(device) => ({ ...device, kind: "speaker", assetId: "tera:device.speaker.desk" }),
|
||||
/is a speaker anchored to tera:device\.mic\.desk/,
|
||||
],
|
||||
[
|
||||
"a disclosure that does not say the readings are simulated",
|
||||
(device) => ({ ...device, disclosure: "Live studio hardware." }),
|
||||
/does not say so/,
|
||||
],
|
||||
];
|
||||
|
||||
for (const [what, mutate, message] of CASES) {
|
||||
it(`drops a device with ${what}, and never throws`, () => {
|
||||
const office = offsetFixture();
|
||||
const level = office.levels[0];
|
||||
assert.ok(level);
|
||||
const original = level.floorplan.devices?.[0];
|
||||
assert.ok(original);
|
||||
level.floorplan.devices = [mutate(original)];
|
||||
|
||||
const plan = new Plan(office, { warn: false });
|
||||
assert.equal(plan.allDevices().length, 0);
|
||||
assert.equal(plan.levels[0]?.devices.length, 0);
|
||||
assert.equal(plan.problems.length, 1);
|
||||
assert.equal(plan.problems[0]?.action, "dropped");
|
||||
assert.match(plan.problems[0]?.message ?? "", message);
|
||||
// And the building is still a building: the drop costs the device and
|
||||
// nothing else, which is the property that makes authored furniture safe.
|
||||
assert.equal(plan.levels[0]?.props.length, 1);
|
||||
assert.equal(plan.levels[0]?.rooms.length, 1);
|
||||
});
|
||||
}
|
||||
|
||||
it("clears an unknown seat rather than dropping the device", () => {
|
||||
const office = offsetFixture();
|
||||
const level = office.levels[0];
|
||||
assert.ok(level);
|
||||
const original = level.floorplan.devices?.[0];
|
||||
assert.ok(original);
|
||||
level.floorplan.devices = [
|
||||
{ ...original, anchor: { ...original.anchor, seatId: "nobody-sits-here" } },
|
||||
];
|
||||
|
||||
const plan = new Plan(office, { warn: false });
|
||||
assert.equal(plan.problems.length, 1);
|
||||
assert.equal(plan.problems[0]?.action, "repaired");
|
||||
assert.equal(plan.device("fixture-mic")?.seatId, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A public build takes the device away with the furniture, and says nothing.
|
||||
*
|
||||
* This is the one case where a device that does not resolve is **not** a
|
||||
* problem, and telling the two apart is the whole of the distinction: "your
|
||||
* hardware does not exist" is a typo in a pack, and "your hardware is not in
|
||||
* this build" is `PlanOptions.depth` doing exactly what it is for. Reporting the
|
||||
* second would put a line in `problems` on every public build of any pack that
|
||||
* ever marks a desk private, and `packRegression.test.ts` asserts that list is
|
||||
* empty at both depths.
|
||||
*/
|
||||
describe("depth takes a device away with its hardware", () => {
|
||||
it("drops a device standing on a private prop, without reporting one", () => {
|
||||
const office = offsetFixture();
|
||||
const prop = office.levels[0]?.floorplan.props?.[0];
|
||||
assert.ok(prop);
|
||||
prop.audience = "private";
|
||||
|
||||
const full = new Plan(office, { warn: false });
|
||||
assert.equal(full.allDevices().length, 1);
|
||||
assert.deepEqual(full.problems, []);
|
||||
|
||||
const publicBuild = new Plan(office, { depth: "public", warn: false });
|
||||
assert.equal(publicBuild.allDevices().length, 0);
|
||||
assert.equal(publicBuild.levels[0]?.devices.length, 0);
|
||||
assert.deepEqual(publicBuild.problems, []);
|
||||
});
|
||||
|
||||
/**
|
||||
* And the same for a private desk bank, which is the harder half: a private
|
||||
* bank generates no props at all, so the ids it *would* have generated have to
|
||||
* be derived from the contract rather than observed.
|
||||
*/
|
||||
it("drops a device standing on a private bank's desk, without reporting one", () => {
|
||||
const office = offsetFixture();
|
||||
const level = office.levels[0];
|
||||
assert.ok(level);
|
||||
level.floorplan.props = [];
|
||||
level.floorplan.deskBanks = [
|
||||
{
|
||||
id: "hidden",
|
||||
desk: "tera:desk.workstation",
|
||||
chair: "tera:seat.task-chair",
|
||||
origin: { x: 4, z: 6 },
|
||||
rotation: 0,
|
||||
columns: 2,
|
||||
rows: 1,
|
||||
pitch: 1.7,
|
||||
audience: "private",
|
||||
},
|
||||
];
|
||||
const original = level.floorplan.devices?.[0];
|
||||
assert.ok(original);
|
||||
level.floorplan.devices = [
|
||||
{ ...original, anchor: { ...original.anchor, propId: "hidden-desk-01" } },
|
||||
];
|
||||
|
||||
const publicBuild = new Plan(office, { depth: "public", warn: false });
|
||||
assert.equal(publicBuild.allDevices().length, 0);
|
||||
assert.deepEqual(publicBuild.problems, []);
|
||||
|
||||
// The full build still resolves it, standing on the desk the bank generated
|
||||
// — which is also the assertion that the derived id was the right one.
|
||||
const full = new Plan(office, { warn: false });
|
||||
assert.equal(full.device("fixture-mic")?.propId, "hidden-desk-01");
|
||||
});
|
||||
});
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
/**
|
||||
* The smallest office that can carry a device: one room, one desk, one mic on
|
||||
* the corner of it, and a quarter turn so that a frame error is visible.
|
||||
*
|
||||
* Built fresh on every call, because half of these tests mutate it.
|
||||
*/
|
||||
function offsetFixture(): Office {
|
||||
const mic: DeviceDeclaration = {
|
||||
id: "fixture-mic",
|
||||
kind: "mic",
|
||||
label: "Fixture mic",
|
||||
assetId: "tera:device.mic.desk",
|
||||
anchor: {
|
||||
levelId: "level-1",
|
||||
propId: "fixture-mic-hardware",
|
||||
offset: { x: 0.5, y: 0, z: 0 },
|
||||
},
|
||||
capabilities: CANONICAL_CAPABILITIES.mic,
|
||||
provenance: "simulated",
|
||||
disclosure: "Simulated fixture hardware, never presence data.",
|
||||
};
|
||||
return {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
levels: [
|
||||
{
|
||||
id: "level-1",
|
||||
name: "Ground",
|
||||
elevation: 0,
|
||||
wallHeight: 2.8,
|
||||
floorplan: {
|
||||
rooms: [
|
||||
{
|
||||
id: "room",
|
||||
name: "Room",
|
||||
outline: [
|
||||
{ x: 0, z: 0 },
|
||||
{ x: 0, z: 10 },
|
||||
{ x: 10, z: 10 },
|
||||
{ x: 10, z: 0 },
|
||||
],
|
||||
floor: "tera:concrete.polished",
|
||||
},
|
||||
],
|
||||
walls: [],
|
||||
props: [
|
||||
{
|
||||
id: "fixture-mic-hardware",
|
||||
kind: "tera:device.mic.desk",
|
||||
position: { x: 4, z: 6 },
|
||||
rotation: -Math.PI / 2,
|
||||
elevation: 0.73,
|
||||
},
|
||||
],
|
||||
devices: [mic],
|
||||
},
|
||||
},
|
||||
],
|
||||
viewpoints: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* The LA studio's content, held to the bar the SF studio set.
|
||||
*
|
||||
* `mateo-court` was never the smaller pack — it is sixteen rooms and roughly
|
||||
* 250 props against four rooms and thirty. What it lacked was **fidelity per
|
||||
* square metre and authoring generation**, and that had four measurable
|
||||
* symptoms, every one of which is a check in this file:
|
||||
*
|
||||
* 1. Ninety-eight of its props were ceiling troffers, including an eight-by-
|
||||
* three grid in a room declared `ceiling: null`.
|
||||
* 2. It bound **zero** props to seats, so ten hand-authored addresses had no
|
||||
* furniture and an occupancy layer had nothing to dim.
|
||||
* 3. It placed **none** of the habitat kit past the kitchen: no sofa, no bed,
|
||||
* no wardrobe, and — in a building whose every fitting was overhead — no
|
||||
* floor lamp anywhere.
|
||||
* 4. Twelve of its sixteen rooms had no viewpoint at all, and a room nobody
|
||||
* has framed is a room nobody has looked at since they authored it.
|
||||
*
|
||||
* None of the four fails a build, none of them throws, and all four look
|
||||
* completely fine in a screenshot of the one room somebody did frame. That is
|
||||
* what makes them worth asserting rather than remembering.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { Plan, type PropPlacement } from "../../interiors/plan.ts";
|
||||
import LUMBRIDGE_HQ from "../../offices/lumbridge-hq.ts";
|
||||
import MATEO_COURT from "../../offices/mateo-court.ts";
|
||||
|
||||
const plan = new Plan(MATEO_COURT, { warn: false });
|
||||
const props: PropPlacement[] = plan.levels.flatMap((level) => [...level.props]);
|
||||
|
||||
/** A light fitting, by id convention: `tera:light.*` is the whole family. */
|
||||
function isFitting(prop: PropPlacement): boolean {
|
||||
return prop.kind.startsWith("tera:light.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-light props per square metre of authored floor, building-wide.
|
||||
*
|
||||
* Lights are excluded because they are the thing that was being counted instead
|
||||
* of furniture — a room can hit any density target with a ceiling grid and still
|
||||
* be empty. Fittings are measured separately, below, and capped.
|
||||
*/
|
||||
function densityOf(plan: Plan): { overall: number; byRoom: Map<string, number> } {
|
||||
let area = 0;
|
||||
let count = 0;
|
||||
const byRoom = new Map<string, number>();
|
||||
for (const level of plan.levels) {
|
||||
for (const room of level.rooms) {
|
||||
const inside = level.props.filter(
|
||||
(prop) =>
|
||||
!isFitting(prop) &&
|
||||
plan.roomAt(level.id, { x: prop.position.x, z: prop.position.z })?.id === room.id,
|
||||
);
|
||||
area += room.area;
|
||||
count += inside.length;
|
||||
byRoom.set(room.id, inside.length / room.area);
|
||||
}
|
||||
}
|
||||
return { overall: count / area, byRoom };
|
||||
}
|
||||
|
||||
describe("the LA studio binds its furniture to its addresses", () => {
|
||||
/**
|
||||
* A seat is an address and a chair is a prop, and the whole point of the
|
||||
* `seat` field is that something outside this repo can say "review-03" and
|
||||
* have it mean a chair. Ten seats were authored here by hand and not one of
|
||||
* them had a prop pointing at it.
|
||||
*/
|
||||
it("gives every hand-authored seat at least one prop that names it", () => {
|
||||
const bound = new Set(props.map((prop) => prop.seat).filter((id) => id !== undefined));
|
||||
const authored = MATEO_COURT.levels
|
||||
.flatMap((level) => level.floorplan.seats ?? [])
|
||||
.map((seat) => seat.id);
|
||||
assert.ok(authored.length >= 10, "the pack stopped authoring seats by hand");
|
||||
assert.deepEqual(
|
||||
authored.filter((id) => !bound.has(id)),
|
||||
[],
|
||||
"seats with no furniture bound to them",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the bindings on the props that are actually at those places", () => {
|
||||
// Named pairs rather than a count, because the failure this catches is a
|
||||
// chair bound to the seat on the other side of the table — which is invisible
|
||||
// until an occupancy layer dims the wrong one.
|
||||
for (const [propId, seatId] of [
|
||||
["front-chair", "front-01"],
|
||||
["mess-stool-01", "commons-01"],
|
||||
["mess-stool-02", "commons-02"],
|
||||
["hewitt-chair-n-01", "review-01"],
|
||||
["hewitt-chair-n-02", "review-02"],
|
||||
["hewitt-chair-s-01", "review-03"],
|
||||
["hewitt-chair-s-02", "review-04"],
|
||||
["loggia-chair-01", "loggia-01"],
|
||||
] as const) {
|
||||
const prop = plan.prop(propId);
|
||||
const seat = plan.seat(seatId);
|
||||
assert.ok(prop, `${propId} is gone`);
|
||||
assert.ok(seat, `${seatId} is gone`);
|
||||
assert.equal(prop.seat, seatId);
|
||||
// Bound and co-located: a binding that is right and a chair that is two
|
||||
// metres away is a different bug with the same symptom.
|
||||
assert.ok(
|
||||
Math.hypot(prop.position.x - seat.position.x, prop.position.z - seat.position.z) < 1.0,
|
||||
`${propId} is nowhere near ${seatId}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the LA studio is furnished, not merely lit", () => {
|
||||
/**
|
||||
* Mirrors the SF assertion in `office.test.ts`. Four of these seven assets
|
||||
* were written, tested and placed by nobody — the kit existed and the second
|
||||
* studio used none of it.
|
||||
*/
|
||||
it("places all seven of the habitat kit", () => {
|
||||
const kinds = new Set(props.map((prop) => prop.kind));
|
||||
for (const id of [
|
||||
"tera:bed.platform",
|
||||
"tera:sofa.modular",
|
||||
"tera:kitchen.run",
|
||||
"tera:kitchen.island",
|
||||
"tera:storage.wardrobe",
|
||||
"tera:seat.stool",
|
||||
"tera:light.floor",
|
||||
]) assert.ok(kinds.has(id), `${id} is not placed in the LA studio`);
|
||||
});
|
||||
|
||||
/**
|
||||
* The troffer cap, and the reason it is a cap and not a target.
|
||||
*
|
||||
* `furnish.ts` batches per kind, so the ninety-eighth troffer buys nothing the
|
||||
* eye reads while costing exactly as much authoring attention as a piece of
|
||||
* furniture would. Two of the grids hung from rooms declared `ceiling: null`.
|
||||
* The baseline was 98; the cap is 55 and the pack currently sits at 48.
|
||||
*/
|
||||
it("stops hanging a hundred troffers from ceilings that are not there", () => {
|
||||
const troffers = props.filter((prop) => prop.kind === "tera:light.troffer");
|
||||
assert.ok(
|
||||
troffers.length <= 55,
|
||||
`${troffers.length} troffers, which is more than the 55 this building is allowed`,
|
||||
);
|
||||
// And the fittings that remain are still fittings: every one is authored at
|
||||
// an elevation, because a troffer on the floor is a box in the middle of the
|
||||
// room. This is the check that stops the cap being met by moving them.
|
||||
for (const prop of troffers) {
|
||||
assert.ok(prop.position.y > 2.5, `${prop.id} is a ceiling fitting at y ${prop.position.y}`);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Soft light exists at all, which is the other half of the troffer argument. A
|
||||
* building lit exclusively from a ceiling grid reads as a rendering of an
|
||||
* office rather than as a place, and this pack had 108 fittings and not one of
|
||||
* them below head height.
|
||||
*/
|
||||
it("lights something with a lamp somebody could turn off", () => {
|
||||
const lamps = props.filter((prop) => prop.kind === "tera:light.floor");
|
||||
assert.ok(lamps.length >= 5, `only ${lamps.length} floor lamps in sixteen rooms`);
|
||||
const rooms = new Set(
|
||||
lamps.map(
|
||||
(lamp) =>
|
||||
plan.roomAt(lamp.levelId, { x: lamp.position.x, z: lamp.position.z })?.id ?? "nowhere",
|
||||
),
|
||||
);
|
||||
assert.ok(rooms.size >= 5, `every floor lamp is in one of ${rooms.size} rooms`);
|
||||
assert.equal(rooms.has("nowhere"), false, "a floor lamp stands outside every room");
|
||||
});
|
||||
|
||||
/**
|
||||
* ### Density, and the target it is measured against
|
||||
*
|
||||
* The bar is the SF studio's own figure — 0.28 non-light props/m² over its
|
||||
* whole floor — and the gate is 0.26 building-wide with no room over 20 m²
|
||||
* below 0.15. This pack reached 0.288 with the studio kit; it was at 0.135
|
||||
* when the pass started and 0.149 before those twelve assets existed.
|
||||
*
|
||||
* Lights are excluded on purpose. A ceiling grid will satisfy any prop count
|
||||
* you like while leaving the floor bare, and this building's first version
|
||||
* proved it: ninety-eight of its props were troffers, two of the grids hung in
|
||||
* rooms declared `ceiling: null`, and it still looked empty from every
|
||||
* viewpoint. Fittings are capped separately, above.
|
||||
*/
|
||||
it("carries at least as much furniture per square metre as the SF studio", () => {
|
||||
const { overall, byRoom } = densityOf(plan);
|
||||
assert.ok(overall >= 0.26, `density fell to ${overall.toFixed(3)} non-light props/m²`);
|
||||
for (const level of plan.levels) {
|
||||
for (const room of level.rooms) {
|
||||
if (room.area < 20) continue;
|
||||
const density = byRoom.get(room.id) ?? 0;
|
||||
assert.ok(
|
||||
density >= 0.15,
|
||||
`${room.id} is at ${density.toFixed(3)} props/m² over ${room.area.toFixed(0)} m²`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** The bar this pack is being measured against, stated rather than assumed. */
|
||||
it("measures the SF studio the same way, so the target is a real number", () => {
|
||||
const sf = densityOf(new Plan(LUMBRIDGE_HQ, { warn: false }));
|
||||
assert.ok(sf.overall >= 0.26, `the SF studio itself fell to ${sf.overall.toFixed(3)}`);
|
||||
const { overall } = densityOf(plan);
|
||||
assert.ok(
|
||||
overall >= sf.overall,
|
||||
`LA is at ${overall.toFixed(3)} against SF's ${sf.overall.toFixed(3)}`,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ### The check that actually stops this pack going thin again
|
||||
*
|
||||
* `furnish.ts` batches props by **(asset, colorKey)** and draws `ctx.rand`
|
||||
* once per batch, so every instance of a kind is geometrically identical —
|
||||
* same seeded jitter, same books on the same shelf. Ten more shelves in a room
|
||||
* are one shelf drawn ten times. That makes the density figure above gameable
|
||||
* by exactly the move that would not change a single pixel a viewer resolves,
|
||||
* and it is why the courtyard could sit at thirteen props of six kinds and
|
||||
* read as a car park.
|
||||
*
|
||||
* So the real assertion is **distinct kinds per room**, and it is deliberately
|
||||
* a floor per room rather than a building-wide count: a pack can put thirty
|
||||
* kinds in reception and leave the yard bare, and the yard is the room every
|
||||
* viewpoint looks across.
|
||||
*/
|
||||
it("furnishes its big rooms out of many kinds and not many copies", () => {
|
||||
const thin: string[] = [];
|
||||
for (const level of plan.levels) {
|
||||
for (const room of level.rooms) {
|
||||
if (room.area < 40) continue;
|
||||
const kinds = new Set(
|
||||
level.props
|
||||
.filter(
|
||||
(prop) =>
|
||||
!isFitting(prop) &&
|
||||
plan.roomAt(level.id, { x: prop.position.x, z: prop.position.z })?.id === room.id,
|
||||
)
|
||||
.map((prop) => prop.kind),
|
||||
);
|
||||
// Seven is the number the *reference* pack's one big room manages, and
|
||||
// it is the floor rather than the aspiration: `works`, `loft` and `court`
|
||||
// are all well past it.
|
||||
if (kinds.size < 7) thin.push(`${room.id} (${kinds.size} kinds over ${room.area.toFixed(0)} m²)`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(thin, [], "big rooms furnished out of too few distinct assets");
|
||||
});
|
||||
|
||||
/**
|
||||
* The studio kit is placed at all, mirroring the habitat assertion above.
|
||||
*
|
||||
* Twelve assets arrived in `src/assets/office/studio.ts` for this pack and no
|
||||
* other, and an asset nobody places is an asset nobody has looked at since it
|
||||
* was written — which is precisely the state four of the seven habitat assets
|
||||
* were found in.
|
||||
*/
|
||||
it("places every one of the twelve studio assets", () => {
|
||||
const kinds = new Set(props.map((prop) => prop.kind));
|
||||
for (const id of [
|
||||
"tera:planter.trough",
|
||||
"tera:bench.slat",
|
||||
"tera:canopy.parasol",
|
||||
"tera:bench.lab",
|
||||
"tera:rack.equipment",
|
||||
"tera:cart.tool",
|
||||
"tera:dock.robot",
|
||||
"tera:case.stack",
|
||||
"tera:light.softbox",
|
||||
"tera:camera.tripod",
|
||||
"tera:acoustic.baffle",
|
||||
"tera:divider.slat",
|
||||
]) assert.ok(kinds.has(id), `${id} is not placed in the LA studio`);
|
||||
});
|
||||
|
||||
/**
|
||||
* A robot charge station stands on a dock, and not on bare floor.
|
||||
*
|
||||
* `operations/mateo-court.ts` parks a humanoid at `la-l1-dock` and
|
||||
* `la-l2-dock`. Before `tera:dock.robot` existed, the ground-floor one was a
|
||||
* point at (18.4, 13.2) — underneath the courtyard's long table. That was
|
||||
* invisible for as long as nothing was drawn there, which is the whole problem
|
||||
* with an address that names no furniture.
|
||||
*/
|
||||
it("stands its charge stations on something", () => {
|
||||
const docks = props.filter((prop) => prop.kind === "tera:dock.robot");
|
||||
assert.ok(docks.length >= 4, `only ${docks.length} robot docks in a building with a robot`);
|
||||
for (const [levelId, x, z] of [
|
||||
["level-1", 10.9, 16.9],
|
||||
["level-2", 22.6, 1.5],
|
||||
] as const) {
|
||||
const near = docks.filter(
|
||||
(dock) =>
|
||||
dock.levelId === levelId &&
|
||||
Math.hypot(dock.position.x - x, dock.position.z - z) < 1.2,
|
||||
);
|
||||
assert.ok(near.length >= 1, `the ${levelId} charge station stands on nothing`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the LA studio frames every room it has", () => {
|
||||
it("declares at least twelve viewpoints, arriving at the passage", () => {
|
||||
assert.ok(plan.viewpoints.length >= 12, `${plan.viewpoints.length} viewpoints`);
|
||||
// `viewpoints[0]` is the arrival pose *and* the walk spawn, so its identity
|
||||
// is load-bearing in two places at once.
|
||||
assert.equal(plan.viewpoints[0]?.id, "paseo");
|
||||
});
|
||||
|
||||
it("puts a viewpoint inside every room over twenty square metres", () => {
|
||||
const unframed: string[] = [];
|
||||
for (const level of plan.levels) {
|
||||
for (const room of level.rooms) {
|
||||
if (room.area < 20) continue;
|
||||
const framed = plan.viewpoints.some(
|
||||
(view) =>
|
||||
view.levelId === level.id && plan.roomAt(level.id, view.focus.at)?.id === room.id,
|
||||
);
|
||||
if (!framed) unframed.push(`${room.id} (${room.area.toFixed(0)} m²)`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(unframed, [], "rooms with no viewpoint");
|
||||
});
|
||||
|
||||
it("writes a description for each one that says something", () => {
|
||||
for (const view of plan.viewpoints) {
|
||||
// The length band is the existing six, which were written to sit on two
|
||||
// lines of the legend. A one-word description is a placeholder somebody
|
||||
// meant to come back to.
|
||||
assert.ok(view.description, `${view.id} has no description`);
|
||||
assert.ok(
|
||||
(view.description?.length ?? 0) >= 110 && (view.description?.length ?? 0) <= 200,
|
||||
`${view.id} description is ${view.description?.length} characters`,
|
||||
);
|
||||
assert.ok(view.shortLabel, `${view.id} has no short label`);
|
||||
}
|
||||
const numbers = plan.viewpoints.map((view) => view.number);
|
||||
assert.equal(new Set(numbers).size, numbers.length, "two viewpoints share a number");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Two sets of prop ids in this pack are addresses something outside it holds.
|
||||
*
|
||||
* Media screens are named by hosted screen grants (`server/src/media/bindings.ts`
|
||||
* treats every `tera:screen.*` prop as a shareable surface), and robot stations
|
||||
* are anchored to props by id in `operations/mateo-court.ts` — where a miss
|
||||
* throws rather than degrades. A content pass renumbers a `scatter` without
|
||||
* noticing; this is what notices.
|
||||
*/
|
||||
describe("the pinned ids survive a content pass", () => {
|
||||
it("keeps every media screen id a screen", () => {
|
||||
for (const id of [
|
||||
"front-monitor",
|
||||
"paseo-directory",
|
||||
"hewitt-display",
|
||||
"willow-display",
|
||||
"palmetto-display",
|
||||
"works-display",
|
||||
"loft-display",
|
||||
]) {
|
||||
const prop = plan.prop(id);
|
||||
assert.ok(prop, `media screen ${id} is gone`);
|
||||
assert.match(prop.kind, /^tera:screen\./, `${id} is no longer a screen`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every prop a robot station is anchored to", () => {
|
||||
for (const id of [
|
||||
"store-shelf-02",
|
||||
"works-locker-02",
|
||||
"loft-locker-02",
|
||||
"jesse-shelf-01",
|
||||
"jesse-board-02",
|
||||
]) assert.ok(plan.prop(id), `robot station anchor ${id} is gone`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Every shipped pack still resolves, and still resolves to the same thing over
|
||||
* the wire as it does in the bundle.
|
||||
*
|
||||
* This is the file that stands between "deprioritised" and "broken". Two of the
|
||||
* three packs in this repo are not being invested in — `frontier-valley` is
|
||||
* published as `building` and `lumbridge-hq` is finished — and the LA content
|
||||
* pass edits shared helpers, shared constants and the schema all three are
|
||||
* authored against. A pack nothing asserts on is a pack that stops resolving
|
||||
* the first time somebody changes a helper two directories away, and the
|
||||
* failure is silent: `Plan` never throws, it drops the offending item and
|
||||
* records a line in `problems` that nothing reads.
|
||||
*
|
||||
* The JSON round trip is here for the same reason and caught a real defect.
|
||||
* CONTRACT.md §2 says a pack hand-written as a `.ts` module and a pack arriving
|
||||
* as a `.json` body over HTTP have to be **literally the same thing**. Both
|
||||
* `mateo-court.ts` and `frontier-valley.ts` had a `scatter()` helper assigning
|
||||
* `elevation: opts.elevation` unconditionally, which put 283 and 106
|
||||
* undefined-valued keys into their exported values respectively. `JSON.stringify`
|
||||
* drops those keys, so the served pack was a different object from the bundled
|
||||
* one — invisible in every renderer, and exactly the sort of thing that turns
|
||||
* into a two-day bug the first time a self-hoster round-trips a pack through the
|
||||
* office API and finds their props have moved.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { Plan } from "../../interiors/plan.ts";
|
||||
import type { Office } from "../../interiors/types.ts";
|
||||
import LUMBRIDGE_HQ from "../../offices/lumbridge-hq.ts";
|
||||
import FRONTIER_VALLEY from "../../offices/frontier-valley.ts";
|
||||
import MATEO_COURT from "../../offices/mateo-court.ts";
|
||||
import { OFFICE_SITES } from "../../offices/sites.ts";
|
||||
|
||||
const PACKS: readonly Office[] = [LUMBRIDGE_HQ, FRONTIER_VALLEY, MATEO_COURT];
|
||||
|
||||
/** Every path in `value` whose key is present and whose value is `undefined`. */
|
||||
function undefinedKeys(value: unknown, path: string, out: string[]): void {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, i) => undefinedKeys(item, `${path}[${i}]`, out));
|
||||
return;
|
||||
}
|
||||
if (value === null || typeof value !== "object") return;
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (entry === undefined) out.push(`${path}.${key}`);
|
||||
else undefinedKeys(entry, `${path}.${key}`, out);
|
||||
}
|
||||
}
|
||||
|
||||
describe("every shipped pack resolves", () => {
|
||||
for (const pack of PACKS) {
|
||||
/**
|
||||
* At **both** depths, because they are different builds and only one of them
|
||||
* is the one a visitor gets. `PlanOptions.depth` skips every item marked
|
||||
* `audience: "private"` before it is resolved, so a pack can be clean at
|
||||
* `"full"` and drop something at `"public"` — a device standing on a private
|
||||
* prop is the case this build introduced.
|
||||
*/
|
||||
for (const depth of ["full", "public"] as const) {
|
||||
it(`${pack.id} at ${depth} depth reports no problems`, () => {
|
||||
const plan = new Plan(pack, { depth, warn: false });
|
||||
assert.deepEqual(
|
||||
plan.problems.map((p) => `${p.where}: ${p.message} (${p.action})`),
|
||||
[],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it(`${pack.id} survives a JSON round trip unchanged`, () => {
|
||||
// Strict deep equality, which treats `{ a: undefined }` and `{}` as
|
||||
// different objects. That is the whole point: they *are* different
|
||||
// objects, and only one of them survives `JSON.stringify`.
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(pack)), pack);
|
||||
const stray: string[] = [];
|
||||
undefinedKeys(pack, pack.id, stray);
|
||||
assert.deepEqual(stray, [], `${pack.id} carries undefined-valued keys`);
|
||||
});
|
||||
|
||||
/**
|
||||
* `main.ts` spawns the walker at `viewpoints[0].focus.at` — that is not a
|
||||
* camera target in this one case, it is a coordinate a person stands on. A
|
||||
* pack whose first viewpoint frames a shot from outside the building looks
|
||||
* fine in the legend and spawns the visitor inside a wall.
|
||||
*/
|
||||
it(`${pack.id} arrives somewhere a walker can stand`, () => {
|
||||
const plan = new Plan(pack, { warn: false });
|
||||
const arrival = plan.arrival();
|
||||
assert.ok(arrival, `${pack.id} declares no viewpoints`);
|
||||
const room = plan.roomAt(arrival.levelId, arrival.focus.at);
|
||||
assert.ok(room, `${pack.id} arrives at ${JSON.stringify(arrival.focus.at)}, which is in no room`);
|
||||
for (const [dx, dz] of [[0.4, 0], [-0.4, 0], [0, 0.4], [0, -0.4]] as const) {
|
||||
const step = { x: arrival.focus.at.x + dx, z: arrival.focus.at.z + dz };
|
||||
if (!plan.blocked(arrival.levelId, arrival.focus.at, step, 0.3)) return;
|
||||
}
|
||||
assert.fail(`${pack.id} arrives in a spot with no clear step in any direction`);
|
||||
});
|
||||
|
||||
it(`${pack.id} keeps every viewpoint on a level that exists`, () => {
|
||||
const plan = new Plan(pack, { warn: false });
|
||||
const levels = new Set(plan.levels.map((level) => level.id));
|
||||
// `Plan` drops a viewpoint whose level does not resolve, so a count that
|
||||
// matches the authored one is the assertion that none were dropped.
|
||||
assert.equal(plan.viewpoints.length, pack.viewpoints.length);
|
||||
for (const view of plan.viewpoints) {
|
||||
assert.ok(levels.has(view.levelId), `${pack.id}/${view.id} is on nothing`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The in-development pack stays in development.
|
||||
*
|
||||
* `frontier-valley` is deprioritised, which is a decision about investment and
|
||||
* not about correctness: it must keep resolving, and it must keep telling a
|
||||
* visitor the truth about itself. A content pass that quietly promoted it to
|
||||
* `active` would put a half-furnished hangar in the same sentence as two
|
||||
* finished studios.
|
||||
*/
|
||||
describe("the published runtime status", () => {
|
||||
it("still calls Frontier Valley a building site and the two studios active", () => {
|
||||
assert.deepEqual(
|
||||
OFFICE_SITES.map(({ id, status }) => `${id}:${status}`),
|
||||
["lumbridge-hq:active", "frontier-valley:building", "mateo-court:active"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
# Test home for the `render` workstream.
|
||||
#
|
||||
# Each build workstream owns its own subdirectory so eight builders can add
|
||||
# suites in parallel without ever colliding on a path. `npm test` picks these
|
||||
# up through the widened `src/test/**/*.test.ts` glob in package.json.
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* The environment rig: lifecycle, caching and the contract it must not break.
|
||||
*
|
||||
* There is no GL context under `node --test`, so the renderer here is a fake —
|
||||
* but a fake of a very specific kind. `PMREMGenerator` never touches WebGL
|
||||
* directly: it allocates plain `WebGLRenderTarget` objects, builds plain
|
||||
* meshes, and reaches the GPU only through `renderer.render`,
|
||||
* `renderer.setRenderTarget` and a handful of state accessors. Stubbing those
|
||||
* out runs the *real* generator, the real target allocation and the real
|
||||
* blur chain, and leaves only the pixels unwritten. So these tests exercise the
|
||||
* actual code path the browser takes, which is the difference between testing
|
||||
* the rig and testing a mock of it.
|
||||
*
|
||||
* The one assertion that needs the fake to be more than a no-op is the dispose
|
||||
* check: the render targets the rig allocates are private to it, so they are
|
||||
* captured on their way through `setRenderTarget` and identified afterwards by
|
||||
* the texture the scene ended up holding.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createEnvironmentRig } from "../../engine/environmentRig.ts";
|
||||
import type { LightingState } from "../../engine/types.ts";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
|
||||
// ---- A renderer that allocates but does not draw ---------------------------
|
||||
|
||||
interface FakeRenderer {
|
||||
renders: number;
|
||||
targets: Set<THREE.WebGLRenderTarget>;
|
||||
failNext: boolean;
|
||||
as(): THREE.WebGLRenderer;
|
||||
}
|
||||
|
||||
function fakeRenderer(): FakeRenderer {
|
||||
const targets = new Set<THREE.WebGLRenderTarget>();
|
||||
const state = {
|
||||
renders: 0,
|
||||
targets,
|
||||
failNext: false,
|
||||
as(): THREE.WebGLRenderer {
|
||||
return stub as unknown as THREE.WebGLRenderer;
|
||||
},
|
||||
};
|
||||
|
||||
const stub = {
|
||||
autoClear: true,
|
||||
toneMapping: THREE.NoToneMapping,
|
||||
xr: { enabled: false },
|
||||
state: { buffers: { depth: { getReversed: () => false } } },
|
||||
getRenderTarget: () => null,
|
||||
getActiveCubeFace: () => 0,
|
||||
getActiveMipmapLevel: () => 0,
|
||||
getClearColor: (target: THREE.Color) => target,
|
||||
getClearAlpha: () => 1,
|
||||
setClearColor: () => {},
|
||||
setClearAlpha: () => {},
|
||||
clearDepth: () => {},
|
||||
compile: () => {},
|
||||
setRenderTarget(target: THREE.WebGLRenderTarget | null) {
|
||||
if (target) targets.add(target);
|
||||
},
|
||||
render() {
|
||||
if (state.failNext) {
|
||||
state.failNext = false;
|
||||
throw new Error("simulated context loss");
|
||||
}
|
||||
state.renders++;
|
||||
},
|
||||
};
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
// ---- Lighting states -------------------------------------------------------
|
||||
|
||||
function lightingState(overrides: Partial<LightingState> = {}): LightingState {
|
||||
return {
|
||||
sun: { direction: [0.31, 0.86, 0.4], color: 0xfff3e0, intensity: 2.35 },
|
||||
hemisphere: { sky: 0xdcecf7, ground: 0x6b6f5e, intensity: 0.92 },
|
||||
ambient: { color: 0xffffff, intensity: 0.24 },
|
||||
sky: { top: 0x8fb8d8, horizon: 0xd9e6ee },
|
||||
fog: { color: 0xd9e6ee, near: 210, far: 460 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** The render target the scene's environment texture came out of. */
|
||||
function targetOf(fake: FakeRenderer, scene: THREE.Scene): THREE.WebGLRenderTarget {
|
||||
for (const target of fake.targets) {
|
||||
if (target.texture === scene.environment) return target;
|
||||
}
|
||||
throw new Error("no allocated render target owns the scene's environment texture");
|
||||
}
|
||||
|
||||
// ---- Lifecycle -------------------------------------------------------------
|
||||
|
||||
for (const kind of ["office", "city"] as const) {
|
||||
test(`apply(..., "${kind}") leaves a texture on the scene and dispose frees it`, () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
rig.apply(scene, lightingState(), kind);
|
||||
|
||||
assert.ok(
|
||||
scene.environment instanceof THREE.Texture,
|
||||
"apply must leave a real texture on the scene, not a placeholder",
|
||||
);
|
||||
assert.equal(scene.environmentIntensity, 1);
|
||||
assert.ok(fake.renders > 0, "the PMREM chain never rendered");
|
||||
|
||||
// The PMREM target, identified by the texture the scene is holding.
|
||||
const target = targetOf(fake, scene);
|
||||
let freed = false;
|
||||
target.addEventListener("dispose", () => {
|
||||
freed = true;
|
||||
});
|
||||
|
||||
rig.dispose();
|
||||
|
||||
assert.equal(scene.environment, null, "dispose must take the environment back off the scene");
|
||||
assert.ok(freed, "dispose must free the PMREM render target, not just drop the reference");
|
||||
});
|
||||
}
|
||||
|
||||
test("apply adds nothing to the scene graph", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const scene = new THREE.Scene();
|
||||
scene.add(new THREE.Object3D());
|
||||
|
||||
rig.apply(scene, lightingState(), "office");
|
||||
rig.apply(scene, lightingState(), "city");
|
||||
|
||||
// The environment is a property, not a child. A rig that parented its probe
|
||||
// room into the caller's scene would light the office with a nine-quad box
|
||||
// floating inside it.
|
||||
assert.equal(scene.children.length, 1);
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
test("dispose clears every scene the rig ever wrote to", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const a = new THREE.Scene();
|
||||
const b = new THREE.Scene();
|
||||
|
||||
rig.apply(a, lightingState(), "city");
|
||||
rig.apply(b, lightingState(), "city");
|
||||
assert.ok(a.environment);
|
||||
assert.ok(b.environment);
|
||||
|
||||
rig.dispose();
|
||||
assert.equal(a.environment, null);
|
||||
assert.equal(b.environment, null);
|
||||
});
|
||||
|
||||
test("apply after dispose is inert rather than fatal", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const scene = new THREE.Scene();
|
||||
rig.dispose();
|
||||
|
||||
rig.apply(scene, lightingState(), "city");
|
||||
assert.equal(scene.environment, null);
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
// ---- Caching ---------------------------------------------------------------
|
||||
|
||||
test("an unchanged lighting state does not rebuild", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
rig.apply(scene, lightingState(), "city");
|
||||
const first = scene.environment;
|
||||
const after = fake.renders;
|
||||
|
||||
for (let i = 0; i < 20; i++) rig.apply(scene, lightingState(), "city");
|
||||
|
||||
assert.equal(scene.environment, first, "the environment texture was replaced for no reason");
|
||||
assert.equal(fake.renders, after, "the PMREM chain ran again for an unchanged sky");
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
test("a change below the quantisation step does not rebuild", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
rig.apply(scene, lightingState(), "city");
|
||||
const first = scene.environment;
|
||||
|
||||
// `Atmosphere` interpolates continuously, so every field of a LightingState
|
||||
// moves a fraction every frame. Rebuilding on that would run the whole chain
|
||||
// sixty times a second to produce sixty indistinguishable environments.
|
||||
rig.apply(
|
||||
scene,
|
||||
lightingState({
|
||||
sun: { direction: [0.3104, 0.8601, 0.4002], color: 0xfff3e1, intensity: 2.352 },
|
||||
}),
|
||||
"city",
|
||||
);
|
||||
|
||||
assert.equal(scene.environment, first);
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
test("a real change of hour rebuilds, and frees what it replaced", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
rig.apply(scene, lightingState(), "city");
|
||||
const noon = targetOf(fake, scene);
|
||||
let freed = false;
|
||||
noon.addEventListener("dispose", () => {
|
||||
freed = true;
|
||||
});
|
||||
|
||||
rig.apply(
|
||||
scene,
|
||||
lightingState({
|
||||
sun: { direction: [0.86, 0.06, -0.5], color: 0xc2795c, intensity: 0.58 },
|
||||
sky: { top: 0x2a4275, horizon: 0x9a6a63 },
|
||||
}),
|
||||
"city",
|
||||
);
|
||||
|
||||
assert.notEqual(scene.environment, noon.texture, "sunset must not reflect noon's sky");
|
||||
assert.ok(freed, "the replaced PMREM target leaked");
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
test("city and office are cached separately and do not evict each other", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const city = new THREE.Scene();
|
||||
const office = new THREE.Scene();
|
||||
|
||||
rig.apply(city, lightingState(), "city");
|
||||
const cityEnv = city.environment;
|
||||
rig.apply(office, lightingState(), "office");
|
||||
const officeEnv = office.environment;
|
||||
|
||||
assert.notEqual(cityEnv, officeEnv, "a room and a sky must not be the same environment");
|
||||
|
||||
const before = fake.renders;
|
||||
rig.apply(city, lightingState(), "city");
|
||||
assert.equal(city.environment, cityEnv);
|
||||
assert.equal(fake.renders, before, "the office build evicted the city's cached environment");
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
test("rebuilding one kind does not repoint scenes on the other", () => {
|
||||
// A page holds both at once — CONTRACT.md §1 keeps the city alive and paused
|
||||
// while an office is on screen — so the loop that repoints scenes after a
|
||||
// rebuild has to know which kind each scene is on. Getting this wrong lights
|
||||
// an office through a wall with the city's sunset.
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const city = new THREE.Scene();
|
||||
const office = new THREE.Scene();
|
||||
|
||||
rig.apply(city, lightingState(), "city");
|
||||
rig.apply(office, lightingState(), "office");
|
||||
const officeEnv = office.environment;
|
||||
|
||||
rig.apply(
|
||||
city,
|
||||
lightingState({
|
||||
sun: { direction: [0.86, 0.06, -0.5], color: 0xc2795c, intensity: 0.58 },
|
||||
sky: { top: 0x2a4275, horizon: 0x9a6a63 },
|
||||
}),
|
||||
"city",
|
||||
);
|
||||
|
||||
assert.equal(office.environment, officeEnv, "the office was handed the city's new sky");
|
||||
assert.notEqual(city.environment, officeEnv);
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
test("a scene left holding a freed target is repointed at the replacement", () => {
|
||||
// The other half of the same loop: two city scenes, one rebuild, and the one
|
||||
// that did not ask must not be left pointing at a disposed render target.
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const a = new THREE.Scene();
|
||||
const b = new THREE.Scene();
|
||||
|
||||
rig.apply(a, lightingState(), "city");
|
||||
rig.apply(b, lightingState(), "city");
|
||||
|
||||
rig.apply(
|
||||
a,
|
||||
lightingState({ sun: { direction: [0.86, 0.06, -0.5], color: 0xc2795c, intensity: 0.58 } }),
|
||||
"city",
|
||||
);
|
||||
|
||||
assert.equal(b.environment, a.environment, "the second city scene kept a freed texture");
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
// ---- Degradation -----------------------------------------------------------
|
||||
|
||||
test("a renderer that throws costs the environment, not the frame", () => {
|
||||
const fake = fakeRenderer();
|
||||
const rig = createEnvironmentRig(fake.as());
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
const warnings: unknown[] = [];
|
||||
const warn = console.warn;
|
||||
console.warn = (...args: unknown[]) => warnings.push(args);
|
||||
try {
|
||||
fake.failNext = true;
|
||||
rig.apply(scene, lightingState(), "city");
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
}
|
||||
|
||||
assert.equal(scene.environment, null, "a failed build must not leave a half-made environment");
|
||||
assert.equal(warnings.length, 1, "a missing environment is undiagnosable from the picture alone");
|
||||
|
||||
// And it recovers: the next apply tries again rather than latching off.
|
||||
rig.apply(scene, lightingState(), "city");
|
||||
const recovered: unknown = scene.environment;
|
||||
assert.ok(recovered instanceof THREE.Texture);
|
||||
rig.dispose();
|
||||
});
|
||||
|
||||
// ---- CONTRACT §4 -----------------------------------------------------------
|
||||
|
||||
test("the rig constructs no light of any kind", () => {
|
||||
// The literal form of the rule, checked against the source, because the whole
|
||||
// value of CONTRACT.md §4 is that there is exactly one light owner and this is
|
||||
// the module most likely to be tempted into becoming a second one.
|
||||
const source = readFileSync(path.join(ROOT, "src/engine/environmentRig.ts"), "utf8");
|
||||
const lights = source.match(
|
||||
/new\s+THREE\.(Directional|Point|Spot|Rect(Area)?|Hemisphere|Ambient|Light\b)/g,
|
||||
);
|
||||
assert.equal(lights, null, `environmentRig constructs lights: ${lights?.join(", ")}`);
|
||||
// And no import of three's fixed studio room either — the office probe has to
|
||||
// stay derived from the LightingState, or the reflections stop knowing what
|
||||
// time it is. (The prose above the code discusses `RoomEnvironment` by name,
|
||||
// hence matching the import statement rather than the word.)
|
||||
assert.doesNotMatch(source, /^\s*import[^\n]*RoomEnvironment/m);
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* The material roles, and the two new texture channels they bind.
|
||||
*
|
||||
* Four roles land here that three other workstreams are waiting on —
|
||||
* `deviceShell`, `deviceMesh`, `deviceIndicator`, `screenContent` — plus the
|
||||
* `alphaMap` wiring that turns `foliage` from a flat green shard into a leaf.
|
||||
* The assertions are deliberately about *bindings* rather than about numbers:
|
||||
* a roughness value is a judgement and will be re-tuned, but a `screenContent`
|
||||
* that has stopped carrying an `emissiveMap` is a screen that has gone back to
|
||||
* being a lamp, and a `foliage` with no `alphaMap` is the worst-looking asset in
|
||||
* the product returning.
|
||||
*
|
||||
* The registry is fed a stub `TextureBin` throughout. `node --test` has no
|
||||
* canvas, so the real bin correctly returns `null` for everything (see its own
|
||||
* comment about the zero-config boot), and a null map cannot demonstrate that
|
||||
* the map was bound to the right slot. The stub hands back real
|
||||
* `THREE.Texture`s named after what was asked for, so every assertion below is
|
||||
* about the code in `MaterialRegistry.create`, which is the code under test.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { MaterialRegistry, type SurfaceRole } from "../../assets/materials.ts";
|
||||
import {
|
||||
DEFAULT_INTERIOR_PALETTE,
|
||||
LIGHTNESS_HEADROOM,
|
||||
ROLE_SHIFTS,
|
||||
derivePalette,
|
||||
} from "../../assets/palette.ts";
|
||||
import {
|
||||
NORMAL_MAP_KINDS,
|
||||
SCREEN_UI_VARIANTS,
|
||||
TextureBin,
|
||||
type TextureKind,
|
||||
} from "../../assets/textures.ts";
|
||||
|
||||
/** A bin that draws nothing but answers as though it had. */
|
||||
class StubBin extends TextureBin {
|
||||
readonly asked: string[] = [];
|
||||
|
||||
override get(kind: TextureKind, variant = 0): THREE.Texture | null {
|
||||
this.asked.push(`${kind}#${variant}`);
|
||||
const texture = new THREE.Texture();
|
||||
texture.name = `${kind}#${variant}`;
|
||||
return texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stubbed for the same reason `get` is, with one difference that matters: the
|
||||
* real `normal()` needs no canvas and would work here, but it would also build
|
||||
* six 512² fields per registry. What is under test is the *binding*, so the
|
||||
* stub keeps the one property an assertion can hang off — relief exists for
|
||||
* exactly the kinds that have a recipe and for no others.
|
||||
*/
|
||||
override normal(kind: TextureKind): THREE.Texture | null {
|
||||
if (!NORMAL_MAP_KINDS.includes(kind)) return null;
|
||||
this.asked.push(`${kind}!normal`);
|
||||
const texture = new THREE.Texture();
|
||||
texture.name = `${kind}!normal`;
|
||||
return texture;
|
||||
}
|
||||
|
||||
override variants(kind: TextureKind): number {
|
||||
return kind === "screenUI" ? SCREEN_UI_VARIANTS : 1;
|
||||
}
|
||||
}
|
||||
|
||||
function registry(quality: "low" | "medium" | "high" = "high"): {
|
||||
materials: MaterialRegistry;
|
||||
bin: StubBin;
|
||||
} {
|
||||
const bin = new StubBin(quality);
|
||||
return { materials: new MaterialRegistry({ quality, textures: bin }), bin };
|
||||
}
|
||||
|
||||
const NEW_ROLES: SurfaceRole[] = ["deviceShell", "deviceMesh", "deviceIndicator", "screenContent"];
|
||||
|
||||
// ---- The published names ---------------------------------------------------
|
||||
|
||||
test("the registry exposes the four device roles", () => {
|
||||
const { materials } = registry();
|
||||
for (const role of NEW_ROLES) {
|
||||
const material = materials.get(role);
|
||||
assert.ok(material instanceof THREE.Material, `${role} did not resolve to a material`);
|
||||
assert.equal(material.name, role);
|
||||
// One material per role per registry — the sharing that is half the
|
||||
// draw-call budget.
|
||||
assert.equal(materials.get(role), material);
|
||||
}
|
||||
});
|
||||
|
||||
test("every new role carries a palette derivation", () => {
|
||||
// The compiler enforces this for `ROLE_SHIFTS`, but not that the shift
|
||||
// actually produced a colour, and a role missing from the derived palette
|
||||
// would silently construct a material with `color: undefined` (black).
|
||||
for (const role of NEW_ROLES) {
|
||||
assert.ok(role in ROLE_SHIFTS, `${role} has no shift`);
|
||||
const color = DEFAULT_INTERIOR_PALETTE[role];
|
||||
assert.equal(typeof color, "number");
|
||||
assert.ok(color >= 0 && color <= 0xffffff);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- What each new role is -------------------------------------------------
|
||||
|
||||
test("the device shell and grille are metal, which they can now afford to be", () => {
|
||||
const { materials } = registry();
|
||||
const shell = materials.get("deviceShell") as THREE.MeshStandardMaterial;
|
||||
const mesh = materials.get("deviceMesh") as THREE.MeshStandardMaterial;
|
||||
|
||||
assert.ok(shell.metalness > 0.15, "a device body with no metalness reads as painted plastic");
|
||||
assert.ok(mesh.metalness > 0.6, "a speaker grille is metal");
|
||||
// Double-sided: you see through a grille to the inside of the housing, and
|
||||
// that is most of what makes a speaker look like a speaker.
|
||||
assert.equal(mesh.side, THREE.DoubleSide);
|
||||
assert.equal(shell.side, THREE.FrontSide);
|
||||
});
|
||||
|
||||
test("the indicator emits, and a tint carries into the emission", () => {
|
||||
const { materials } = registry();
|
||||
const led = materials.get("deviceIndicator") as THREE.MeshStandardMaterial;
|
||||
assert.equal(led.emissiveIntensity, 1, "an LED is a light source, not a lit surface");
|
||||
assert.notEqual(led.emissive.getHex(), 0x000000);
|
||||
|
||||
// The device render layer tints this per state. That path must reach the
|
||||
// emissive term, or a powered mic and an unpowered one glow the same colour.
|
||||
const hot = materials.tinted("deviceIndicator", 0xff3a1e) as THREE.MeshStandardMaterial;
|
||||
assert.notEqual(hot, led);
|
||||
assert.equal(hot.color.getHex(THREE.SRGBColorSpace), 0xff3a1e);
|
||||
assert.equal(hot.emissive.getHex(THREE.SRGBColorSpace), 0xff3a1e);
|
||||
// Cached: a hundred indicators in three states are three materials.
|
||||
assert.equal(materials.tinted("deviceIndicator", 0xff3a1e), hot);
|
||||
});
|
||||
|
||||
test("screen content is lit through its own map, not flat across the panel", () => {
|
||||
const { materials } = registry();
|
||||
const screen = materials.get("screenContent") as THREE.MeshStandardMaterial;
|
||||
|
||||
assert.ok(screen.map, "screenContent must carry the drawn interface as its colour map");
|
||||
assert.equal(screen.map?.name, "screenUI#0");
|
||||
assert.ok(screen.emissiveMap, "screenContent must emit through the map, or it is a light box");
|
||||
assert.equal(screen.emissiveMap, screen.map, "the emissive map must be the same drawing");
|
||||
// White emissive, so the drawn colours are not tinted a second time on top of
|
||||
// `color` already tinting them.
|
||||
assert.equal(screen.emissive.getHex(), 0xffffff);
|
||||
assert.ok(screen.emissiveIntensity > 0.5);
|
||||
|
||||
// And the palette gets out of the map's way: this is the one non-neutral
|
||||
// texture in the library, so a mid-grey role colour would multiply it to mud.
|
||||
const hsl = { h: 0, s: 0, l: 0 };
|
||||
new THREE.Color(DEFAULT_INTERIOR_PALETTE.screenContent).getHSL(hsl, THREE.SRGBColorSpace);
|
||||
assert.ok(hsl.l > 0.8, `screenContent is L=${hsl.l.toFixed(2)}, too dark to pass a drawing through`);
|
||||
});
|
||||
|
||||
test("screen variants are separate cached materials, and variant 0 is the base", () => {
|
||||
const { materials } = registry();
|
||||
const base = materials.get("screenContent");
|
||||
|
||||
assert.equal(materials.variant("screenContent", 0), base, "variant 0 must not mint a duplicate");
|
||||
|
||||
const seen = new Set<THREE.Material>();
|
||||
for (let i = 0; i < SCREEN_UI_VARIANTS; i++) seen.add(materials.variant("screenContent", i));
|
||||
assert.equal(seen.size, SCREEN_UI_VARIANTS, "layouts collapsed onto the same material");
|
||||
|
||||
// Cached, and wrapping rather than throwing: `furnish.ts` batches per kind, so
|
||||
// a caller hands this a running prop index and must not have to bound it.
|
||||
assert.equal(
|
||||
materials.variant("screenContent", 2),
|
||||
materials.variant("screenContent", 2 + SCREEN_UI_VARIANTS),
|
||||
);
|
||||
assert.equal(materials.variant("screenContent", -1), materials.variant("screenContent", SCREEN_UI_VARIANTS - 1));
|
||||
|
||||
// A role with one layout ignores the index entirely.
|
||||
assert.equal(materials.variant("carpet", 3), materials.get("carpet"));
|
||||
});
|
||||
|
||||
// ---- The alpha channel -----------------------------------------------------
|
||||
|
||||
test("foliage is a cutout, not a rectangle", () => {
|
||||
const { materials } = registry();
|
||||
const leaf = materials.get("foliage") as THREE.MeshStandardMaterial;
|
||||
|
||||
assert.ok(leaf.alphaMap, "foliage without an alphaMap is the flat green shard on the live site");
|
||||
assert.equal(leaf.alphaMap?.name, "leafAlpha#0");
|
||||
assert.ok(leaf.alphaTest > 0.2 && leaf.alphaTest < 0.8, `alphaTest ${leaf.alphaTest} is at an edge of the range`);
|
||||
// Cutout, not blend: a leaf still writes depth, sorts as solid geometry and
|
||||
// casts a correctly-shaped shadow.
|
||||
assert.equal(leaf.transparent, false);
|
||||
assert.equal(leaf.depthWrite, true);
|
||||
assert.equal(leaf.side, THREE.DoubleSide);
|
||||
});
|
||||
|
||||
test("a role with no alpha texture is not given an alphaTest", () => {
|
||||
const { materials } = registry();
|
||||
const carpet = materials.get("carpet") as THREE.MeshStandardMaterial;
|
||||
assert.equal(carpet.alphaMap, null);
|
||||
// A threshold with no map to test against compiles a branch into the shader
|
||||
// for a comparison that always passes.
|
||||
assert.equal(carpet.alphaTest, 0);
|
||||
});
|
||||
|
||||
test("the leaf keeps its shape when it is ghosted", () => {
|
||||
const { materials } = registry();
|
||||
const ghost = materials.ghostOf("foliage");
|
||||
// The colour map is decoration and is dropped on purpose; the coverage map is
|
||||
// *shape*, and a ghost with no cutout is the shard again at 18% opacity.
|
||||
assert.equal(ghost.map, null);
|
||||
assert.ok(ghost.alphaMap, "ghostOf must not drop the coverage map with the colour map");
|
||||
assert.equal(ghost.transparent, true);
|
||||
});
|
||||
|
||||
test("low quality still cuts the leaf out", () => {
|
||||
// `low` means no *shading* maps. An alpha cutout is one fetch and a discard,
|
||||
// and the alternative at low quality is not a cheaper plant, it is a shard.
|
||||
const { materials } = registry("low");
|
||||
const leaf = materials.get("foliage") as THREE.MeshLambertMaterial;
|
||||
assert.ok(leaf instanceof THREE.MeshLambertMaterial, "low quality must stay Lambert");
|
||||
assert.ok(leaf.alphaMap);
|
||||
assert.ok(leaf.alphaTest > 0);
|
||||
});
|
||||
|
||||
// ---- The widened lightness band --------------------------------------------
|
||||
|
||||
test("the band was widened, and the darkest roles actually went darker", () => {
|
||||
assert.ok(LIGHTNESS_HEADROOM > 0.14, "the headroom was not relaxed");
|
||||
|
||||
const palette = derivePalette();
|
||||
const hsl = { h: 0, s: 0, l: 0 };
|
||||
const lightnessOf = (role: SurfaceRole) => {
|
||||
new THREE.Color(palette[role]).getHSL(hsl, THREE.SRGBColorSpace);
|
||||
return hsl.l;
|
||||
};
|
||||
|
||||
// Under the old 0.14 headroom the floor of the band was L≈0.248 and these
|
||||
// three were all clamped to it, which is why a screen bezel was a mid-grey.
|
||||
for (const role of ["screenBezel", "deviceShell", "deviceMesh"] as SurfaceRole[]) {
|
||||
const l = lightnessOf(role);
|
||||
assert.ok(l < 0.248, `${role} is L=${l.toFixed(3)}, still clamped up into mid-grey`);
|
||||
assert.ok(l > 0.05, `${role} is L=${l.toFixed(3)}, past charcoal into black`);
|
||||
}
|
||||
|
||||
// Saturation gets no concession, and widening the lightness band must not
|
||||
// have quietly widened that too: every role stays inside the city's own
|
||||
// saturation range.
|
||||
const city = Object.values(palette).map((hex) => {
|
||||
new THREE.Color(hex).getHSL(hsl, THREE.SRGBColorSpace);
|
||||
return hsl.s;
|
||||
});
|
||||
assert.ok(Math.max(...city) <= 0.6, "an interior role has become more chromatic than the city");
|
||||
});
|
||||
|
||||
// ---- Glass -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The one role that changes material *class* with quality.
|
||||
*
|
||||
* A 22%-opacity blend is a grey film. Transmission is glass: it refracts what
|
||||
* is behind it, keeps a specular highlight and an environment reflection on top,
|
||||
* and turns `roughness` into frosting instead of into a matte grey. What must
|
||||
* survive the change is the note at the role itself — glass writes no depth —
|
||||
* because three.js's own advice for a transmissive material is the opposite,
|
||||
* and an office is a box of glass boxes where the first sheet the sorter reaches
|
||||
* would erase the two behind it.
|
||||
*/
|
||||
test("glazing at medium and high is physical glass that still writes no depth", () => {
|
||||
for (const quality of ["medium", "high"] as const) {
|
||||
const { materials } = registry(quality);
|
||||
const glass = materials.get("glazing");
|
||||
assert.ok(
|
||||
glass instanceof THREE.MeshPhysicalMaterial,
|
||||
`glazing at ${quality} is ${glass.type}, not physical glass`,
|
||||
);
|
||||
const physical = glass as THREE.MeshPhysicalMaterial;
|
||||
assert.ok(physical.transmission > 0, "glazing has no transmission");
|
||||
assert.equal(physical.ior, 1.5, "glazing is no longer soda-lime glass");
|
||||
assert.ok(physical.thickness > 0, "glazing has no thickness to refract through");
|
||||
// The line the spec asked to be kept, kept.
|
||||
assert.equal(physical.depthWrite, false, "glass started writing depth");
|
||||
// Transmission carries the see-through; blending it as well would leave the
|
||||
// sheet four fifths invisible and refracting the fifth that was left.
|
||||
assert.equal(physical.transparent, false);
|
||||
assert.equal(physical.opacity, 1);
|
||||
// three.js scales transmission by `1 - metalness`, so any metalness at all
|
||||
// is that fraction of the glass quietly turned back into a mirror.
|
||||
assert.equal(physical.metalness, 0);
|
||||
assert.equal(physical.side, THREE.DoubleSide);
|
||||
}
|
||||
});
|
||||
|
||||
test("glazing at low falls back to a blended sheet rather than to nothing", () => {
|
||||
const { materials } = registry("low");
|
||||
const glass = materials.get("glazing");
|
||||
// `low` is the integrated-GPU setting and a transmission pass is a full
|
||||
// render-target copy, so the blend has to stay reachable — and it is still a
|
||||
// window, so it still must not write depth.
|
||||
assert.ok(glass instanceof THREE.MeshLambertMaterial);
|
||||
assert.equal(glass.transparent, true);
|
||||
assert.ok(glass.opacity < 0.5);
|
||||
assert.equal(glass.depthWrite, false);
|
||||
});
|
||||
|
||||
test("a ghosted sheet of glass stops refracting", () => {
|
||||
const { materials } = registry("high");
|
||||
const ghost = materials.ghostOf("glazing") as THREE.MeshPhysicalMaterial;
|
||||
// The occlusion fade is a hint and is rebuilt as the camera moves; putting it
|
||||
// through the transmission pass buys nothing and costs a target copy.
|
||||
assert.equal(ghost.transmission, 0);
|
||||
assert.equal(ghost.transparent, true);
|
||||
assert.equal(ghost.depthWrite, false);
|
||||
});
|
||||
|
||||
// ---- The relief channel ----------------------------------------------------
|
||||
|
||||
test("every role with a texture also carries its relief", () => {
|
||||
const { materials } = registry("high");
|
||||
// Six kinds have relief; a whiteboard and a screen are flat. A role gets the
|
||||
// relief of its own texture or nothing — there is no third option, and no role
|
||||
// opts in separately.
|
||||
const expected: [SurfaceRole, boolean][] = [
|
||||
["carpet", true],
|
||||
["woodFloor", true],
|
||||
["tile", true],
|
||||
["ceilingTile", true],
|
||||
["plaster", true],
|
||||
["chairFabric", true],
|
||||
["whiteboard", false],
|
||||
["screenContent", false],
|
||||
["metalTrim", false],
|
||||
["deviceShell", false],
|
||||
];
|
||||
for (const [role, hasRelief] of expected) {
|
||||
const material = materials.get(role) as THREE.MeshStandardMaterial;
|
||||
if (hasRelief) {
|
||||
assert.ok(material.normalMap, `${role} lost its normal map`);
|
||||
assert.match(material.normalMap.name, /!normal$/);
|
||||
} else {
|
||||
assert.equal(material.normalMap, null, `${role} gained relief it has no surface for`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("relief follows a tint and a variant, because it is the same surface", () => {
|
||||
const { materials } = registry("high");
|
||||
const tinted = materials.tinted("carpet", 0x884422) as THREE.MeshStandardMaterial;
|
||||
assert.ok(tinted.normalMap, "a recoloured carpet lost its pile");
|
||||
const variant = materials.variant("screenContent", 3) as THREE.MeshStandardMaterial;
|
||||
assert.equal(variant.normalMap, null, "a screen layout grew relief");
|
||||
});
|
||||
|
||||
test("low quality binds no relief at all", () => {
|
||||
const { materials } = registry("low");
|
||||
const carpet = materials.get("carpet") as THREE.MeshLambertMaterial;
|
||||
// `low` exists to compile the cheap shader. A normal map is a fetch and a
|
||||
// matrix multiply per fragment, which is exactly the cost it is refusing.
|
||||
assert.ok(carpet instanceof THREE.MeshLambertMaterial);
|
||||
assert.equal(carpet.normalMap, null);
|
||||
});
|
||||
|
||||
test("a ghost drops the relief with the colour, for the same reason", () => {
|
||||
const { materials } = registry("high");
|
||||
const ghost = materials.ghostOf("carpet") as THREE.MeshStandardMaterial;
|
||||
assert.equal(ghost.map, null);
|
||||
assert.equal(ghost.normalMap, null, "an 82%-transparent surface is still being bumped");
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* The draw-call reclaim in `engine/structures.ts`.
|
||||
*
|
||||
* These are budget tests, and they are here because the budget is the reason
|
||||
* anything else in this build can be made to look better. The city measured 616
|
||||
* draw calls against a cap of 650 while the office spent 8% of its triangle
|
||||
* allowance: indoors quality is nearly free, outdoors it is not, and every call
|
||||
* this module gives back is one the exterior Model X and the aircraft get to
|
||||
* spend. `scripts/performance-budget.mjs` is the real gate, but it needs a
|
||||
* built bundle, a browser and eleven seconds a cell — these run in
|
||||
* milliseconds and fail on the line that caused the regression.
|
||||
*
|
||||
* Two invariants, and they are the two ways this file has gone wrong before:
|
||||
*
|
||||
* 1. **A material is per colour, not per call site.** `roadRibbon` used to
|
||||
* close over `new THREE.MeshLambertMaterial({ color })`, so twelve
|
||||
* identical asphalt decks were twelve materials — and two meshes that do
|
||||
* not share a material can never be merged, whatever else you do.
|
||||
* 2. **Geometry is merged per bucket.** A suspension bridge used to arrive as
|
||||
* about thirty-four meshes of one colour.
|
||||
*
|
||||
* There is a third thing the tests below quietly guard, and it is the one that
|
||||
* fails silently: `mergeGeometries` returns `null` when the attribute sets
|
||||
* disagree, so a ribbon without UVs sitting in a bucket beside a tube that has
|
||||
* them loses the whole bucket. Asserting on merged vertex counts is what catches
|
||||
* that, because a dropped bucket looks exactly like a very efficient one.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createBridge, createBridges, createRoads } from "../../engine/structures.ts";
|
||||
import type { Bridge, City, Road } from "../../engine/types.ts";
|
||||
import type { World } from "../../engine/world.ts";
|
||||
|
||||
/**
|
||||
* The smallest thing `structures.ts` will accept: a flat projection, ground at
|
||||
* zero, and metres straight through.
|
||||
*
|
||||
* A real `World` builds a heightfield, which is 0.53M lattice points and a
|
||||
* couple of seconds — none of which any assertion here depends on.
|
||||
*/
|
||||
function flatWorld(city: Partial<City>): World {
|
||||
return {
|
||||
city: { roads: [], bridges: [], inlandWater: [], ...city } as unknown as City,
|
||||
project(lat: number, lng: number): [number, number] {
|
||||
return [(lng + 122) * 20, -(lat - 37) * 20];
|
||||
},
|
||||
groundAt(): number {
|
||||
return 0;
|
||||
},
|
||||
metres(value: number): number {
|
||||
return value / 100;
|
||||
},
|
||||
} as unknown as World;
|
||||
}
|
||||
|
||||
const GOLDEN_GATE: Bridge = {
|
||||
name: "golden-gate",
|
||||
path: [
|
||||
[37.806, -122.4756],
|
||||
[37.8199, -122.4783],
|
||||
[37.8324, -122.4796],
|
||||
],
|
||||
towers: [
|
||||
[37.8104, -122.4767],
|
||||
[37.8249, -122.4787],
|
||||
],
|
||||
deckHeight: 67,
|
||||
towerHeight: 227,
|
||||
sag: 0.45,
|
||||
color: 0xc0553b,
|
||||
};
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function materialsIn(root: THREE.Object3D): Set<THREE.Material> {
|
||||
const set = new Set<THREE.Material>();
|
||||
for (const mesh of meshes(root)) {
|
||||
if (Array.isArray(mesh.material)) for (const material of mesh.material) set.add(material);
|
||||
else set.add(mesh.material);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
// ---- Bridges ---------------------------------------------------------------
|
||||
|
||||
test("a suspension bridge is one material and one draw call", () => {
|
||||
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
|
||||
|
||||
// The spec's number is six; a bridge is painted one colour throughout, so
|
||||
// anything above one is a part that was left out of the bucket.
|
||||
const distinct = materialsIn(bridge);
|
||||
assert.ok(distinct.size <= 6, `the bridge holds ${distinct.size} materials`);
|
||||
assert.equal(distinct.size, 1, `the bridge holds ${distinct.size} materials, not one`);
|
||||
assert.equal(meshes(bridge).length, 1, "the bridge did not merge into one mesh");
|
||||
});
|
||||
|
||||
test("merging kept every part of the bridge", () => {
|
||||
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
|
||||
const merged = meshes(bridge)[0];
|
||||
assert.ok(merged);
|
||||
|
||||
// The arithmetic, because a bucket that failed to merge comes out as one
|
||||
// *span* of geometry and otherwise looks entirely healthy: a 3-point deck tube
|
||||
// is 7 × 5 = 35 vertices, two towers and four braces are 24 each = 144, three
|
||||
// cable spans at 25 × 6 = 450, and the hangers are 24 boxes of 24 less
|
||||
// whichever ones the deck-clearance test culls — call it 1,000 at the floor.
|
||||
const vertices = merged.geometry.getAttribute("position").count;
|
||||
assert.ok(vertices > 1_000, `the bridge merged down to ${vertices} vertices`);
|
||||
|
||||
// The merge only happens because every part carries the same attributes.
|
||||
for (const name of ["position", "normal", "uv"]) {
|
||||
assert.ok(merged.geometry.getAttribute(name), `the merged bridge has no ${name}`);
|
||||
}
|
||||
assert.ok(merged.geometry.getIndex(), "the merged bridge lost its index");
|
||||
|
||||
// A 227 m tower is the tallest thing on the board; it has to cast.
|
||||
assert.equal(merged.castShadow, true);
|
||||
});
|
||||
|
||||
test("the bridge is still shaped like a bridge after the merge", () => {
|
||||
const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
|
||||
const merged = meshes(bridge)[0];
|
||||
assert.ok(merged);
|
||||
merged.geometry.computeBoundingBox();
|
||||
const box = merged.geometry.boundingBox;
|
||||
assert.ok(box);
|
||||
|
||||
// Towers to 2.27 units, deck at 0.67, cables sagging between. Baking the
|
||||
// transforms into the geometry is where a merge goes wrong — a part that lost
|
||||
// its translation collapses onto the origin and the box stops matching.
|
||||
assert.ok(Math.abs(box.max.y - 2.27) < 0.05, `the towers top out at ${box.max.y.toFixed(2)}`);
|
||||
assert.ok(box.min.y > 0, "something sank below the water line");
|
||||
assert.ok(box.max.x - box.min.x > 0.4, "the bridge has no span");
|
||||
});
|
||||
|
||||
test("two bridges are two draw calls, not sixty-eight", () => {
|
||||
const second: Bridge = { ...GOLDEN_GATE, name: "bay-bridge", color: 0x9aa6ad };
|
||||
const group = createBridges(flatWorld({ bridges: [GOLDEN_GATE, second] }));
|
||||
assert.equal(meshes(group).length, 2);
|
||||
// Different colours, so genuinely two materials. Each bridge builds its own
|
||||
// batch, which is deliberate: the cache cannot outlive the build, because
|
||||
// `createScene().dispose()` walks the scene disposing every material it finds
|
||||
// and a shared cache would hand the next board a disposed one.
|
||||
assert.equal(materialsIn(group).size, 2);
|
||||
});
|
||||
|
||||
// ---- Roads -----------------------------------------------------------------
|
||||
|
||||
test("identical roads share one material and one mesh", () => {
|
||||
const street: Road = {
|
||||
kind: "street",
|
||||
width: 0.1,
|
||||
path: [
|
||||
[37.7, -122.4],
|
||||
[37.75, -122.42],
|
||||
[37.8, -122.45],
|
||||
],
|
||||
};
|
||||
const group = createRoads(flatWorld({ roads: [street, street, street] }));
|
||||
|
||||
// Three streets, one colour: one draw call. Before the cache this was three
|
||||
// materials and three meshes, and it scaled with the pack.
|
||||
assert.equal(materialsIn(group).size, 1);
|
||||
assert.equal(meshes(group).length, 1);
|
||||
|
||||
const merged = meshes(group)[0];
|
||||
assert.ok(merged);
|
||||
// All three really are in there — three drapes of the same path.
|
||||
const vertices = merged.geometry.getAttribute("position").count;
|
||||
assert.ok(vertices > 100, `three roads merged to ${vertices} vertices`);
|
||||
assert.ok(merged.geometry.getAttribute("uv"), "the road deck lost the UVs merging depends on");
|
||||
});
|
||||
|
||||
test("a freeway keeps its median stroke as a second material", () => {
|
||||
const freeway: Road = {
|
||||
kind: "freeway",
|
||||
width: 0.14,
|
||||
path: [
|
||||
[37.7, -122.4],
|
||||
[37.9, -122.45],
|
||||
],
|
||||
};
|
||||
const group = createRoads(flatWorld({ roads: [freeway] }));
|
||||
// Two colours is two calls, and that is the floor rather than a regression:
|
||||
// the stroke is a different colour from the deck it sits on.
|
||||
assert.equal(meshes(group).length, 2);
|
||||
assert.equal(materialsIn(group).size, 2);
|
||||
});
|
||||
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* The two new texture kinds, and the bin that parameterises them.
|
||||
*
|
||||
* Drawing is done against a recording 2D context rather than a real one. There
|
||||
* is no canvas under `node --test`, and a real rasteriser would only let these
|
||||
* tests assert about pixels — which is a picture, which is exactly the thing
|
||||
* nobody should be asserting equality on. What *can* be pinned, and matters, is
|
||||
* the structure: that six genuinely different layouts exist rather than one
|
||||
* drawn six times, that the same variant is byte-identical run to run (the
|
||||
* drawings are seeded, and a `Math.random` slipping in would make an office
|
||||
* different on every reload), that the leaf stays inside the quad it is cut out
|
||||
* of, and that each kind gets the wrap mode and colour space its *use* demands.
|
||||
*
|
||||
* That last one is the subtle failure this file is really guarding. An
|
||||
* `alphaMap` sampled through an sRGB decode shifts every coverage value — a
|
||||
* cutout authored at 0.5 arrives at 0.21 — and the symptom is half a leaf, at
|
||||
* runtime, with nothing in the source looking wrong.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import {
|
||||
NORMAL_MAP_KINDS,
|
||||
SCREEN_UI_VARIANTS,
|
||||
TextureBin,
|
||||
type TextureKind,
|
||||
} from "../../assets/textures.ts";
|
||||
|
||||
// ---- A 2D context that records instead of rasterising -----------------------
|
||||
|
||||
/** One entry per drawing, in the order the canvases were created. */
|
||||
const logs: string[][] = [];
|
||||
|
||||
interface FakeCanvas {
|
||||
width: number;
|
||||
height: number;
|
||||
getContext(id: string): unknown;
|
||||
}
|
||||
|
||||
function installFakeDocument(): void {
|
||||
const document = {
|
||||
createElement(tag: string): FakeCanvas {
|
||||
if (tag !== "canvas") throw new Error(`unexpected element ${tag}`);
|
||||
const canvas: FakeCanvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext(): unknown {
|
||||
const log: string[] = [];
|
||||
logs.push(log);
|
||||
return makeContext(canvas, log);
|
||||
},
|
||||
};
|
||||
return canvas;
|
||||
},
|
||||
};
|
||||
(globalThis as { document?: unknown }).document = document;
|
||||
}
|
||||
|
||||
function makeContext(canvas: FakeCanvas, log: string[]): unknown {
|
||||
const record = (name: string, ...args: number[]) => {
|
||||
log.push(`${name}(${args.map((n) => n.toFixed(3)).join(",")})`);
|
||||
};
|
||||
const style = (name: string, value: unknown) => {
|
||||
log.push(`${name}=${String(value)}`);
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
_fillStyle: "",
|
||||
set fillStyle(v: unknown) {
|
||||
style("fillStyle", v);
|
||||
ctx._fillStyle = String(v);
|
||||
},
|
||||
get fillStyle() {
|
||||
return ctx._fillStyle;
|
||||
},
|
||||
set strokeStyle(v: unknown) {
|
||||
style("strokeStyle", v);
|
||||
},
|
||||
set lineWidth(v: number) {
|
||||
style("lineWidth", v);
|
||||
},
|
||||
set lineCap(v: string) {
|
||||
style("lineCap", v);
|
||||
},
|
||||
set globalCompositeOperation(v: string) {
|
||||
style("composite", v);
|
||||
},
|
||||
fillRect: (x: number, y: number, w: number, h: number) => record("fillRect", x, y, w, h),
|
||||
beginPath: () => log.push("beginPath()"),
|
||||
closePath: () => log.push("closePath()"),
|
||||
moveTo: (x: number, y: number) => record("moveTo", x, y),
|
||||
lineTo: (x: number, y: number) => record("lineTo", x, y),
|
||||
arc: (x: number, y: number, r: number) => record("arc", x, y, r),
|
||||
arcTo: (x1: number, y1: number, x2: number, y2: number, r: number) =>
|
||||
record("arcTo", x1, y1, x2, y2, r),
|
||||
bezierCurveTo: (a: number, b: number, c: number, d: number, e: number, f: number) =>
|
||||
record("bezierCurveTo", a, b, c, d, e, f),
|
||||
fill: () => log.push("fill()"),
|
||||
stroke: () => log.push("stroke()"),
|
||||
getImageData: (_x: number, _y: number, w: number, h: number) => {
|
||||
log.push(`getImageData(${w},${h})`);
|
||||
return { data: new Uint8ClampedArray(w * h * 4).fill(255), width: w, height: h };
|
||||
},
|
||||
putImageData: () => log.push("putImageData()"),
|
||||
};
|
||||
void canvas;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
installFakeDocument();
|
||||
|
||||
/** Draw one texture in isolation and hand back the texture and its op log. */
|
||||
function draw(
|
||||
kind: TextureKind,
|
||||
variant = 0,
|
||||
quality: "low" | "medium" | "high" = "high",
|
||||
): { texture: THREE.Texture | null; log: string[]; canvas: FakeCanvas | null } {
|
||||
const before = logs.length;
|
||||
const bin = new TextureBin(quality);
|
||||
const texture = bin.get(kind, variant);
|
||||
const log = logs[before] ?? [];
|
||||
const canvas = (texture?.image as FakeCanvas | undefined) ?? null;
|
||||
return { texture, log, canvas };
|
||||
}
|
||||
|
||||
// ---- screenUI ---------------------------------------------------------------
|
||||
|
||||
test("screenUI publishes at least the four variants the assets workstream needs", () => {
|
||||
assert.ok(SCREEN_UI_VARIANTS >= 4, `only ${SCREEN_UI_VARIANTS} screen layouts`);
|
||||
assert.equal(new TextureBin("high").variants("screenUI"), SCREEN_UI_VARIANTS);
|
||||
// Everything that tiles has exactly one.
|
||||
assert.equal(new TextureBin("high").variants("carpetLoop"), 1);
|
||||
});
|
||||
|
||||
test("every screen variant is a genuinely different drawing", () => {
|
||||
const signatures = new Set<string>();
|
||||
for (let v = 0; v < SCREEN_UI_VARIANTS; v++) {
|
||||
const { log } = draw("screenUI", v);
|
||||
assert.ok(log.length > 60, `variant ${v} drew only ${log.length} operations`);
|
||||
signatures.add(log.join("|"));
|
||||
}
|
||||
// A single layout drawn six times with a different seed would still differ,
|
||||
// so this is the weaker half of the claim; the stronger half is that the
|
||||
// module has six distinct layout functions, which the count below pins.
|
||||
assert.equal(signatures.size, SCREEN_UI_VARIANTS, "two screen variants draw the same picture");
|
||||
});
|
||||
|
||||
test("a screen variant is the same picture every time it is drawn", () => {
|
||||
// The drawings are seeded from the variant index. A `Math.random` anywhere in
|
||||
// this path would give a studio a different set of monitors on every reload,
|
||||
// and would break the byte-identical-for-a-seed property the asset library
|
||||
// promises everywhere else.
|
||||
for (const v of [0, 3, 5]) {
|
||||
const first = draw("screenUI", v).log.join("|");
|
||||
const second = draw("screenUI", v).log.join("|");
|
||||
assert.equal(first, second, `screen variant ${v} is not deterministic`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a screen is drawn in the proportions of a screen", () => {
|
||||
const { canvas } = draw("screenUI", 1);
|
||||
assert.ok(canvas);
|
||||
const aspect = (canvas?.width ?? 0) / (canvas?.height ?? 1);
|
||||
assert.ok(Math.abs(aspect - 16 / 9) < 0.02, `screen canvas aspect ${aspect.toFixed(3)}`);
|
||||
});
|
||||
|
||||
test("a screen clamps and carries colour", () => {
|
||||
const { texture } = draw("screenUI", 2);
|
||||
assert.ok(texture);
|
||||
// One image on one quad. Repeat wrapping here means a UV that overshoots by a
|
||||
// hair draws the right edge of the interface against the left one.
|
||||
assert.equal(texture?.wrapS, THREE.ClampToEdgeWrapping);
|
||||
assert.equal(texture?.wrapT, THREE.ClampToEdgeWrapping);
|
||||
// Content, not coverage: this is the one drawing in the library with its own
|
||||
// colour, and it is authored in sRGB.
|
||||
assert.equal(texture?.colorSpace, THREE.SRGBColorSpace);
|
||||
assert.equal(texture?.name, "screenUI#2");
|
||||
});
|
||||
|
||||
test("a screen draws no text and no logo", () => {
|
||||
// ARCHITECTURE.md §3.1: a screen drawing a recognisable interface is a screen
|
||||
// drawing somebody's trademark. There is no `fillText` in the fake context at
|
||||
// all, so a drawing that reached for one would throw — this asserts the
|
||||
// intent explicitly so the next person does not add one.
|
||||
for (let v = 0; v < SCREEN_UI_VARIANTS; v++) {
|
||||
const { log } = draw("screenUI", v);
|
||||
assert.ok(!log.some((op) => op.startsWith("fillText") || op.startsWith("drawImage")));
|
||||
}
|
||||
});
|
||||
|
||||
// ---- leafAlpha --------------------------------------------------------------
|
||||
|
||||
test("the leaf is coverage, not colour", () => {
|
||||
const { texture } = draw("leafAlpha");
|
||||
assert.ok(texture);
|
||||
// An sRGB decode on an alphaMap shifts every coverage value: a cutout drawn
|
||||
// at 0.5 arrives at 0.21 and `alphaTest` eats half the leaf.
|
||||
assert.equal(texture?.colorSpace, THREE.NoColorSpace);
|
||||
assert.equal(texture?.wrapS, THREE.ClampToEdgeWrapping);
|
||||
assert.equal(texture?.wrapT, THREE.ClampToEdgeWrapping);
|
||||
});
|
||||
|
||||
test("the leaf is drawn as a shape, black ground first", () => {
|
||||
const { log } = draw("leafAlpha");
|
||||
const firstFill = log.findIndex((op) => op.startsWith("fillRect"));
|
||||
assert.ok(firstFill > 0);
|
||||
assert.equal(log[firstFill - 1], "fillStyle=#000000", "the ground under the cutout must be empty");
|
||||
assert.ok(
|
||||
log.some((op) => op.startsWith("bezierCurveTo")),
|
||||
"a leaf outline drawn without curves is a rectangle with a different name",
|
||||
);
|
||||
// The serrations are bitten out and the mode is put back, or every drawing
|
||||
// after this one on the same context would erase instead of paint.
|
||||
const cut = log.indexOf("composite=destination-out");
|
||||
const restore = log.indexOf("composite=source-over");
|
||||
assert.ok(cut > 0, "no serrations were cut");
|
||||
assert.ok(restore > cut, "the composite mode was left in destination-out");
|
||||
});
|
||||
|
||||
test("the leaf stays inside the quad it is cut out of", () => {
|
||||
const { log, canvas } = draw("leafAlpha");
|
||||
const w = canvas?.width ?? 0;
|
||||
const h = canvas?.height ?? 0;
|
||||
assert.ok(w > 0 && h > 0);
|
||||
|
||||
const numbers = (op: string): number[] =>
|
||||
(op.slice(op.indexOf("(") + 1, -1).match(/-?\d+\.\d+/g) ?? []).map(Number);
|
||||
|
||||
for (const op of log) {
|
||||
if (op.startsWith("arc(")) {
|
||||
const [x = 0, y = 0, r = 0] = numbers(op);
|
||||
assert.ok(x - r >= -0.5 && x + r <= w + 0.5, `serration off the left/right edge: ${op}`);
|
||||
assert.ok(y - r >= -0.5 && y + r <= h + 0.5, `serration off the top/bottom edge: ${op}`);
|
||||
} else if (op.startsWith("bezierCurveTo") || op.startsWith("moveTo")) {
|
||||
const values = numbers(op);
|
||||
for (let i = 0; i < values.length; i += 2) {
|
||||
assert.ok((values[i] ?? 0) >= -0.5 && (values[i] ?? 0) <= w + 0.5, `x out of bounds: ${op}`);
|
||||
assert.ok(
|
||||
(values[i + 1] ?? 0) >= -0.5 && (values[i + 1] ?? 0) <= h + 0.5,
|
||||
`y out of bounds: ${op}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the leaf is cut even at low quality, where every other map is skipped", () => {
|
||||
// `low` means no *shading* maps, and it is the setting that makes an office
|
||||
// open on an integrated GPU. Skipping the cutout there does not buy a cheaper
|
||||
// plant, it buys the flat green shard back.
|
||||
const leaf = draw("leafAlpha", 0, "low");
|
||||
assert.ok(leaf.texture, "leafAlpha must survive low quality");
|
||||
assert.ok((leaf.canvas?.width ?? 0) >= 128);
|
||||
|
||||
const carpet = draw("carpetLoop", 0, "low");
|
||||
assert.equal(carpet.texture, null, "low quality must still skip the shading maps");
|
||||
});
|
||||
|
||||
// ---- The bin ----------------------------------------------------------------
|
||||
|
||||
test("the bin draws each kind and variant at most once", () => {
|
||||
const bin = new TextureBin("high");
|
||||
const before = logs.length;
|
||||
const a = bin.get("screenUI", 1);
|
||||
const b = bin.get("screenUI", 1);
|
||||
assert.equal(a, b);
|
||||
assert.equal(logs.length - before, 1, "the same variant was drawn twice");
|
||||
|
||||
const c = bin.get("screenUI", 4);
|
||||
assert.notEqual(a, c);
|
||||
assert.equal(logs.length - before, 2);
|
||||
|
||||
// Wrapping, so a caller can hand it a running prop index.
|
||||
assert.equal(bin.get("screenUI", 1 + SCREEN_UI_VARIANTS), a);
|
||||
assert.equal(logs.length - before, 2);
|
||||
bin.dispose();
|
||||
});
|
||||
|
||||
test("a variant index on a kind that has none is ignored", () => {
|
||||
const bin = new TextureBin("high");
|
||||
const before = logs.length;
|
||||
assert.equal(bin.get("carpetLoop", 0), bin.get("carpetLoop", 7));
|
||||
assert.equal(logs.length - before, 1);
|
||||
bin.dispose();
|
||||
});
|
||||
|
||||
test("the tiling kinds are unchanged: square, repeating, sRGB", () => {
|
||||
for (const kind of [
|
||||
"carpetLoop",
|
||||
"woodPlank",
|
||||
"polishedConcrete",
|
||||
"ceilingTile",
|
||||
"plasterPaint",
|
||||
"fabricWeave",
|
||||
"tileGrid",
|
||||
"whiteboard",
|
||||
] as TextureKind[]) {
|
||||
const { texture, canvas } = draw(kind);
|
||||
assert.ok(texture, `${kind} did not draw`);
|
||||
assert.equal(canvas?.width, canvas?.height, `${kind} is no longer square`);
|
||||
assert.equal(texture?.wrapS, THREE.RepeatWrapping, `${kind} stopped tiling`);
|
||||
assert.equal(texture?.wrapT, THREE.RepeatWrapping, `${kind} stopped tiling`);
|
||||
assert.equal(texture?.colorSpace, THREE.SRGBColorSpace);
|
||||
assert.equal(texture?.name, kind, `${kind} gained a variant suffix it did not ask for`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- The relief channel ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* These read pixels, which the tests above deliberately refuse to do — and the
|
||||
* difference is that a normal map is not a picture. It is a field of measured
|
||||
* directions with a defined encoding, and the three things that can be wrong
|
||||
* with it are all arithmetic: it can be un-normalised, it can be flipped in one
|
||||
* axis (a floor that lights as though it were embossed inside out), or it can
|
||||
* have a discontinuity at the tile seam. All three are checkable, none of them
|
||||
* is a judgement about how a carpet ought to look, and none of them shows up in
|
||||
* a screenshot until a low sun rakes across the floor.
|
||||
*
|
||||
* They also run *without* the fake canvas: relief comes off an authored height
|
||||
* field rather than off the drawing, which is what lets it exist under
|
||||
* `node --test` at all.
|
||||
*/
|
||||
|
||||
/** Every kind the spec asked for relief on, in the order the spec named them. */
|
||||
const RELIEF_KINDS: TextureKind[] = [
|
||||
"carpetLoop",
|
||||
"woodPlank",
|
||||
"fabricWeave",
|
||||
"plasterPaint",
|
||||
"tileGrid",
|
||||
"ceilingTile",
|
||||
];
|
||||
|
||||
function normalTexture(kind: TextureKind, quality: "low" | "medium" | "high" = "high") {
|
||||
const texture = new TextureBin(quality).draw(kind, "normal");
|
||||
assert.ok(texture, `${kind} has no normal map`);
|
||||
const image = texture.image as { width: number; height: number; data: Uint8Array };
|
||||
return { texture, ...image };
|
||||
}
|
||||
|
||||
test("every kind the spec named has relief, and nothing else does", () => {
|
||||
assert.deepEqual([...NORMAL_MAP_KINDS].sort(), [...RELIEF_KINDS].sort());
|
||||
const bin = new TextureBin("high");
|
||||
// A whiteboard, a display and an alpha cutout are flat. Binding a normal map
|
||||
// to them would be inventing texture that is not on the object.
|
||||
for (const kind of ["polishedConcrete", "whiteboard", "screenUI", "leafAlpha"] as TextureKind[]) {
|
||||
assert.equal(bin.normal(kind), null, `${kind} grew a normal map`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the centre of every relief map is flat, within a texel of tolerance", () => {
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
const { width, data } = normalTexture(kind);
|
||||
const i = ((width / 2) * width + width / 2) * 4;
|
||||
const [r, g, b] = [data[i] ?? 0, data[i + 1] ?? 0, data[i + 2] ?? 0];
|
||||
// The middle of the tile is the middle of a plank, the bottom of a grout
|
||||
// line or the crest of a carpet row depending on the kind — a stationary
|
||||
// point of the height field in every case, so the surface there points
|
||||
// straight up and encodes as (128, 128, 255).
|
||||
assert.ok(Math.abs(r - 128) <= 6, `${kind} centre R is ${r}`);
|
||||
assert.ok(Math.abs(g - 128) <= 6, `${kind} centre G is ${g}`);
|
||||
assert.ok(Math.abs(b - 255) <= 6, `${kind} centre B is ${b}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("every texel of every relief map is a unit vector pointing out of the surface", () => {
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
const { width, height, data } = normalTexture(kind);
|
||||
let worst = 0;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const x = ((data[i * 4] ?? 0) / 255) * 2 - 1;
|
||||
const y = ((data[i * 4 + 1] ?? 0) / 255) * 2 - 1;
|
||||
const z = ((data[i * 4 + 2] ?? 0) / 255) * 2 - 1;
|
||||
// Out of the surface, never into it: a negative Z is a normal facing away
|
||||
// from the viewer, which shades as a hole.
|
||||
assert.ok(z > 0, `${kind} has a texel whose normal points into the surface`);
|
||||
worst = Math.max(worst, Math.abs(Math.hypot(x, y, z) - 1));
|
||||
assert.equal(data[i * 4 + 3], 255, `${kind} has a non-opaque texel`);
|
||||
}
|
||||
// 1/255 per channel of quantisation, tripled and rounded up.
|
||||
assert.ok(worst < 0.02, `${kind} normals are off unit length by ${worst.toFixed(4)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("relief has something in it — a flat normal map is a wasted texture unit", () => {
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
const { width, height, data } = normalTexture(kind);
|
||||
let peak = 0;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
peak = Math.max(
|
||||
peak,
|
||||
Math.abs((data[i * 4] ?? 0) - 128),
|
||||
Math.abs((data[i * 4 + 1] ?? 0) - 128),
|
||||
);
|
||||
}
|
||||
assert.ok(peak >= 8, `${kind} relief peaks at ${peak}/128 and reads as flat`);
|
||||
}
|
||||
});
|
||||
|
||||
test("relief wraps at the tile seam", () => {
|
||||
// The colour maps are built to tile; a normal map that does not would put a
|
||||
// hard lighting crease every two metres across a floor, which is worse than no
|
||||
// relief at all because it moves with the sun.
|
||||
//
|
||||
// The comparison is against the *local* step, not against zero. Column 0 and
|
||||
// column 511 are one texel apart under wrapping, and in the wall of a grout
|
||||
// line one texel is a big step — legitimately. What would not be legitimate is
|
||||
// the step across the seam being larger than the steps either side of it,
|
||||
// which is exactly what a field sampled at `(x + 0.5) / size` instead of
|
||||
// `x / size` produces.
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
const { width, height, data } = normalTexture(kind);
|
||||
const texel = (x: number, y: number): number[] => {
|
||||
const i = (y * width + x) * 4;
|
||||
return [data[i] ?? 0, data[i + 1] ?? 0, data[i + 2] ?? 0];
|
||||
};
|
||||
const spread = (a: number[], b: number[]): number =>
|
||||
Math.max(...a.map((value, c) => Math.abs(value - (b[c] ?? 0))));
|
||||
|
||||
for (let y = 0; y < height; y += 17) {
|
||||
const seam = spread(texel(width - 1, y), texel(0, y));
|
||||
const local = Math.max(
|
||||
spread(texel(0, y), texel(1, y)),
|
||||
spread(texel(width - 2, y), texel(width - 1, y)),
|
||||
);
|
||||
assert.ok(seam <= local + 4, `${kind} row ${y}: seam step ${seam} vs local ${local}`);
|
||||
}
|
||||
for (let x = 0; x < width; x += 17) {
|
||||
const seam = spread(texel(x, height - 1), texel(x, 0));
|
||||
const local = Math.max(
|
||||
spread(texel(x, 0), texel(x, 1)),
|
||||
spread(texel(x, height - 2), texel(x, height - 1)),
|
||||
);
|
||||
assert.ok(seam <= local + 4, `${kind} column ${x}: seam step ${seam} vs local ${local}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("relief is resolution-independent, so medium and high light the same", () => {
|
||||
// The gradient is taken per unit UV rather than per texel. Get that wrong and
|
||||
// the same floor is twice as steep at `medium` as at `high`, which is a
|
||||
// quality setting that changes the art rather than the cost.
|
||||
//
|
||||
// `fabricWeave` is excluded, and the exclusion is the finding rather than a
|
||||
// fudge: its drawing rules 128 threads across the tile, which is two texels at
|
||||
// 256² — below what a half-resolution map can carry at all. The weave
|
||||
// therefore genuinely disappears from the relief at `medium`, the same way the
|
||||
// 1-pixel thread lines alias out of the colour map at `medium`. That is a
|
||||
// graceful loss of detail, which is what a quality setting is for; every other
|
||||
// kind's features are coarse enough to survive both and are held to within a
|
||||
// few percent.
|
||||
for (const kind of RELIEF_KINDS.filter((kind) => kind !== "fabricWeave")) {
|
||||
const strength = (quality: "medium" | "high") => {
|
||||
const { width, height, data } = normalTexture(kind, quality);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
sum += Math.hypot((data[i * 4] ?? 0) - 128, (data[i * 4 + 1] ?? 0) - 128);
|
||||
}
|
||||
return sum / (width * height);
|
||||
};
|
||||
const medium = strength("medium");
|
||||
const high = strength("high");
|
||||
// A third, not a few percent: a grout line is a couple of texels wide even
|
||||
// at 512 and softens measurably at 256. The bug this is really guarding
|
||||
// against — differentiating per texel instead of per unit UV — is a factor
|
||||
// of two, and no amount of softening reaches that.
|
||||
assert.ok(
|
||||
Math.abs(medium - high) <= Math.max(0.3, high * 0.35),
|
||||
`${kind} relief is ${medium.toFixed(2)} at medium and ${high.toFixed(2)} at high`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("relief is set up as a sampled map, not as raw data", () => {
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
const { texture } = normalTexture(kind);
|
||||
assert.equal(texture.wrapS, THREE.RepeatWrapping, `${kind} relief stopped tiling`);
|
||||
assert.equal(texture.wrapT, THREE.RepeatWrapping, `${kind} relief stopped tiling`);
|
||||
// A direction is not a colour. An sRGB decode would bend every normal.
|
||||
assert.equal(texture.colorSpace, THREE.NoColorSpace, `${kind} relief is being decoded`);
|
||||
// `DataTexture` defaults to nearest and no mipmaps, which on a floor running
|
||||
// to the horizon is a field of shimmering static.
|
||||
assert.equal(texture.generateMipmaps, true, `${kind} relief has no mipmaps`);
|
||||
assert.equal(texture.minFilter, THREE.LinearMipmapLinearFilter);
|
||||
assert.equal(texture.magFilter, THREE.LinearFilter);
|
||||
}
|
||||
});
|
||||
|
||||
test("low quality has no relief at all, the same way it has no colour maps", () => {
|
||||
const bin = new TextureBin("low");
|
||||
for (const kind of RELIEF_KINDS) {
|
||||
assert.equal(bin.normal(kind), null, `${kind} drew relief at low quality`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the bin builds each relief map at most once", () => {
|
||||
const bin = new TextureBin("high");
|
||||
const first = bin.normal("tileGrid");
|
||||
assert.ok(first);
|
||||
assert.equal(bin.normal("tileGrid"), first);
|
||||
// `draw` is the uncached door and must stay uncached, or the tests above
|
||||
// would be asserting about one shared texture.
|
||||
assert.notEqual(bin.draw("tileGrid", "normal"), first);
|
||||
bin.dispose();
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* The tone curve, and the light table that is tuned against it.
|
||||
*
|
||||
* These two things are one change and are tested in one file on purpose. Turning
|
||||
* on ACES without re-tuning `atmosphere.ts` produces a world that is correctly
|
||||
* *shaped* and too dark; re-tuning `atmosphere.ts` without ACES produces a world
|
||||
* that clips even harder than it did. Either half on its own is a regression, so
|
||||
* the assertions below fail if either half is reverted alone.
|
||||
*
|
||||
* `createStage` itself cannot be called here — it constructs a real
|
||||
* `WebGLRenderer`, and `node --test` has no GL context and no canvas. What can
|
||||
* be checked, and is, is (1) that the three renderer properties are actually
|
||||
* assigned in the source, which is the thing a careless merge would drop, (2)
|
||||
* that the constants they are assigned from still mean what the rest of the
|
||||
* repo assumes, and (3) that the light table's *shape* still matches the curve.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createAtmosphere, type Environment } from "../../engine/atmosphere.ts";
|
||||
import type { MoonPosition } from "../../engine/atmosphere.ts";
|
||||
import { DEFAULT_TONE_MAPPING_EXPOSURE } from "../../engine/stage.ts";
|
||||
import type { SolarPosition } from "../../engine/solar.ts";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const STAGE_SOURCE = readFileSync(path.join(ROOT, "src/engine/stage.ts"), "utf8");
|
||||
|
||||
// ---- The renderer configuration -------------------------------------------
|
||||
|
||||
test("the stage configures ACES, an exposure and an explicit output colour space", () => {
|
||||
assert.match(
|
||||
STAGE_SOURCE,
|
||||
/renderer\.toneMapping\s*=\s*THREE\.ACESFilmicToneMapping/,
|
||||
"the renderer must tone map; NoToneMapping is saturate() and clips every value above 1.0",
|
||||
);
|
||||
assert.match(
|
||||
STAGE_SOURCE,
|
||||
/renderer\.toneMappingExposure\s*=/,
|
||||
"the exposure must be assigned, not inherited",
|
||||
);
|
||||
assert.match(
|
||||
STAGE_SOURCE,
|
||||
/renderer\.outputColorSpace\s*=\s*THREE\.SRGBColorSpace/,
|
||||
"the output transfer function must be stated rather than relying on a library default",
|
||||
);
|
||||
assert.match(
|
||||
STAGE_SOURCE,
|
||||
/exposure\?:\s*number/,
|
||||
"StageOptions must expose the exposure, so a capture or a test can drive it",
|
||||
);
|
||||
});
|
||||
|
||||
test("the three constants the stage names still exist in three", () => {
|
||||
// A rename upstream would leave the assignments above compiling against
|
||||
// `undefined` and silently restore the clipping renderer.
|
||||
assert.equal(typeof THREE.ACESFilmicToneMapping, "number");
|
||||
assert.notEqual(THREE.ACESFilmicToneMapping, THREE.NoToneMapping);
|
||||
assert.equal(THREE.SRGBColorSpace, "srgb");
|
||||
});
|
||||
|
||||
// ---- What the exposure means ----------------------------------------------
|
||||
|
||||
/**
|
||||
* Three's own ACES fit, transcribed from `tonemapping_pars_fragment.glsl.js`.
|
||||
*
|
||||
* Duplicated here deliberately. The point of these assertions is not to check
|
||||
* that three's shader does what three's shader does — it is to check that the
|
||||
* *exposure this repo chose* lands the values this repo actually renders in the
|
||||
* places they need to be, and that requires evaluating the curve on the CPU.
|
||||
*/
|
||||
function rrtAndOdtFit(v: number): number {
|
||||
const a = v * (v + 0.0245786) - 0.000090537;
|
||||
const b = v * (0.983729 * v + 0.432951) + 0.238081;
|
||||
return a / b;
|
||||
}
|
||||
|
||||
/** Linear radiance in, display-linear out. Neutral colours only, so no matrices. */
|
||||
function aces(linear: number, exposure = DEFAULT_TONE_MAPPING_EXPOSURE): number {
|
||||
return Math.min(1, Math.max(0, rrtAndOdtFit((linear * exposure) / 0.6)));
|
||||
}
|
||||
|
||||
/** Display-linear to what the panel shows, so thresholds can be read as levels. */
|
||||
function srgb(v: number): number {
|
||||
return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
test("the chosen exposure keeps mid grey near the middle", () => {
|
||||
// An 18% card is the definition of a neutral exposure. A little above 0.5 is
|
||||
// the deliberate lift documented on the constant; a long way from it means
|
||||
// somebody has turned this into a brightness slider.
|
||||
const grey = srgb(aces(0.18));
|
||||
assert.ok(grey > 0.5 && grey < 0.58, `18% grey displayed at ${grey.toFixed(3)}`);
|
||||
});
|
||||
|
||||
test("values above 1.0 stay separable, which is the whole reason for the change", () => {
|
||||
// These are the numbers the library actually drives: `lightDiffuser` glows at
|
||||
// 0.85, `screenContent` at 0.9, `deviceIndicator` at 1.0, and the office
|
||||
// assets reach 3.2. Under NoToneMapping every one of them displayed as 1.0.
|
||||
const levels = [0.85, 1, 1.6, 2.35, 3.2, 6].map((v) => aces(v));
|
||||
for (let i = 1; i < levels.length; i++) {
|
||||
const previous = levels[i - 1] ?? 0;
|
||||
const current = levels[i] ?? 0;
|
||||
assert.ok(current > previous, `radiance step ${i} did not brighten`);
|
||||
assert.ok(current < 1, `radiance step ${i} clipped at 1.0`);
|
||||
assert.ok(
|
||||
current - previous > 0.002,
|
||||
`radiance step ${i} moved by ${(current - previous).toFixed(4)}, which is not a visible difference`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the curve is monotonic across the whole range it is fed", () => {
|
||||
let previous = -1;
|
||||
for (let linear = 0; linear <= 12; linear += 0.05) {
|
||||
const value = aces(linear);
|
||||
assert.ok(value >= previous, `not monotonic at ${linear.toFixed(2)}`);
|
||||
previous = value;
|
||||
}
|
||||
});
|
||||
|
||||
test("the exposure is a photographic dial, not a brightness control", () => {
|
||||
assert.ok(
|
||||
DEFAULT_TONE_MAPPING_EXPOSURE > 0.7 && DEFAULT_TONE_MAPPING_EXPOSURE < 1.8,
|
||||
`exposure ${DEFAULT_TONE_MAPPING_EXPOSURE} is outside the range the light table is tuned for`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---- The light table, tuned against that curve -----------------------------
|
||||
|
||||
const NO_MOON: MoonPosition = {
|
||||
azimuth: 0,
|
||||
elevation: -40,
|
||||
illuminated: 0,
|
||||
phase: 0,
|
||||
distanceKm: 384_400,
|
||||
};
|
||||
|
||||
function sun(elevation: number): SolarPosition {
|
||||
return { azimuth: 180, elevation, declination: 0, equationOfTime: 0 };
|
||||
}
|
||||
|
||||
function environment(elevation: number): Environment {
|
||||
return { time: new Date("2026-06-21T20:00:00Z"), sun: sun(elevation), moon: NO_MOON, weather: null };
|
||||
}
|
||||
|
||||
function rig(elevation: number) {
|
||||
// A city-scale atmosphere with the moon switched off, so what comes back is
|
||||
// the keyframe table plus the night floor and nothing else.
|
||||
const atmosphere = createAtmosphere({ lng: -122.4, metresPerUnit: 94, moonlight: null });
|
||||
return atmosphere.apply(environment(elevation));
|
||||
}
|
||||
|
||||
test("the day stops raise the key and lower the fill", () => {
|
||||
const noon = rig(65);
|
||||
// Contrast, not brightness: a shoulder means the sun no longer has to be held
|
||||
// back to keep a lit wall off pure white, so the key went up and the fill came
|
||||
// down. If someone restores the old table these three flip together.
|
||||
assert.ok(noon.sun.intensity >= 2.5, `peak sun ${noon.sun.intensity} is below the retuned key`);
|
||||
assert.ok(
|
||||
noon.hemisphere.intensity <= 1.0,
|
||||
`peak hemisphere ${noon.hemisphere.intensity} is above the retuned fill`,
|
||||
);
|
||||
assert.ok(
|
||||
noon.ambient.intensity <= 0.25,
|
||||
`peak ambient ${noon.ambient.intensity} is above the retuned fill`,
|
||||
);
|
||||
|
||||
// The ratio is the thing that reads as modelling. Under the old table it was
|
||||
// 2.35 / 1.10 = 2.1; it must not go back there.
|
||||
const keyToFill = noon.sun.intensity / noon.hemisphere.intensity;
|
||||
assert.ok(keyToFill > 2.4, `key-to-fill ratio ${keyToFill.toFixed(2)} is too flat`);
|
||||
});
|
||||
|
||||
test("the night floor sits high enough to survive the ACES toe", () => {
|
||||
const night = rig(-18);
|
||||
// The toe costs roughly 18% of the display value of a moonless night. The
|
||||
// floor was raised by a third in linear light to pay for it, and these are the
|
||||
// floors themselves rather than the keyframe rows, because the floor binds.
|
||||
assert.ok(
|
||||
night.hemisphere.intensity >= 1.0,
|
||||
`night hemisphere ${night.hemisphere.intensity} is back below the raised floor`,
|
||||
);
|
||||
assert.ok(
|
||||
night.ambient.intensity >= 0.28,
|
||||
`night ambient ${night.ambient.intensity} is back below the raised floor`,
|
||||
);
|
||||
// And still a night: the fill is a fraction of noon's key, not a match for it.
|
||||
assert.ok(night.sun.intensity < 0.5, "the night sidelight has become a sun");
|
||||
});
|
||||
|
||||
test("the sun brightens monotonically as it rises", () => {
|
||||
let previous = -1;
|
||||
for (const elevation of [-18, -12, -6, -0.4, 3, 8, 25, 65]) {
|
||||
const intensity = rig(elevation).sun.intensity;
|
||||
assert.ok(intensity > previous, `sun intensity fell between stops at ${elevation} degrees`);
|
||||
previous = intensity;
|
||||
}
|
||||
});
|
||||
|
||||
test("the sky colours were left alone, because they are not tone mapped", () => {
|
||||
// Three marks the background mesh `toneMapped = false` for an sRGB-transfer
|
||||
// texture and mixes fog after the tone map from an already-encoded uniform.
|
||||
// So the one thing the re-tune must NOT have touched is the sky, and the noon
|
||||
// stop still reproduces the city's own declared daylight colours.
|
||||
const noon = rig(25);
|
||||
assert.ok(noon.sky);
|
||||
assert.equal(noon.sky?.top, 0x8fb8d8);
|
||||
assert.equal(noon.sky?.horizon, 0xd9e6ee);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
# Test home for the `ui` workstream.
|
||||
#
|
||||
# Each build workstream owns its own subdirectory so eight builders can add
|
||||
# suites in parallel without ever colliding on a path. `npm test` picks these
|
||||
# up through the widened `src/test/**/*.test.ts` glob in package.json.
|
||||
@@ -0,0 +1,791 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import type { Tier } from "../../access.ts";
|
||||
import { CANONICAL_CAPABILITIES } from "../../devices/types.ts";
|
||||
import type { DeviceDeclaration } from "../../devices/types.ts";
|
||||
import type { ControlMode } from "../../play/controlMode.ts";
|
||||
import { chromeState } from "../../ui/chromeState.ts";
|
||||
import type { ChromeInputs, ChromeState } from "../../ui/chromeState.ts";
|
||||
|
||||
// ---- Fixtures -------------------------------------------------------------
|
||||
|
||||
const TIERS: readonly Tier[] = ["anon", "member", "god"];
|
||||
|
||||
const MODES: readonly ControlMode[] = [
|
||||
"overview",
|
||||
"drive",
|
||||
"actor",
|
||||
"aircraft",
|
||||
"office-overview",
|
||||
"office-walk",
|
||||
];
|
||||
|
||||
const CITY_MODES: readonly ControlMode[] = ["overview", "drive", "actor", "aircraft"];
|
||||
const OFFICE_MODES: readonly ControlMode[] = ["office-overview", "office-walk"];
|
||||
|
||||
const MIC: DeviceDeclaration = {
|
||||
id: "hq-mic-01",
|
||||
kind: "mic",
|
||||
label: "Desk mic",
|
||||
assetId: "tera:device.mic.desk",
|
||||
anchor: { levelId: "l1", propId: "desk-01" },
|
||||
capabilities: CANONICAL_CAPABILITIES.mic,
|
||||
provenance: "simulated",
|
||||
disclosure: "Simulated studio hardware. This is demonstration data, never presence data.",
|
||||
};
|
||||
|
||||
function base(overrides: Partial<ChromeInputs> = {}): ChromeInputs {
|
||||
return {
|
||||
mode: "overview",
|
||||
available: ["overview", "drive", "actor", "aircraft"],
|
||||
access: { tier: "anon", signInUrl: "/login.html", subject: null },
|
||||
inside: false,
|
||||
officeDepth: null,
|
||||
viewport: { width: 1440, height: 900, coarsePointer: false },
|
||||
feeds: { markers: false, weather: true, flights: true },
|
||||
degraded: [],
|
||||
devices: [],
|
||||
firstVisit: false,
|
||||
panelOpen: true,
|
||||
planOpen: true,
|
||||
board: {
|
||||
cityId: "california",
|
||||
cityLabel: "California",
|
||||
officeId: "lumbridge-hq",
|
||||
officeLabel: "SF HQ",
|
||||
officeStatus: "active",
|
||||
isCalifornia: true,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that must hold in **every** state the product can be in.
|
||||
*
|
||||
* These are the assertions that would have caught the two live layout defects if
|
||||
* anything had been asserting anything at all: the desktop was being shoved
|
||||
* 160px for touch controls it does not draw, and a phone was being offered a
|
||||
* keyboard reference and no way to move.
|
||||
*/
|
||||
function invariants(state: ChromeState, inputs: ChromeInputs): void {
|
||||
// A touch surface is never drawn for a mouse. This is the one that was broken.
|
||||
if (!inputs.viewport.coarsePointer) {
|
||||
assert.equal(state.touchControlsVisible, false, "touch controls on a fine pointer");
|
||||
assert.equal(state.stickVisible, false, "a joystick on a fine pointer");
|
||||
}
|
||||
// Exactly one dock button is pressed, always — it is a radio group.
|
||||
const pressed = state.modeButtons.filter((button) => button.pressed);
|
||||
assert.equal(pressed.length, 1, "the dock must always show exactly one current mode");
|
||||
// ...and the pressed one is always visible, or the viewer cannot see where
|
||||
// they are.
|
||||
assert.equal(pressed[0]?.visible, true, "the current mode's dock button is hidden");
|
||||
// A dock with one button is a label pretending to be a control.
|
||||
const visible = state.modeButtons.filter((button) => button.visible);
|
||||
assert.equal(state.modeDockVisible, visible.length > 1);
|
||||
// The canvas always describes itself to a screen reader.
|
||||
assert.ok(state.canvasLabel.length > 20, "the canvas has no useful description");
|
||||
// Nothing office-shaped leaks into the city and vice versa.
|
||||
if (!inputs.inside) {
|
||||
assert.equal(state.officeInviteVisible, false);
|
||||
assert.equal(state.officeNoteVisible, false);
|
||||
assert.equal(state.devicePanelVisible, false);
|
||||
} else {
|
||||
assert.equal(state.flyVisible, false, "the aircraft route does not exist indoors");
|
||||
}
|
||||
// The tier badge is always on screen: every other difference between tiers is
|
||||
// a silence, and a silence you cannot attribute is indistinguishable from a
|
||||
// fault.
|
||||
assert.equal(state.tierVisible, true);
|
||||
assert.ok(state.tierLabel.length > 0);
|
||||
}
|
||||
|
||||
// ---- The matrix -----------------------------------------------------------
|
||||
|
||||
describe("chromeState across the whole matrix", () => {
|
||||
it("holds its invariants in all 72 states: 3 tiers x 6 modes x in/out x coarse/fine", () => {
|
||||
let visited = 0;
|
||||
for (const tier of TIERS) {
|
||||
for (const mode of MODES) {
|
||||
for (const inside of [false, true]) {
|
||||
for (const coarsePointer of [false, true]) {
|
||||
// A mode is only reachable where it exists: the office has two, the
|
||||
// city has four, and `controlModeAvailable` already refuses the
|
||||
// crossings. Feeding a state the app cannot reach would be testing
|
||||
// a fiction.
|
||||
const available = inside ? OFFICE_MODES : CITY_MODES;
|
||||
const resolved = available.includes(mode) ? mode : available[0]!;
|
||||
const inputs = base({
|
||||
mode: resolved,
|
||||
available,
|
||||
inside,
|
||||
officeDepth: inside ? (tier === "anon" ? "public" : "full") : null,
|
||||
access: {
|
||||
tier,
|
||||
signInUrl: tier === "anon" ? "/login.html" : null,
|
||||
subject: tier === "anon" ? null : "someone@example.com",
|
||||
},
|
||||
viewport: coarsePointer
|
||||
? { width: 390, height: 844, coarsePointer: true }
|
||||
: { width: 1440, height: 900, coarsePointer: false },
|
||||
devices: inside ? [MIC] : [],
|
||||
});
|
||||
invariants(chromeState(inputs), inputs);
|
||||
visited += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(visited, TIERS.length * MODES.length * 2 * 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- The dock -------------------------------------------------------------
|
||||
|
||||
describe("the mode dock", () => {
|
||||
it("disappears when overview is the only thing available", () => {
|
||||
// On two of the three city boards it rendered as a lit pill holding one
|
||||
// already-pressed button that did nothing.
|
||||
assert.equal(chromeState(base({ available: ["overview"] })).modeDockVisible, false);
|
||||
});
|
||||
|
||||
it("appears the moment there is a second mode", () => {
|
||||
assert.equal(chromeState(base({ available: ["overview", "drive"] })).modeDockVisible, true);
|
||||
});
|
||||
|
||||
it("lets one View button stand for both orbit states", () => {
|
||||
// They are one idea — the camera is yours and nothing else is — and two
|
||||
// buttons for it would swap under the viewer as they walked through a door.
|
||||
const outside = chromeState(base({ mode: "overview" }));
|
||||
assert.equal(outside.modeButtons.find((b) => b.mode === "overview")?.pressed, true);
|
||||
|
||||
const inside = chromeState(
|
||||
base({ mode: "office-overview", available: OFFICE_MODES, inside: true, officeDepth: "full" }),
|
||||
);
|
||||
assert.equal(inside.modeButtons.find((b) => b.mode === "overview")?.pressed, true);
|
||||
assert.equal(inside.modeButtons.find((b) => b.mode === "overview")?.visible, true);
|
||||
});
|
||||
|
||||
it("hides a mode that is not available", () => {
|
||||
const state = chromeState(base({ available: ["overview", "aircraft"] }));
|
||||
assert.equal(state.modeButtons.find((b) => b.mode === "drive")?.visible, false);
|
||||
assert.equal(state.modeButtons.find((b) => b.mode === "actor")?.visible, false);
|
||||
assert.equal(state.modeButtons.find((b) => b.mode === "aircraft")?.visible, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Layout ---------------------------------------------------------------
|
||||
|
||||
describe("layout", () => {
|
||||
it("splits at the two published breakpoints and nowhere else", () => {
|
||||
const at = (width: number) => chromeState(base({ viewport: { width, height: 800, coarsePointer: false } })).layout;
|
||||
assert.equal(at(390), "phone");
|
||||
assert.equal(at(600), "phone");
|
||||
assert.equal(at(601), "compact");
|
||||
assert.equal(at(900), "compact");
|
||||
assert.equal(at(901), "desktop");
|
||||
assert.equal(at(1440), "desktop");
|
||||
});
|
||||
|
||||
it("assumes a desktop rather than a phone when the width is nonsense", () => {
|
||||
// A `NaN` width from a detached iframe must not collapse the layout to a
|
||||
// bottom sheet: wrong-and-usable beats wrong-and-unusable.
|
||||
const state = chromeState(base({ viewport: { width: Number.NaN, height: 0, coarsePointer: false } }));
|
||||
assert.equal(state.layout, "desktop");
|
||||
});
|
||||
|
||||
it("keeps the panel toggle off the desktop, where the column is furniture", () => {
|
||||
assert.equal(chromeState(base()).panelToggleVisible, false);
|
||||
assert.equal(
|
||||
chromeState(base({ viewport: { width: 820, height: 1180, coarsePointer: true } }))
|
||||
.panelToggleVisible,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("only puts a scrim behind the panel where the panel is a sheet", () => {
|
||||
const phone = { width: 390, height: 844, coarsePointer: true };
|
||||
assert.equal(chromeState(base({ viewport: phone, panelOpen: true })).scrimVisible, true);
|
||||
assert.equal(chromeState(base({ viewport: phone, panelOpen: false })).scrimVisible, false);
|
||||
assert.equal(chromeState(base({ panelOpen: true })).scrimVisible, false);
|
||||
});
|
||||
|
||||
it("publishes the body classes as one list rather than five toggles", () => {
|
||||
const state = chromeState(
|
||||
base({
|
||||
panelOpen: false,
|
||||
planOpen: false,
|
||||
presenceVisible: true,
|
||||
cameraLive: true,
|
||||
viewport: { width: 390, height: 844, coarsePointer: true },
|
||||
}),
|
||||
);
|
||||
for (const name of ["panel-closed", "minimap-off", "presence-on", "camera-live", "touch-first", "layout-phone"]) {
|
||||
assert.ok(state.bodyClasses.includes(name), `missing body class ${name}`);
|
||||
}
|
||||
assert.equal(state.bodyClasses.includes("inside"), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- The touch surface ----------------------------------------------------
|
||||
|
||||
describe("the touch surface", () => {
|
||||
const phone = { width: 390, height: 844, coarsePointer: true };
|
||||
|
||||
it("is drawn in every play mode, and in no orbit mode", () => {
|
||||
for (const mode of ["drive", "actor", "aircraft"] as const) {
|
||||
const state = chromeState(base({ mode, viewport: phone }));
|
||||
assert.equal(state.touchControlsVisible, true, `${mode} has no touch surface`);
|
||||
assert.equal(state.stickVisible, true);
|
||||
}
|
||||
assert.equal(chromeState(base({ mode: "overview", viewport: phone })).stickVisible, false);
|
||||
assert.equal(
|
||||
chromeState(base({ mode: "office-overview", available: OFFICE_MODES, inside: true, viewport: phone }))
|
||||
.stickVisible,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("names what the stick moves rather than leaving a bare circle", () => {
|
||||
assert.equal(chromeState(base({ mode: "drive", viewport: phone })).stickLabel, "Steer");
|
||||
assert.equal(chromeState(base({ mode: "aircraft", viewport: phone })).stickLabel, "Fly");
|
||||
assert.equal(
|
||||
chromeState(base({ mode: "office-walk", available: OFFICE_MODES, inside: true, viewport: phone }))
|
||||
.stickLabel,
|
||||
"Walk",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows only the actions the current mode actually has", () => {
|
||||
const drive = chromeState(base({ mode: "drive", viewport: phone }));
|
||||
const shown = (state: ChromeState) =>
|
||||
state.touchActions.filter((action) => action.visible).map((action) => action.id);
|
||||
assert.deepEqual(shown(drive), [
|
||||
"touch-primary", "touch-assist", "touch-reset", "touch-camera", "touch-map",
|
||||
]);
|
||||
assert.equal(drive.touchActions.find((a) => a.id === "touch-primary")?.label, "Handbrake");
|
||||
|
||||
const fly = chromeState(base({ mode: "aircraft", viewport: phone }));
|
||||
assert.equal(fly.touchActions.find((a) => a.id === "touch-primary")?.label, "Throttle");
|
||||
assert.deepEqual(shown(fly), [
|
||||
"touch-primary", "touch-pitch-up", "touch-pitch-down", "touch-assist", "touch-reset", "touch-map",
|
||||
]);
|
||||
|
||||
const walk = chromeState(
|
||||
base({ mode: "office-walk", available: OFFICE_MODES, inside: true, viewport: phone }),
|
||||
);
|
||||
// On foot there is no handbrake, no throttle and nothing to reset.
|
||||
assert.deepEqual(shown(walk), ["touch-map"]);
|
||||
});
|
||||
|
||||
it("gives a crow on the wing its own action pad", () => {
|
||||
const ground = chromeState(base({ mode: "actor", viewport: phone, actor: { kind: "crow", flying: false } }));
|
||||
assert.equal(ground.touchActions.find((a) => a.id === "touch-primary")?.label, "Sprint");
|
||||
assert.equal(ground.touchActions.find((a) => a.id === "touch-secondary")?.visible, false);
|
||||
|
||||
const flying = chromeState(base({ mode: "actor", viewport: phone, actor: { kind: "crow", flying: true } }));
|
||||
assert.equal(flying.touchActions.find((a) => a.id === "touch-primary")?.label, "Climb");
|
||||
assert.equal(flying.touchActions.find((a) => a.id === "touch-primary")?.control, "ascend");
|
||||
assert.equal(flying.touchActions.find((a) => a.id === "touch-secondary")?.visible, true);
|
||||
assert.equal(flying.stickLabel, "Bank");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- The rail -------------------------------------------------------------
|
||||
|
||||
describe("the rail", () => {
|
||||
it("drops the keyboard hints on a device with no keyboard", () => {
|
||||
const phone = chromeState(base({ viewport: { width: 390, height: 844, coarsePointer: true } }));
|
||||
assert.equal(phone.railHintsVisible, false);
|
||||
assert.deepEqual(phone.railHints, []);
|
||||
// The `?` button stays: it is the only reference that layout has, since the
|
||||
// key strip is `display: none` there. What changes is what it opens.
|
||||
assert.equal(phone.helpVisible, true);
|
||||
});
|
||||
|
||||
it("shows at most two hints, chosen for the mode", () => {
|
||||
const drive = chromeState(base({ mode: "drive" }));
|
||||
assert.equal(drive.railHintsVisible, true);
|
||||
assert.ok(drive.railHints.length <= 2);
|
||||
assert.deepEqual(drive.railHints.map((hint) => hint.id), ["primary", "assist"]);
|
||||
});
|
||||
|
||||
it("only offers the plan button where a key cannot do the job", () => {
|
||||
assert.equal(chromeState(base()).planToggleVisible, false);
|
||||
assert.equal(
|
||||
chromeState(base({ viewport: { width: 1024, height: 768, coarsePointer: true } })).planToggleVisible,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
chromeState(base({ viewport: { width: 390, height: 844, coarsePointer: false } })).planToggleVisible,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("ties the plan to the toggle and to the minimap, in one decision", () => {
|
||||
const on = chromeState(base({ planOpen: true }));
|
||||
assert.equal(on.minimapVisible, true);
|
||||
assert.equal(on.planTogglePressed, true);
|
||||
assert.equal(on.bodyClasses.includes("minimap-off"), false);
|
||||
|
||||
const off = chromeState(base({ planOpen: false }));
|
||||
assert.equal(off.minimapVisible, false);
|
||||
assert.equal(off.planTogglePressed, false);
|
||||
assert.ok(off.bodyClasses.includes("minimap-off"));
|
||||
});
|
||||
|
||||
it("drops the hover-only readout where there is no hover", () => {
|
||||
assert.equal(chromeState(base()).minimapReadoutVisible, true);
|
||||
assert.equal(
|
||||
chromeState(base({ viewport: { width: 390, height: 844, coarsePointer: true } }))
|
||||
.minimapReadoutVisible,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- The tier badge -------------------------------------------------------
|
||||
|
||||
describe("the tier badge", () => {
|
||||
it("says what signing in adds, rather than labelling the visitor", () => {
|
||||
// "Public view" names the visitor rather than the offer, which on a product
|
||||
// designed anon-first is the wrong sentence in the most-read corner.
|
||||
const anon = chromeState(base());
|
||||
assert.equal(anon.tierLabel, "Open demo");
|
||||
assert.ok(anon.tierAdds !== null);
|
||||
assert.match(anon.tierAdds, /Sign in adds/);
|
||||
assert.match(anon.tierAdds, /character/);
|
||||
assert.equal(anon.signInVisible, true);
|
||||
assert.equal(anon.signInHref, "/login.html");
|
||||
});
|
||||
|
||||
it("offers nothing to add when there is nothing to add", () => {
|
||||
// A clean self-hosted clone with no API resolves to `member` and has no door
|
||||
// to knock on. Telling that visitor to sign in to a server that does not
|
||||
// exist is the small dishonesty that makes the rest untrustworthy.
|
||||
const selfHosted = chromeState(
|
||||
base({ access: { tier: "member", signInUrl: null, subject: null } }),
|
||||
);
|
||||
assert.equal(selfHosted.tierLabel, "Full view");
|
||||
assert.equal(selfHosted.tierAdds, null);
|
||||
assert.equal(selfHosted.signInVisible, false);
|
||||
|
||||
const anonNoDoor = chromeState(base({ access: { tier: "anon", signInUrl: null, subject: null } }));
|
||||
assert.equal(anonNoDoor.tierAdds, null);
|
||||
assert.equal(anonNoDoor.signInVisible, false);
|
||||
});
|
||||
|
||||
it("prefers a chosen name over an auth subject, and offers Character only with a profile", () => {
|
||||
const withProfile = chromeState(
|
||||
base({
|
||||
access: {
|
||||
tier: "member",
|
||||
signInUrl: null,
|
||||
subject: "auth|1234",
|
||||
displayName: "Karti",
|
||||
hasProfile: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(withProfile.whoLabel, "Karti");
|
||||
assert.equal(withProfile.characterVisible, true);
|
||||
assert.equal(withProfile.signInVisible, false);
|
||||
|
||||
const withoutProfile = chromeState(
|
||||
base({ access: { tier: "god", signInUrl: null, subject: "auth|1234" } }),
|
||||
);
|
||||
assert.equal(withoutProfile.whoLabel, "auth|1234");
|
||||
assert.equal(withoutProfile.characterVisible, false);
|
||||
assert.equal(withoutProfile.tierLabel, "Godmode");
|
||||
assert.equal(withoutProfile.tierTone, "god");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Inside a studio ------------------------------------------------------
|
||||
|
||||
describe("inside a studio", () => {
|
||||
const insideBase = (overrides: Partial<ChromeInputs> = {}) =>
|
||||
base({
|
||||
inside: true,
|
||||
mode: "office-overview",
|
||||
available: OFFICE_MODES,
|
||||
officeDepth: "full",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("puts the sign-in invitation on a surface that survives a closed panel", () => {
|
||||
// It used to be the last child of `#office-badge` inside `#panel`, and on a
|
||||
// phone `#panel` is a bottom sheet that starts closed — so the only in-office
|
||||
// call to sign in was behind a hamburger on the device least likely to open
|
||||
// one.
|
||||
const publicView = chromeState(insideBase({ officeDepth: "public" }));
|
||||
assert.equal(publicView.officeInviteVisible, true);
|
||||
assert.match(publicView.officeInviteText, /the building, not the people/);
|
||||
assert.equal(publicView.officeInviteLinkLabel, "Sign in for the live floor");
|
||||
|
||||
const full = chromeState(insideBase({ officeDepth: "full" }));
|
||||
assert.equal(full.officeInviteVisible, false);
|
||||
});
|
||||
|
||||
it("says something honest where there is no door at all", () => {
|
||||
const state = chromeState(
|
||||
insideBase({ officeDepth: "public", access: { tier: "anon", signInUrl: null, subject: null } }),
|
||||
);
|
||||
assert.equal(state.officeInviteVisible, true);
|
||||
assert.equal(state.officeInviteLinkLabel, null);
|
||||
assert.match(state.officeInviteText, /no sign-in/);
|
||||
});
|
||||
|
||||
it("shows the screens button only where there are screens and the depth to drive them", () => {
|
||||
assert.equal(chromeState(insideBase({ office: { mediaSurfaceCount: 0 } })).screensVisible, false);
|
||||
assert.equal(chromeState(insideBase({ office: { mediaSurfaceCount: 3 } })).screensVisible, true);
|
||||
assert.equal(
|
||||
chromeState(insideBase({ officeDepth: "public", office: { mediaSurfaceCount: 3 } })).screensVisible,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("counts the screens in words a person would use", () => {
|
||||
assert.match(
|
||||
chromeState(insideBase({ office: { mediaSurfaceCount: 1 } })).officeNoteText,
|
||||
/1 screen ready · media stays off until you opt in\./,
|
||||
);
|
||||
assert.match(
|
||||
chromeState(insideBase({ office: { mediaSurfaceCount: 4, mediaSurfaceActiveCount: 2 } })).officeNoteText,
|
||||
/2 of 4 screens active/,
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the robot disclosure alongside, never instead", () => {
|
||||
const both = chromeState(
|
||||
insideBase({ office: { mediaSurfaceCount: 2, robotDisclosure: "Robot activity is simulated." } }),
|
||||
);
|
||||
assert.match(both.officeNoteText, /2 screens ready/);
|
||||
assert.match(both.officeNoteText, /Robot activity is simulated\./);
|
||||
});
|
||||
|
||||
it("gives an anonymous visitor the hardware panel", () => {
|
||||
// The declarations are authored into the pack, so they are public by
|
||||
// construction — the hardware in the room is a description of the room. What
|
||||
// an account buys is the live readings.
|
||||
const anon = chromeState(
|
||||
insideBase({
|
||||
officeDepth: "public",
|
||||
access: { tier: "anon", signInUrl: "/login.html", subject: null },
|
||||
devices: [MIC],
|
||||
}),
|
||||
);
|
||||
assert.equal(anon.devicePanelVisible, true);
|
||||
assert.deepEqual(anon.deviceDeclarations, [MIC]);
|
||||
assert.equal(anon.devicePanelLabel, "Studio hardware →");
|
||||
assert.equal(chromeState(insideBase({ devices: [] })).devicePanelVisible, false);
|
||||
});
|
||||
|
||||
it("refuses to offer a walk in a building with no walker", () => {
|
||||
assert.equal(chromeState(insideBase({ office: { walkable: false } })).walkVisible, false);
|
||||
assert.equal(chromeState(insideBase()).walkVisible, true);
|
||||
});
|
||||
|
||||
it("names the building, not the board", () => {
|
||||
const state = chromeState(insideBase());
|
||||
assert.equal(state.panelTitle, "SF HQ");
|
||||
assert.equal(state.panelToggleLabel, "SF HQ");
|
||||
assert.equal(state.boardPicker, "office");
|
||||
assert.equal(state.enterLabel, "← Back to the city");
|
||||
assert.match(state.panelSubtitle, /active environment/);
|
||||
});
|
||||
|
||||
it("says a building is still being built when it is", () => {
|
||||
const state = chromeState(base({ board: { officeLabel: "Frontier Valley", officeStatus: "building" } }));
|
||||
assert.equal(state.enterLabel, "Preview Frontier Valley · building →");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- The honesty line -----------------------------------------------------
|
||||
|
||||
describe("the honesty line", () => {
|
||||
it("says nothing at all when nothing is live", () => {
|
||||
const state = chromeState(base({ feeds: { markers: false, weather: false, flights: false } }));
|
||||
assert.equal(state.sourceVisible, false);
|
||||
assert.equal(state.sourceLabel, "");
|
||||
assert.equal(state.sourceLive, false);
|
||||
});
|
||||
|
||||
it("names the parts rather than claiming the whole", () => {
|
||||
// "live data" printed over invented companies because a weather station
|
||||
// answered is exactly the claim this wording exists to prevent.
|
||||
assert.equal(
|
||||
chromeState(base({ feeds: { markers: false, weather: true, flights: false } })).sourceLabel,
|
||||
"live weather",
|
||||
);
|
||||
assert.equal(
|
||||
chromeState(base({ feeds: { markers: false, weather: true, flights: true } })).sourceLabel,
|
||||
"live weather · live traffic",
|
||||
);
|
||||
assert.equal(
|
||||
chromeState(base({ feeds: { markers: true, weather: true, flights: true } })).sourceLabel,
|
||||
"live data",
|
||||
);
|
||||
});
|
||||
|
||||
it("retires the weather claim while somebody's invented sky is up", () => {
|
||||
const state = chromeState(
|
||||
base({ feeds: { markers: false, weather: true, flights: false, weatherOverridden: true } }),
|
||||
);
|
||||
assert.equal(state.sourceLabel, "");
|
||||
assert.equal(
|
||||
chromeState(base({ feeds: { markers: true, weather: true, flights: true, weatherOverridden: true } }))
|
||||
.sourceLabel,
|
||||
"live markers · live traffic",
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the office's occupancy disclosure outrank the board's liveness", () => {
|
||||
const state = chromeState(
|
||||
base({
|
||||
inside: true,
|
||||
mode: "office-overview",
|
||||
available: OFFICE_MODES,
|
||||
officeDepth: "full",
|
||||
feeds: { markers: true, weather: true, flights: true, sampleOccupancy: true },
|
||||
}),
|
||||
);
|
||||
assert.equal(state.sourceLabel, "sample occupancy · these people are invented");
|
||||
assert.equal(state.sourceLive, false, "an invented staff list is not a live-data green");
|
||||
assert.ok(state.bodyClasses.includes("sample-occupancy"));
|
||||
});
|
||||
|
||||
it("carries the live sources' credit lines, deduplicated, to the ? card", () => {
|
||||
// A licence obligation that was plumbed to within one line of being met:
|
||||
// parsed into `WeatherFeed.attribution` and `FlightsBody.attribution`, then
|
||||
// read by nobody.
|
||||
const state = chromeState(
|
||||
base({
|
||||
feeds: {
|
||||
markers: false,
|
||||
weather: true,
|
||||
flights: true,
|
||||
credits: ["Data from MET Norway", "Data from MET Norway", "adsb.lol, ODbL", ""],
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(state.credits, ["Data from MET Norway", "adsb.lol, ODbL"]);
|
||||
assert.deepEqual(chromeState(base()).credits, []);
|
||||
});
|
||||
|
||||
it("shows the deployment's demotions to an admin and to nobody else", () => {
|
||||
const degraded = ["No weather source is configured, so the sky is synthetic."];
|
||||
assert.equal(chromeState(base({ degraded })).degradedVisible, false);
|
||||
assert.equal(
|
||||
chromeState(base({ degraded, access: { tier: "member", signInUrl: null, subject: "x" } }))
|
||||
.degradedVisible,
|
||||
false,
|
||||
);
|
||||
const admin = chromeState(base({ degraded, access: { tier: "god", signInUrl: null, subject: "x" } }));
|
||||
assert.equal(admin.degradedVisible, true);
|
||||
assert.deepEqual(admin.degradedLines, degraded);
|
||||
// Nothing to report is not a reason to draw an empty card.
|
||||
assert.equal(
|
||||
chromeState(base({ degraded: [], access: { tier: "god", signInUrl: null, subject: "x" } }))
|
||||
.degradedVisible,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Lists, HUD and detail ------------------------------------------------
|
||||
|
||||
describe("the lists the applier draws", () => {
|
||||
it("marks exactly one board active, and carries the office status through", () => {
|
||||
const state = chromeState(
|
||||
base({
|
||||
inside: true,
|
||||
mode: "office-overview",
|
||||
available: OFFICE_MODES,
|
||||
officeDepth: "full",
|
||||
boards: [
|
||||
{ id: "lumbridge-hq", label: "SF HQ", status: "active" },
|
||||
{ id: "mateo-court", label: "LA Studio", status: "active" },
|
||||
{ id: "frontier-valley", label: "Frontier", status: "building" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(state.boards.map((board) => board.active), [true, false, false]);
|
||||
assert.equal(state.boards[2]?.status, "building");
|
||||
});
|
||||
|
||||
it("numbers a view list that did not number itself", () => {
|
||||
const state = chromeState(
|
||||
base({
|
||||
views: [
|
||||
{ id: "a", shortLabel: "Approach", description: "A long way out." },
|
||||
{ id: "b", number: "07", shortLabel: "Paseo" },
|
||||
],
|
||||
activeViewId: "b",
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(state.views.map((view) => view.number), ["01", "07"]);
|
||||
assert.deepEqual(state.views.map((view) => view.active), [false, true]);
|
||||
assert.equal(state.chaptersVisible, true);
|
||||
});
|
||||
|
||||
it("drops the blurb where there is nothing to say, and on a phone regardless", () => {
|
||||
const withText = chromeState(
|
||||
base({ views: [{ id: "a", shortLabel: "A", description: "Something." }], activeViewId: "a" }),
|
||||
);
|
||||
assert.equal(withText.blurbVisible, true);
|
||||
assert.equal(withText.blurbText, "Something.");
|
||||
|
||||
const noText = chromeState(base({ views: [{ id: "a", shortLabel: "A" }], activeViewId: "a" }));
|
||||
assert.equal(noText.blurbVisible, false);
|
||||
|
||||
const phone = chromeState(
|
||||
base({
|
||||
viewport: { width: 390, height: 844, coarsePointer: true },
|
||||
views: [{ id: "a", shortLabel: "A", description: "Something." }],
|
||||
activeViewId: "a",
|
||||
}),
|
||||
);
|
||||
assert.equal(phone.blurbVisible, false);
|
||||
});
|
||||
|
||||
it("keeps the play HUD off in the orbit modes and on with real telemetry", () => {
|
||||
assert.equal(chromeState(base({ mode: "overview" })).playHudVisible, false);
|
||||
assert.equal(chromeState(base({ mode: "drive" })).playHud, null);
|
||||
|
||||
const driving = chromeState(
|
||||
base({
|
||||
mode: "drive",
|
||||
telemetry: {
|
||||
kind: "drive",
|
||||
speedMps: 26.8,
|
||||
roadName: "US-101",
|
||||
driveMode: "assisted",
|
||||
progress: 0.42,
|
||||
camera: "chase",
|
||||
guardrailContact: false,
|
||||
collisionRisk: 0.1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(driving.playHudVisible, true);
|
||||
assert.equal(driving.playHud?.mode, "Drive");
|
||||
assert.match(driving.playHud?.primary ?? "", /60 mph · US-101/);
|
||||
assert.equal(driving.playHud?.warning, false);
|
||||
});
|
||||
|
||||
it("formats an aircraft pick into the card an anonymous visitor gets", () => {
|
||||
const state = chromeState(
|
||||
base({
|
||||
detail: {
|
||||
kind: "aircraft",
|
||||
aircraft: {
|
||||
id: "a1b2c3",
|
||||
callsign: "UAL 512",
|
||||
lat: 37.6188,
|
||||
lng: -122.3756,
|
||||
altitude: 3048,
|
||||
heading: 47,
|
||||
attribution: "Data from adsb.lol, ODbL",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(state.detailVisible, true);
|
||||
assert.equal(state.detail?.kind, "aircraft");
|
||||
if (state.detail?.kind !== "aircraft") throw new Error("unreachable");
|
||||
assert.equal(state.detail.view.title, "UAL 512");
|
||||
assert.equal(state.detail.view.subtitle, "Mode S A1B2C3");
|
||||
assert.deepEqual(
|
||||
state.detail.view.rows.map((row) => row.label),
|
||||
["Altitude", "Heading", "Position"],
|
||||
);
|
||||
assert.match(state.detail.view.rows[0]?.value ?? "", /10,000 ft · 3,048 m/);
|
||||
assert.match(state.detail.view.rows[1]?.value ?? "", /47° NE/);
|
||||
assert.match(state.detail.view.rows[2]?.value ?? "", /37\.619° N · 122\.376° W/);
|
||||
assert.equal(state.detail.view.attribution, "Data from adsb.lol, ODbL");
|
||||
});
|
||||
|
||||
it("labels an invented track as invented", () => {
|
||||
const state = chromeState(
|
||||
base({
|
||||
detail: {
|
||||
kind: "aircraft",
|
||||
aircraft: { id: "sim-SIM 1", lat: 34, lng: -118, altitude: 900, heading: 180, synthetic: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (state.detail?.kind !== "aircraft") throw new Error("unreachable");
|
||||
assert.equal(state.detail.view.synthetic, true);
|
||||
assert.match(state.detail.view.subtitle, /Simulated track/);
|
||||
});
|
||||
|
||||
it("still shows a plain marker string", () => {
|
||||
const state = chromeState(base({ detail: { kind: "text", text: "Something on the map." } }));
|
||||
assert.equal(state.detailVisible, true);
|
||||
assert.deepEqual(state.detail, { kind: "text", text: "Something on the map." });
|
||||
assert.equal(chromeState(base()).detailVisible, false);
|
||||
assert.equal(chromeState(base()).detail, null);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Onboarding -----------------------------------------------------------
|
||||
|
||||
describe("onboarding's own visibility", () => {
|
||||
it("appears on a first visit to the board, and on no repeat visit", () => {
|
||||
assert.equal(chromeState(base({ firstVisit: true })).onboardingVisible, true);
|
||||
assert.equal(chromeState(base({ firstVisit: false })).onboardingVisible, false);
|
||||
});
|
||||
|
||||
it("does not interrupt somebody who is already doing the thing it teaches", () => {
|
||||
assert.equal(chromeState(base({ firstVisit: true, mode: "drive" })).onboardingVisible, false);
|
||||
assert.equal(
|
||||
chromeState(
|
||||
base({ firstVisit: true, inside: true, mode: "office-overview", available: OFFICE_MODES }),
|
||||
).onboardingVisible,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Purity ---------------------------------------------------------------
|
||||
|
||||
describe("purity", () => {
|
||||
it("returns the same answer for the same inputs, and mutates nothing", () => {
|
||||
// The whole reason this module exists. A decision that reads module state is
|
||||
// a decision you cannot enumerate.
|
||||
const inputs = base({ mode: "drive", firstVisit: true });
|
||||
const snapshot = JSON.parse(JSON.stringify(inputs)) as unknown;
|
||||
const first = chromeState(inputs);
|
||||
const second = chromeState(inputs);
|
||||
assert.deepEqual(first, second);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(inputs)), snapshot);
|
||||
});
|
||||
|
||||
it("survives being given only the twelve documented keys", () => {
|
||||
// The contract with `integration`, which adopts this with stubs before the
|
||||
// optional refinements are wired.
|
||||
const minimal: ChromeInputs = {
|
||||
mode: "overview",
|
||||
available: ["overview"],
|
||||
access: { tier: "anon", signInUrl: null, subject: null },
|
||||
inside: false,
|
||||
officeDepth: null,
|
||||
viewport: { width: 1440, height: 900, coarsePointer: false },
|
||||
feeds: { markers: false, weather: false, flights: false },
|
||||
degraded: [],
|
||||
devices: [],
|
||||
firstVisit: false,
|
||||
panelOpen: true,
|
||||
planOpen: true,
|
||||
};
|
||||
const state = chromeState(minimal);
|
||||
assert.equal(state.modeDockVisible, false);
|
||||
assert.equal(state.boards.length, 0);
|
||||
assert.equal(state.views.length, 0);
|
||||
assert.equal(state.playHud, null);
|
||||
assert.equal(state.detail, null);
|
||||
assert.equal(state.tierVisible, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
CANONICAL_CAPABILITIES,
|
||||
DEVICE_RANGES,
|
||||
initialDeviceState,
|
||||
} from "../../devices/types.ts";
|
||||
import type { DeviceCommand, DeviceDeclaration, DeviceState } from "../../devices/types.ts";
|
||||
import { FakeDocument, type FakeElement } from "./fakeDom.ts";
|
||||
import { meterFraction, mountDevicePanel } from "../../ui/devicePanel.ts";
|
||||
|
||||
const MIC: DeviceDeclaration = {
|
||||
id: "hq-mic-01",
|
||||
kind: "mic",
|
||||
label: "Desk mic",
|
||||
assetId: "tera:device.mic.desk",
|
||||
anchor: { levelId: "l1", propId: "desk-01", roomId: "studio", seatId: "studio-01" },
|
||||
capabilities: CANONICAL_CAPABILITIES.mic,
|
||||
provenance: "simulated",
|
||||
disclosure: "Simulated studio hardware. This is demonstration data, never presence data.",
|
||||
};
|
||||
|
||||
const SPEAKER: DeviceDeclaration = {
|
||||
id: "hq-spk-01",
|
||||
kind: "speaker",
|
||||
label: "Monitor speaker",
|
||||
assetId: "tera:device.speaker.desk",
|
||||
anchor: { levelId: "l1", propId: "desk-01" },
|
||||
capabilities: CANONICAL_CAPABILITIES.speaker,
|
||||
provenance: "simulated",
|
||||
disclosure: "Simulated studio hardware. Nothing here is a reading from a real room.",
|
||||
};
|
||||
|
||||
function setup(declarations: readonly DeviceDeclaration[] = [MIC, SPEAKER]) {
|
||||
const doc = new FakeDocument();
|
||||
const host = doc.createElement("div");
|
||||
doc.body.append(host);
|
||||
const commands: DeviceCommand[] = [];
|
||||
const panel = mountDevicePanel(host as unknown as HTMLElement, {
|
||||
declarations,
|
||||
onCommand: (command) => commands.push(command),
|
||||
});
|
||||
return { doc, host, panel, commands, root: panel.root as unknown as FakeElement };
|
||||
}
|
||||
|
||||
function card(root: FakeElement, id: string): FakeElement {
|
||||
const found = root.querySelector(`[data-device=${id}]`);
|
||||
assert.ok(found, `no card for ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function row(root: FakeElement, deviceId: string, capability: string): FakeElement {
|
||||
const found = card(root, deviceId).querySelector(`[data-capability=${capability}]`);
|
||||
assert.ok(found, `no ${capability} row on ${deviceId}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("the device panel", () => {
|
||||
it("renders exactly one control per declared capability, and never switches on kind", () => {
|
||||
const { root } = setup();
|
||||
const micRows = card(root, MIC.id).querySelectorAll("[data-capability]");
|
||||
assert.equal(micRows.length, MIC.capabilities.length);
|
||||
assert.deepEqual(
|
||||
micRows.map((element) => element.getAttribute("data-capability")),
|
||||
[...MIC.capabilities],
|
||||
);
|
||||
|
||||
const speakerRows = card(root, SPEAKER.id).querySelectorAll("[data-capability]");
|
||||
assert.equal(speakerRows.length, SPEAKER.capabilities.length);
|
||||
assert.deepEqual(
|
||||
speakerRows.map((element) => element.getAttribute("data-capability")),
|
||||
[...SPEAKER.capabilities],
|
||||
);
|
||||
});
|
||||
|
||||
it("renders only the capabilities a device actually declares", () => {
|
||||
// A ceiling array with no gain stage. The panel must not grow a gain slider
|
||||
// for it — the capability list is per device, not per kind.
|
||||
const array: DeviceDeclaration = { ...MIC, id: "hq-mic-02", capabilities: ["power", "mute"] };
|
||||
const { root } = setup([array]);
|
||||
const rows = card(root, array.id).querySelectorAll("[data-capability]");
|
||||
assert.deepEqual(rows.map((r) => r.getAttribute("data-capability")), ["power", "mute"]);
|
||||
assert.equal(card(root, array.id).querySelector("[data-capability=gain]"), null);
|
||||
});
|
||||
|
||||
it("prints the declaration's disclosure under every instrument", () => {
|
||||
// Per instrument, not once per panel: a disclosure at the top of a scrolling
|
||||
// list is one you have already scrolled past by the time you reach the meter
|
||||
// that needed it.
|
||||
const { root } = setup();
|
||||
for (const declaration of [MIC, SPEAKER]) {
|
||||
const text = card(root, declaration.id).text();
|
||||
assert.ok(
|
||||
text.includes(declaration.disclosure),
|
||||
`${declaration.id} does not show its disclosure`,
|
||||
);
|
||||
assert.match(text.toLowerCase(), /simulat/);
|
||||
}
|
||||
});
|
||||
|
||||
it("starts at rest rather than blank, from the same initialDeviceState everything else uses", () => {
|
||||
const { root } = setup();
|
||||
assert.equal(row(root, MIC.id, "power").querySelector("button")?.getAttribute("aria-pressed"), "false");
|
||||
const gain = row(root, MIC.id, "gain").querySelector("input");
|
||||
assert.equal(gain?.value, String(DEVICE_RANGES.gain.initial));
|
||||
// The meter sits at the floor: a microphone nobody has switched on is not
|
||||
// hearing −20 dBFS of anything.
|
||||
assert.equal(row(root, MIC.id, "level").querySelector("i")?.style.width, "0.0%");
|
||||
});
|
||||
|
||||
it("emits a well-formed, validated command on a switch", () => {
|
||||
const { root, commands } = setup();
|
||||
row(root, MIC.id, "power").querySelector("button")?.click();
|
||||
assert.deepEqual(commands, [{ deviceId: MIC.id, op: "power", value: true }]);
|
||||
|
||||
row(root, SPEAKER.id, "playback").querySelector("button")?.click();
|
||||
assert.deepEqual(commands[1], { deviceId: SPEAKER.id, op: "playback", value: true });
|
||||
});
|
||||
|
||||
it("toggles against what is on screen, not blindly to true", () => {
|
||||
// A toggle that always sends `true` is a button that works exactly once.
|
||||
const { root, panel, commands } = setup();
|
||||
panel.apply([{ ...initialDeviceState(MIC, 1), powered: true }]);
|
||||
row(root, MIC.id, "power").querySelector("button")?.click();
|
||||
assert.deepEqual(commands.at(-1), { deviceId: MIC.id, op: "power", value: false });
|
||||
});
|
||||
|
||||
it("emits a clamped command from a slider, and refuses a nonsense one", () => {
|
||||
const { root, commands } = setup();
|
||||
const gain = row(root, MIC.id, "gain").querySelector("input");
|
||||
assert.ok(gain);
|
||||
|
||||
gain.value = "18";
|
||||
gain.dispatch("input");
|
||||
assert.deepEqual(commands.at(-1), { deviceId: MIC.id, op: "gain", value: 18 });
|
||||
|
||||
// Clamped, not refused: a slider reporting 1.0000000002 is not an attack.
|
||||
gain.value = String(DEVICE_RANGES.gain.max + 40);
|
||||
gain.dispatch("input");
|
||||
assert.deepEqual(commands.at(-1), { deviceId: MIC.id, op: "gain", value: DEVICE_RANGES.gain.max });
|
||||
|
||||
const before = commands.length;
|
||||
gain.value = "not a number";
|
||||
gain.dispatch("input");
|
||||
assert.equal(commands.length, before, "a non-numeric slider value must be a silent no-op");
|
||||
});
|
||||
|
||||
it("gives every slider the declared range and a label a screen reader can use", () => {
|
||||
const { root } = setup();
|
||||
const gain = row(root, MIC.id, "gain").querySelector("input");
|
||||
assert.equal(gain?.min, String(DEVICE_RANGES.gain.min));
|
||||
assert.equal(gain?.max, String(DEVICE_RANGES.gain.max));
|
||||
assert.match(gain?.getAttribute("aria-label") ?? "", /Desk mic Gain/);
|
||||
|
||||
const volume = row(root, SPEAKER.id, "volume").querySelector("input");
|
||||
assert.equal(volume?.min, String(DEVICE_RANGES.volume.min));
|
||||
assert.equal(volume?.max, String(DEVICE_RANGES.volume.max));
|
||||
});
|
||||
|
||||
it("never renders a command control for a read-only reading", () => {
|
||||
// `level` is a meter. `DeviceCommandOp` excludes it, and so does the panel.
|
||||
const { root, commands } = setup();
|
||||
const level = row(root, MIC.id, "level");
|
||||
assert.equal(level.querySelector("button"), null);
|
||||
assert.equal(level.querySelector("input"), null);
|
||||
assert.equal(level.querySelector(".tera-device__meter")?.getAttribute("role"), "meter");
|
||||
assert.equal(commands.length, 0);
|
||||
});
|
||||
|
||||
it("shows a reading, and treats undefined as absent rather than as zero", () => {
|
||||
const { root, panel } = setup();
|
||||
const live: DeviceState = {
|
||||
...initialDeviceState(MIC, 5),
|
||||
powered: true,
|
||||
gainDb: 22,
|
||||
levelDb: -12,
|
||||
muted: true,
|
||||
};
|
||||
panel.apply([live]);
|
||||
assert.equal(row(root, MIC.id, "gain").querySelector(".tera-device__value")?.textContent, "+22 dB");
|
||||
assert.equal(row(root, MIC.id, "level").querySelector(".tera-device__value")?.textContent, "-12 dBFS");
|
||||
assert.equal(row(root, MIC.id, "mute").querySelector("button")?.getAttribute("aria-pressed"), "true");
|
||||
assert.equal(row(root, MIC.id, "gain").getAttribute("data-unavailable"), "false");
|
||||
|
||||
// A device that reports no gain at all has no gain, which is a different
|
||||
// statement from "gain is zero".
|
||||
const { gainDb: _dropped, ...withoutGain } = live;
|
||||
panel.apply([withoutGain as DeviceState]);
|
||||
assert.equal(row(root, MIC.id, "gain").getAttribute("data-unavailable"), "true");
|
||||
assert.equal(row(root, MIC.id, "gain").querySelector(".tera-device__value")?.textContent, "—");
|
||||
});
|
||||
|
||||
it("maps a dBFS level onto the meter's travel", () => {
|
||||
assert.equal(meterFraction(DEVICE_RANGES.level.min), 0);
|
||||
assert.equal(meterFraction(DEVICE_RANGES.level.max), 1);
|
||||
assert.equal(meterFraction(-30), 0.5);
|
||||
assert.equal(meterFraction(undefined), 0);
|
||||
assert.equal(meterFraction(Number.NaN), 0);
|
||||
// Out of range is clamped, not wrapped or negative-width.
|
||||
assert.equal(meterFraction(-200), 0);
|
||||
assert.equal(meterFraction(20), 1);
|
||||
});
|
||||
|
||||
it("mounts one stylesheet per document, however many panels are built", () => {
|
||||
const doc = new FakeDocument();
|
||||
const first = doc.createElement("div");
|
||||
const second = doc.createElement("div");
|
||||
doc.body.append(first, second);
|
||||
const a = mountDevicePanel(first as unknown as HTMLElement, {
|
||||
declarations: [MIC],
|
||||
onCommand: () => {},
|
||||
});
|
||||
const b = mountDevicePanel(second as unknown as HTMLElement, {
|
||||
declarations: [SPEAKER],
|
||||
onCommand: () => {},
|
||||
});
|
||||
assert.equal(doc.head.querySelectorAll("style").length, 1);
|
||||
b.dispose();
|
||||
a.dispose();
|
||||
assert.equal(doc.head.querySelectorAll("style").length, 0);
|
||||
});
|
||||
|
||||
it("goes silent and detaches on dispose", () => {
|
||||
const { root, host, panel, commands } = setup();
|
||||
const power = row(root, MIC.id, "power").querySelector("button");
|
||||
panel.dispose();
|
||||
assert.equal(host.children.length, 0);
|
||||
power?.click();
|
||||
panel.apply([initialDeviceState(MIC, 9)]);
|
||||
assert.deepEqual(commands, [], "a disposed panel must not still be sending commands");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* A DOM small enough to run in `node --test` and honest enough to catch the bugs
|
||||
* this interface actually has.
|
||||
*
|
||||
* The repo already had three hand-rolled fakes of this shape — in
|
||||
* `presenceIndicator.test.ts`, `webcamPanel.test.ts` and
|
||||
* `officeScreenPanel.test.ts` — each covering exactly the four methods its own
|
||||
* subject called, and each subtly different. This is the fourth, written once
|
||||
* and shared by the six `src/test/ui/*` suites, because the chrome applier
|
||||
* touches considerably more of the DOM than any single panel does: attributes,
|
||||
* classes, `hidden`, `style` custom properties, `replaceChildren`, event
|
||||
* dispatch, `closest` and a small selector engine.
|
||||
*
|
||||
* It is deliberately not jsdom. The repo has no DOM dependency, the test command
|
||||
* is `node --test` with type stripping and nothing else, and adding a
|
||||
* multi-megabyte browser emulation to assert that a button gets
|
||||
* `aria-pressed="true"` would be a poor trade — the "no surprise dependencies"
|
||||
* allowlist in CI exists for exactly this kind of drive-by addition.
|
||||
*
|
||||
* What it supports, and therefore what a test may rely on: `#id`, `.class`,
|
||||
* `tag` and `[attribute]` selectors, and comma-free compound selectors of the
|
||||
* form `tag.class[attr]`. Anything more expressive is a sign the code under test
|
||||
* is doing something a pure state function should have decided instead.
|
||||
*/
|
||||
|
||||
type Listener = (event: FakeEvent) => void;
|
||||
|
||||
export interface FakeEvent {
|
||||
type: string;
|
||||
target: FakeElement | null;
|
||||
preventDefault(): void;
|
||||
defaultPrevented: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class FakeClassList {
|
||||
private readonly owner: FakeElement;
|
||||
constructor(owner: FakeElement) {
|
||||
this.owner = owner;
|
||||
}
|
||||
private list(): string[] {
|
||||
return this.owner.className.split(/\s+/).filter((name) => name !== "");
|
||||
}
|
||||
private write(names: readonly string[]): void {
|
||||
this.owner.className = [...new Set(names)].join(" ");
|
||||
}
|
||||
add(...names: string[]): void {
|
||||
this.write([...this.list(), ...names]);
|
||||
}
|
||||
remove(...names: string[]): void {
|
||||
this.write(this.list().filter((name) => !names.includes(name)));
|
||||
}
|
||||
contains(name: string): boolean {
|
||||
return this.list().includes(name);
|
||||
}
|
||||
toggle(name: string, force?: boolean): boolean {
|
||||
const on = force ?? !this.contains(name);
|
||||
if (on) this.add(name);
|
||||
else this.remove(name);
|
||||
return on;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeStyle {
|
||||
readonly properties = new Map<string, string>();
|
||||
width = "";
|
||||
minHeight = "";
|
||||
setProperty(name: string, value: string): void {
|
||||
this.properties.set(name, value);
|
||||
}
|
||||
getPropertyValue(name: string): string {
|
||||
return this.properties.get(name) ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeElement {
|
||||
readonly tagName: string;
|
||||
readonly ownerDocument: FakeDocument;
|
||||
/**
|
||||
* Elements and text, in document order.
|
||||
*
|
||||
* Kept as one mixed list rather than as `children` plus a `textContent`
|
||||
* string, because the difference is load-bearing for this applier: in a real
|
||||
* DOM, assigning `textContent` **removes every child**, and `mount.ts` relies
|
||||
* on exactly that to replace a rendered aircraft card with a one-line marker
|
||||
* caption. A fake that stored text beside its children instead of in place of
|
||||
* them let that pass and would have shipped a detail card with the previous
|
||||
* aircraft still stacked underneath it.
|
||||
*/
|
||||
readonly nodes: (FakeElement | FakeText)[] = [];
|
||||
readonly attributes = new Map<string, string>();
|
||||
readonly listeners = new Map<string, Listener[]>();
|
||||
readonly classList = new FakeClassList(this);
|
||||
readonly style = new FakeStyle();
|
||||
parentElement: FakeElement | null = null;
|
||||
className = "";
|
||||
id = "";
|
||||
hidden = false;
|
||||
disabled = false;
|
||||
type = "";
|
||||
href = "";
|
||||
value = "";
|
||||
min = "";
|
||||
max = "";
|
||||
step = "";
|
||||
capturedPointers: number[] = [];
|
||||
focused = false;
|
||||
|
||||
constructor(ownerDocument: FakeDocument, tagName: string) {
|
||||
this.ownerDocument = ownerDocument;
|
||||
this.tagName = tagName.toUpperCase();
|
||||
}
|
||||
|
||||
/** Element children only, in order — the same subset `Element.children` is. */
|
||||
get children(): FakeElement[] {
|
||||
return this.nodes.filter((node): node is FakeElement => node instanceof FakeElement);
|
||||
}
|
||||
|
||||
/** Every string in this subtree, exactly as the real getter concatenates it. */
|
||||
get textContent(): string {
|
||||
return this.nodes
|
||||
.map((node) => (node instanceof FakeText ? node.data : node.textContent))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Assigning removes every child, which is the behaviour that matters here. */
|
||||
set textContent(value: string) {
|
||||
for (const child of this.children) child.parentElement = null;
|
||||
this.nodes.length = 0;
|
||||
if (value !== "") this.nodes.push(new FakeText(value));
|
||||
}
|
||||
|
||||
append(...nodes: (FakeElement | FakeText)[]): void {
|
||||
for (const node of nodes) {
|
||||
if (node instanceof FakeElement) node.parentElement = this;
|
||||
this.nodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
replaceChildren(...nodes: (FakeElement | FakeText)[]): void {
|
||||
for (const child of this.children) child.parentElement = null;
|
||||
this.nodes.length = 0;
|
||||
this.append(...nodes);
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
const parent = this.parentElement;
|
||||
if (parent === null) return;
|
||||
const index = parent.nodes.indexOf(this);
|
||||
if (index >= 0) parent.nodes.splice(index, 1);
|
||||
this.parentElement = null;
|
||||
}
|
||||
|
||||
setAttribute(name: string, value: string): void {
|
||||
if (name === "id") this.id = value;
|
||||
this.attributes.set(name, value);
|
||||
}
|
||||
|
||||
getAttribute(name: string): string | null {
|
||||
if (name === "id" && this.id !== "") return this.id;
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
removeAttribute(name: string): void {
|
||||
this.attributes.delete(name);
|
||||
}
|
||||
|
||||
hasAttribute(name: string): boolean {
|
||||
return this.getAttribute(name) !== null;
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: Listener): void {
|
||||
const existing = this.listeners.get(type);
|
||||
if (existing) existing.push(listener);
|
||||
else this.listeners.set(type, [listener]);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: Listener): void {
|
||||
const existing = this.listeners.get(type);
|
||||
if (!existing) return;
|
||||
const index = existing.indexOf(listener);
|
||||
if (index >= 0) existing.splice(index, 1);
|
||||
}
|
||||
|
||||
/** Fire an event on this element and bubble it to every ancestor. */
|
||||
dispatch(type: string, extra: Record<string, unknown> = {}): FakeEvent {
|
||||
const event: FakeEvent = {
|
||||
type,
|
||||
target: this,
|
||||
defaultPrevented: false,
|
||||
preventDefault() {
|
||||
event.defaultPrevented = true;
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
let node: FakeElement | null = this;
|
||||
while (node !== null) {
|
||||
for (const listener of [...(node.listeners.get(type) ?? [])]) listener(event);
|
||||
node = node.parentElement;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
click(): FakeEvent {
|
||||
return this.dispatch("click");
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.focused = true;
|
||||
this.ownerDocument.activeElement = this;
|
||||
}
|
||||
|
||||
setPointerCapture(pointerId: number): void {
|
||||
this.capturedPointers.push(pointerId);
|
||||
}
|
||||
|
||||
getBoundingClientRect(): { left: number; top: number; width: number; height: number } {
|
||||
return { left: 0, top: 0, width: 100, height: 100 };
|
||||
}
|
||||
|
||||
closest(selector: string): FakeElement | null {
|
||||
let node: FakeElement | null = this;
|
||||
while (node !== null) {
|
||||
if (matches(node, selector)) return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
querySelector(selector: string): FakeElement | null {
|
||||
return this.querySelectorAll(selector)[0] ?? null;
|
||||
}
|
||||
|
||||
querySelectorAll(selector: string): FakeElement[] {
|
||||
const found: FakeElement[] = [];
|
||||
const walk = (node: FakeElement): void => {
|
||||
for (const child of node.children) {
|
||||
if (matches(child, selector)) found.push(child);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(this);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Alias for `textContent`, kept because it reads better in an assertion. */
|
||||
text(): string {
|
||||
return this.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeText {
|
||||
readonly data: string;
|
||||
constructor(data: string) {
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The selector engine. `#id`, `.class`, `tag`, `[attr]`, and any concatenation
|
||||
* of those with no combinator — which covers every selector the chrome uses.
|
||||
*/
|
||||
function matches(element: FakeElement, selector: string): boolean {
|
||||
const parts = selector.trim().match(/#[^.#[\]]+|\.[^.#[\]]+|\[[^\]]+\]|^[a-zA-Z][a-zA-Z0-9-]*/g);
|
||||
if (parts === null || parts.length === 0) return false;
|
||||
for (const part of parts) {
|
||||
if (part.startsWith("#")) {
|
||||
if (element.id !== part.slice(1)) return false;
|
||||
} else if (part.startsWith(".")) {
|
||||
if (!element.classList.contains(part.slice(1))) return false;
|
||||
} else if (part.startsWith("[")) {
|
||||
const body = part.slice(1, -1);
|
||||
const equals = body.indexOf("=");
|
||||
if (equals < 0) {
|
||||
if (!element.hasAttribute(body)) return false;
|
||||
} else {
|
||||
const name = body.slice(0, equals);
|
||||
const wanted = body.slice(equals + 1).replace(/^["']|["']$/g, "");
|
||||
if (element.getAttribute(name) !== wanted) return false;
|
||||
}
|
||||
} else if (element.tagName !== part.toUpperCase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export class FakeDocument {
|
||||
readonly head: FakeElement;
|
||||
readonly body: FakeElement;
|
||||
activeElement: FakeElement | null = null;
|
||||
|
||||
constructor() {
|
||||
this.head = new FakeElement(this, "head");
|
||||
this.body = new FakeElement(this, "body");
|
||||
}
|
||||
|
||||
createElement(tagName: string): FakeElement {
|
||||
return new FakeElement(this, tagName);
|
||||
}
|
||||
|
||||
createTextNode(data: string): FakeText {
|
||||
return new FakeText(data);
|
||||
}
|
||||
|
||||
querySelector(selector: string): FakeElement | null {
|
||||
return this.body.querySelector(selector) ?? this.head.querySelector(selector);
|
||||
}
|
||||
|
||||
querySelectorAll(selector: string): FakeElement[] {
|
||||
return [...this.body.querySelectorAll(selector), ...this.head.querySelectorAll(selector)];
|
||||
}
|
||||
}
|
||||
|
||||
/** Build an element with an id, appended to `parent`, in one line. */
|
||||
export function el(
|
||||
doc: FakeDocument,
|
||||
parent: FakeElement,
|
||||
tagName: string,
|
||||
id: string,
|
||||
className = "",
|
||||
): FakeElement {
|
||||
const element = doc.createElement(tagName);
|
||||
element.id = id;
|
||||
element.className = className;
|
||||
parent.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* A storage double.
|
||||
*
|
||||
* `mode: "throwing"` throws from both accessors, which is the Safari
|
||||
* private-browsing / blocked-third-party-context case and the one the onboarding
|
||||
* module has to survive without taking the page down with it.
|
||||
*/
|
||||
export function fakeStorage(mode: "working" | "throwing" = "working"): {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
readonly map: Map<string, string>;
|
||||
} {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
map,
|
||||
getItem(key: string): string | null {
|
||||
if (mode === "throwing") throw new Error("storage is not available");
|
||||
return map.get(key) ?? null;
|
||||
},
|
||||
setItem(key: string, value: string): void {
|
||||
if (mode === "throwing") throw new Error("storage is not available");
|
||||
map.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { CANONICAL_CAPABILITIES } from "../../devices/types.ts";
|
||||
import type { DeviceCommand, DeviceDeclaration } from "../../devices/types.ts";
|
||||
import type { ControlMode } from "../../play/controlMode.ts";
|
||||
import { chromeState } from "../../ui/chromeState.ts";
|
||||
import type { ChromeInputs } from "../../ui/chromeState.ts";
|
||||
import { mountChrome } from "../../ui/mount.ts";
|
||||
import type { ChromeMountOptions } from "../../ui/mount.ts";
|
||||
import { FakeDocument, type FakeElement, el, fakeStorage } from "./fakeDom.ts";
|
||||
|
||||
const MIC: DeviceDeclaration = {
|
||||
id: "hq-mic-01",
|
||||
kind: "mic",
|
||||
label: "Desk mic",
|
||||
assetId: "tera:device.mic.desk",
|
||||
anchor: { levelId: "l1", propId: "desk-01" },
|
||||
capabilities: CANONICAL_CAPABILITIES.mic,
|
||||
provenance: "simulated",
|
||||
disclosure: "Simulated studio hardware. This is demonstration data, never presence data.",
|
||||
};
|
||||
|
||||
/**
|
||||
* The page, as `mount.ts` expects to find it.
|
||||
*
|
||||
* Built rather than parsed: the ids are the contract between this applier and
|
||||
* `index.html`, and `stylesheet.test.ts` asserts the real document carries every
|
||||
* one of them. Between the two tests, a renamed id fails somewhere rather than
|
||||
* silently ceasing to apply one decision.
|
||||
*/
|
||||
function buildPage(): FakeDocument {
|
||||
const doc = new FakeDocument();
|
||||
const body = doc.body;
|
||||
|
||||
el(doc, body, "canvas", "scene");
|
||||
el(doc, body, "button", "panel-toggle");
|
||||
const panel = el(doc, body, "div", "panel");
|
||||
el(doc, panel, "span", "panel-toggle-label");
|
||||
el(doc, body, "div", "scrim");
|
||||
el(doc, panel, "h1", "title");
|
||||
el(doc, panel, "p", "subtitle");
|
||||
el(doc, panel, "p", "clock");
|
||||
el(doc, panel, "p", "boards-title");
|
||||
el(doc, panel, "nav", "cities");
|
||||
el(doc, panel, "button", "enter");
|
||||
el(doc, panel, "button", "walk");
|
||||
el(doc, panel, "button", "fly");
|
||||
el(doc, panel, "button", "screens");
|
||||
el(doc, panel, "button", "devices");
|
||||
const deviceSection = el(doc, panel, "section", "device-section");
|
||||
el(doc, deviceSection, "div", "device-host");
|
||||
el(doc, panel, "p", "office-invite");
|
||||
el(doc, panel, "p", "office-note");
|
||||
el(doc, panel, "nav", "chapters");
|
||||
el(doc, panel, "p", "blurb");
|
||||
|
||||
const topright = el(doc, body, "div", "topright");
|
||||
const tier = el(doc, topright, "p", "tier");
|
||||
el(doc, tier, "span", "tier-label");
|
||||
el(doc, tier, "span", "tier-who");
|
||||
el(doc, tier, "a", "tier-signin");
|
||||
el(doc, tier, "button", "tier-character");
|
||||
el(doc, topright, "p", "tier-adds");
|
||||
el(doc, topright, "div", "presence-host");
|
||||
const corner = el(doc, topright, "aside", "corner");
|
||||
const minimap = el(doc, corner, "div", "minimap");
|
||||
el(doc, minimap, "p", "minimap-readout");
|
||||
|
||||
const dock = el(doc, body, "nav", "mode-dock");
|
||||
for (const mode of ["overview", "drive", "actor", "aircraft", "office-walk"]) {
|
||||
const button = doc.createElement("button");
|
||||
button.setAttribute("data-control-mode", mode);
|
||||
dock.append(button);
|
||||
}
|
||||
|
||||
const hud = el(doc, body, "section", "play-hud");
|
||||
el(doc, hud, "span", "play-hud-mode");
|
||||
el(doc, hud, "strong", "play-hud-primary");
|
||||
el(doc, hud, "span", "play-hud-status");
|
||||
|
||||
const rail = el(doc, body, "div", "rail");
|
||||
const detail = el(doc, rail, "div", "detail");
|
||||
el(doc, detail, "div", "detail-text");
|
||||
el(doc, detail, "button", "detail-close");
|
||||
el(doc, rail, "div", "hint");
|
||||
el(doc, rail, "button", "plan-toggle");
|
||||
el(doc, rail, "button", "help");
|
||||
|
||||
const touch = el(doc, body, "div", "touch-play-controls");
|
||||
const stick = el(doc, touch, "div", "play-stick");
|
||||
el(doc, stick, "span", "play-stick-knob");
|
||||
el(doc, stick, "span", "play-stick-label");
|
||||
for (const id of [
|
||||
"touch-primary", "touch-secondary", "touch-pitch-up", "touch-pitch-down",
|
||||
"touch-assist", "touch-reset", "touch-camera", "touch-map",
|
||||
]) {
|
||||
el(doc, touch, "button", id);
|
||||
}
|
||||
|
||||
el(doc, body, "p", "source");
|
||||
const shortcuts = el(doc, body, "div", "shortcuts");
|
||||
shortcuts.hidden = true;
|
||||
el(doc, shortcuts, "div", "shortcuts-body");
|
||||
el(doc, shortcuts, "button", "shortcuts-close");
|
||||
el(doc, body, "div", "onboarding-host");
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
function baseInputs(overrides: Partial<ChromeInputs> = {}): ChromeInputs {
|
||||
return {
|
||||
mode: "overview",
|
||||
available: ["overview", "drive", "actor", "aircraft"],
|
||||
access: { tier: "anon", signInUrl: "/login.html", subject: null },
|
||||
inside: false,
|
||||
officeDepth: null,
|
||||
viewport: { width: 1440, height: 900, coarsePointer: false },
|
||||
feeds: { markers: false, weather: true, flights: true },
|
||||
degraded: [],
|
||||
devices: [],
|
||||
firstVisit: false,
|
||||
panelOpen: true,
|
||||
planOpen: true,
|
||||
board: {
|
||||
cityId: "california",
|
||||
cityLabel: "California",
|
||||
officeLabel: "SF HQ",
|
||||
officeStatus: "active",
|
||||
isCalifornia: true,
|
||||
},
|
||||
boards: [
|
||||
{ id: "california", label: "California" },
|
||||
{ id: "bay-area", label: "Bay Area" },
|
||||
],
|
||||
views: [
|
||||
{ id: "one", shortLabel: "Approach", description: "A long way out." },
|
||||
{ id: "two", shortLabel: "Paseo" },
|
||||
],
|
||||
activeViewId: "one",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface Recorded {
|
||||
boards: string[];
|
||||
views: string[];
|
||||
modes: ControlMode[];
|
||||
panel: boolean[];
|
||||
plan: boolean[];
|
||||
holds: [string, boolean][];
|
||||
edges: string[];
|
||||
stick: ({ moveX: number; moveY: number } | null)[];
|
||||
commands: DeviceCommand[];
|
||||
clicks: string[];
|
||||
}
|
||||
|
||||
function setup(extra: Partial<ChromeMountOptions> = {}) {
|
||||
const doc = buildPage();
|
||||
const recorded: Recorded = {
|
||||
boards: [], views: [], modes: [], panel: [], plan: [],
|
||||
holds: [], edges: [], stick: [], commands: [], clicks: [],
|
||||
};
|
||||
const chrome = mountChrome(doc as unknown as Document, {
|
||||
storage: fakeStorage(),
|
||||
onSelectBoard: (id) => recorded.boards.push(id),
|
||||
onSelectView: (id) => recorded.views.push(id),
|
||||
onMode: (mode) => recorded.modes.push(mode),
|
||||
onTogglePanel: (open) => recorded.panel.push(open),
|
||||
onTogglePlan: (open) => recorded.plan.push(open),
|
||||
onTouchHold: (control, pressed) => recorded.holds.push([control, pressed]),
|
||||
onTouchEdge: (edge) => recorded.edges.push(edge),
|
||||
onStick: (axes) => recorded.stick.push(axes),
|
||||
onDeviceCommand: (command) => recorded.commands.push(command),
|
||||
onEnter: () => recorded.clicks.push("enter"),
|
||||
onWalk: () => recorded.clicks.push("walk"),
|
||||
onFly: () => recorded.clicks.push("fly"),
|
||||
onScreens: () => recorded.clicks.push("screens"),
|
||||
onDevices: () => recorded.clicks.push("devices"),
|
||||
onCharacter: () => recorded.clicks.push("character"),
|
||||
onDismissDetail: () => recorded.clicks.push("detail-close"),
|
||||
...extra,
|
||||
});
|
||||
const id = (name: string): FakeElement => {
|
||||
const found = doc.querySelector(`#${name}`);
|
||||
assert.ok(found, `#${name} missing from the fixture`);
|
||||
return found;
|
||||
};
|
||||
return { doc, chrome, recorded, id };
|
||||
}
|
||||
|
||||
describe("mountChrome", () => {
|
||||
it("applies a whole frame of chrome from one call", () => {
|
||||
const { chrome, id } = setup();
|
||||
chrome.apply(chromeState(baseInputs()));
|
||||
|
||||
assert.equal(id("title").textContent, "California");
|
||||
assert.equal(id("subtitle").textContent, "Tera · Lumbridge Simulate");
|
||||
assert.equal(id("boards-title").textContent, "Boards");
|
||||
assert.equal(id("cities").children.length, 2);
|
||||
assert.equal(id("chapters").children.length, 2);
|
||||
assert.equal(id("enter").textContent, "Open SF HQ →");
|
||||
assert.equal(id("blurb").hidden, false);
|
||||
assert.equal(id("blurb").textContent, "A long way out.");
|
||||
assert.equal(id("source").textContent, "live weather · live traffic");
|
||||
assert.equal(id("source").classList.contains("live"), true);
|
||||
});
|
||||
|
||||
it("marks the active board and the active view with aria-pressed, not a class", () => {
|
||||
const { chrome, id } = setup();
|
||||
chrome.apply(chromeState(baseInputs()));
|
||||
const boards = id("cities").children;
|
||||
assert.equal(boards[0]?.getAttribute("aria-pressed"), "true");
|
||||
assert.equal(boards[1]?.getAttribute("aria-pressed"), "false");
|
||||
const views = id("chapters").children;
|
||||
assert.equal(views[0]?.getAttribute("aria-pressed"), "true");
|
||||
assert.equal(views[0]?.querySelector(".num")?.textContent, "01");
|
||||
});
|
||||
|
||||
it("rebuilds a list only when its content changed", () => {
|
||||
// `replaceChildren` on the chapter list every frame would destroy focus and
|
||||
// restart every transition on it.
|
||||
const { chrome, id } = setup();
|
||||
chrome.apply(chromeState(baseInputs()));
|
||||
const first = id("chapters").children[0];
|
||||
chrome.apply(chromeState(baseInputs()));
|
||||
assert.equal(id("chapters").children[0], first, "the list was rebuilt for nothing");
|
||||
|
||||
chrome.apply(chromeState(baseInputs({ activeViewId: "two" })));
|
||||
assert.notEqual(id("chapters").children[0], first);
|
||||
});
|
||||
|
||||
it("routes a board click and a chapter click back by id", () => {
|
||||
const { chrome, id, recorded } = setup();
|
||||
chrome.apply(chromeState(baseInputs()));
|
||||
id("cities").children[1]?.click();
|
||||
assert.deepEqual(recorded.boards, ["bay-area"]);
|
||||
// The click lands on the inner span; `closest` has to find the button.
|
||||
id("chapters").children[1]?.querySelector(".num")?.click();
|
||||
assert.deepEqual(recorded.views, ["two"]);
|
||||
});
|
||||
|
||||
it("drives the dock from the state and reports a press back", () => {
|
||||
const { chrome, id, recorded } = setup();
|
||||
chrome.apply(chromeState(baseInputs({ mode: "drive" })));
|
||||
const buttons = id("mode-dock").children;
|
||||
assert.equal(buttons[0]?.getAttribute("aria-pressed"), "false");
|
||||
assert.equal(buttons[1]?.getAttribute("aria-pressed"), "true");
|
||||
assert.equal(buttons[4]?.hidden, true, "office-walk is not available outside a building");
|
||||
buttons[2]?.click();
|
||||
assert.deepEqual(recorded.modes, ["actor"]);
|
||||
});
|
||||
|
||||
it("hides the dock entirely rather than drawing one pressed button", () => {
|
||||
const { chrome, id } = setup();
|
||||
chrome.apply(chromeState(baseInputs({ available: ["overview"] })));
|
||||
assert.equal(id("mode-dock").hidden, true);
|
||||
});
|
||||
|
||||
it("writes the play HUD only when there is telemetry, and colours a warning", () => {
|
||||
const { chrome, id } = setup();
|
||||
chrome.apply(chromeState(baseInputs()));
|
||||
assert.equal(id("play-hud").hidden, true);
|
||||
|
||||
chrome.apply(
|
||||
chromeState(
|
||||
baseInputs({
|
||||
mode: "drive",
|
||||
telemetry: {
|
||||
kind: "drive",
|
||||
speedMps: 30,
|
||||
roadName: "I-5",
|
||||
driveMode: "manual",
|
||||
progress: 0.9,
|
||||
camera: "driver",
|
||||
guardrailContact: true,
|
||||
collisionRisk: 0.2,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal(id("play-hud").hidden, false);
|
||||
assert.equal(id("play-hud-mode").textContent, "Drive");
|
||||
assert.match(id("play-hud-primary").textContent, /I-5/);
|
||||
assert.equal(id("play-hud-status").classList.contains("warning"), true);
|
||||
});
|
||||
|
||||
it("renders an aircraft pick as a card rather than a sentence", () => {
|
||||
const { chrome, id } = setup();
|
||||
chrome.apply(
|
||||
chromeState(
|
||||
baseInputs({
|
||||
detail: {
|
||||
kind: "aircraft",
|
||||
aircraft: { id: "a1b2c3", callsign: "SWA 44", lat: 34.05, lng: -118.24, altitude: 2400, heading: 270 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal(id("detail").hidden, false);
|
||||
const text = id("detail-text").text();
|
||||
assert.match(text, /SWA 44/);
|
||||
assert.match(text, /Mode S A1B2C3/);
|
||||
assert.match(text, /7,874 ft/);
|
||||
assert.match(text, /270° W/);
|
||||
});
|
||||
|
||||
it("replaces the aircraft card when a plain marker is picked next", () => {
|
||||
const { chrome, id } = setup();
|
||||
chrome.apply(
|
||||
chromeState(
|
||||
baseInputs({
|
||||
detail: { kind: "aircraft", aircraft: { id: "a1", lat: 0, lng: 0, altitude: 0, heading: 0 } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
chrome.apply(chromeState(baseInputs({ detail: { kind: "text", text: "A marker." } })));
|
||||
assert.equal(id("detail-text").text(), "A marker.");
|
||||
assert.equal(id("detail-text").querySelector(".aircraft-card"), null);
|
||||
});
|
||||
|
||||
it("keeps the top-right column in one piece", () => {
|
||||
const { chrome, id } = setup();
|
||||
chrome.apply(chromeState(baseInputs()));
|
||||
assert.equal(id("tier").hidden, false);
|
||||
assert.equal(id("tier-label").textContent, "Open demo");
|
||||
assert.equal(id("tier-who").hidden, true);
|
||||
assert.equal(id("tier-signin").hidden, false);
|
||||
assert.equal(id("tier-signin").href, "/login.html");
|
||||
assert.equal(id("tier-adds").hidden, false);
|
||||
assert.match(id("tier-adds").textContent, /Sign in adds/);
|
||||
assert.equal(id("tier-character").hidden, true);
|
||||
|
||||
chrome.apply(
|
||||
chromeState(
|
||||
baseInputs({
|
||||
access: { tier: "god", signInUrl: null, subject: "a@b.c", displayName: "Karti", hasProfile: true },
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal(id("tier").getAttribute("data-tier"), "god");
|
||||
assert.equal(id("tier-who").textContent, "Karti");
|
||||
assert.equal(id("tier-signin").hidden, true);
|
||||
assert.equal(id("tier-adds").hidden, true);
|
||||
assert.equal(id("tier-character").hidden, false);
|
||||
});
|
||||
|
||||
it("diffs the body classes rather than accumulating them", () => {
|
||||
const { doc, chrome } = setup();
|
||||
chrome.apply(chromeState(baseInputs({ panelOpen: false })));
|
||||
assert.equal(doc.body.classList.contains("panel-closed"), true);
|
||||
chrome.apply(chromeState(baseInputs({ panelOpen: true })));
|
||||
assert.equal(doc.body.classList.contains("panel-closed"), false);
|
||||
assert.equal(doc.body.classList.contains("layout-desktop"), true);
|
||||
});
|
||||
|
||||
it("wires the panel and plan toggles to the state's own next value", () => {
|
||||
const { chrome, id, recorded } = setup();
|
||||
chrome.apply(chromeState(baseInputs({ panelOpen: true, planOpen: false })));
|
||||
id("panel-toggle").click();
|
||||
id("plan-toggle").click();
|
||||
assert.deepEqual(recorded.panel, [false]);
|
||||
assert.deepEqual(recorded.plan, [true]);
|
||||
});
|
||||
|
||||
it("holds a touch control down on pointerdown and releases it on pointerup", () => {
|
||||
// A click is a press *and* a release, and a sprint button bound to a click
|
||||
// sprints for zero milliseconds.
|
||||
const { chrome, id, recorded } = setup();
|
||||
chrome.apply(chromeState(baseInputs({ mode: "drive", viewport: { width: 390, height: 844, coarsePointer: true } })));
|
||||
const primary = id("touch-primary");
|
||||
primary.dispatch("pointerdown", { pointerId: 1 });
|
||||
assert.deepEqual(recorded.holds, [["primary", true]]);
|
||||
assert.equal(primary.getAttribute("aria-pressed"), "true");
|
||||
primary.dispatch("pointerup", { pointerId: 1 });
|
||||
assert.deepEqual(recorded.holds.at(-1), ["primary", false]);
|
||||
assert.equal(primary.getAttribute("aria-pressed"), "false");
|
||||
|
||||
// A finger that slides off the button still releases it.
|
||||
primary.dispatch("pointerdown", { pointerId: 2 });
|
||||
primary.dispatch("lostpointercapture", { pointerId: 2 });
|
||||
assert.deepEqual(recorded.holds.at(-1), ["primary", false]);
|
||||
});
|
||||
|
||||
it("fires the one-shot actions on a click", () => {
|
||||
const { chrome, id, recorded } = setup();
|
||||
chrome.apply(chromeState(baseInputs({ mode: "drive" })));
|
||||
id("touch-assist").click();
|
||||
id("touch-reset").click();
|
||||
id("touch-camera").click();
|
||||
assert.deepEqual(recorded.edges, ["assist", "reset", "camera"]);
|
||||
});
|
||||
|
||||
it("drives the analogue stick and returns to centre on release", () => {
|
||||
const { chrome, id, recorded } = setup();
|
||||
chrome.apply(
|
||||
chromeState(baseInputs({ mode: "actor", viewport: { width: 390, height: 844, coarsePointer: true } })),
|
||||
);
|
||||
const stick = id("play-stick");
|
||||
// The fixture's bounds are 100x100 at the origin, so the centre is (50, 50).
|
||||
stick.dispatch("pointerdown", { pointerId: 7, clientX: 100, clientY: 50 });
|
||||
assert.deepEqual(recorded.stick.at(-1), { moveX: 1, moveY: 0 });
|
||||
assert.equal(id("play-stick-knob").style.getPropertyValue("--stick-x"), "26.0px");
|
||||
|
||||
stick.dispatch("pointermove", { pointerId: 7, clientX: 50, clientY: 0 });
|
||||
assert.deepEqual(recorded.stick.at(-1), { moveX: 0, moveY: 1 });
|
||||
|
||||
// A second finger's move must not steal the gesture.
|
||||
const before = recorded.stick.length;
|
||||
stick.dispatch("pointermove", { pointerId: 9, clientX: 0, clientY: 0 });
|
||||
assert.equal(recorded.stick.length, before);
|
||||
|
||||
stick.dispatch("pointerup", { pointerId: 7 });
|
||||
assert.equal(recorded.stick.at(-1), null);
|
||||
assert.equal(id("play-stick-knob").style.getPropertyValue("--stick-x"), "0.0px");
|
||||
});
|
||||
|
||||
it("names what the joystick moves", () => {
|
||||
const { chrome, id } = setup();
|
||||
chrome.apply(
|
||||
chromeState(baseInputs({ mode: "drive", viewport: { width: 390, height: 844, coarsePointer: true } })),
|
||||
);
|
||||
assert.equal(id("play-stick-label").textContent, "Steer");
|
||||
assert.match(id("play-stick").getAttribute("aria-label") ?? "", /Steer joystick/);
|
||||
});
|
||||
|
||||
it("builds the ? sheet from the keymap, and opens and closes it", () => {
|
||||
const { chrome, id } = setup();
|
||||
chrome.apply(chromeState(baseInputs()));
|
||||
assert.ok(id("shortcuts-body").querySelectorAll("dt").length > 10);
|
||||
assert.equal(chrome.shortcutsOpen(), false);
|
||||
id("help").click();
|
||||
assert.equal(chrome.shortcutsOpen(), true);
|
||||
id("shortcuts-close").click();
|
||||
assert.equal(chrome.shortcutsOpen(), false);
|
||||
});
|
||||
|
||||
it("mounts the device panel once per office and feeds it separately", () => {
|
||||
const { chrome, id, recorded } = setup();
|
||||
const inside = baseInputs({
|
||||
inside: true,
|
||||
mode: "office-overview",
|
||||
available: ["office-overview", "office-walk"],
|
||||
officeDepth: "public",
|
||||
devices: [MIC],
|
||||
});
|
||||
chrome.apply(chromeState(inside));
|
||||
assert.equal(id("device-section").hidden, false);
|
||||
const panel = id("device-host").children[0];
|
||||
assert.ok(panel);
|
||||
assert.match(id("device-host").text(), /Desk mic/);
|
||||
assert.match(id("device-host").text(), /Simulated studio hardware/);
|
||||
|
||||
// A second frame with the same devices must not rebuild it — a mixing desk
|
||||
// that rebuilt its sliders every frame would throw away a drag in progress.
|
||||
chrome.apply(chromeState(inside));
|
||||
assert.equal(id("device-host").children[0], panel);
|
||||
|
||||
chrome.applyDeviceStates([
|
||||
{ id: MIC.id, kind: "mic", powered: true, muted: false, gainDb: 20, levelDb: -18, observedAt: 1, synthetic: true },
|
||||
]);
|
||||
assert.match(id("device-host").text(), /\+20 dB/);
|
||||
|
||||
id("device-host")
|
||||
.querySelector("[data-capability=mute]")
|
||||
?.querySelector("button")
|
||||
?.click();
|
||||
assert.deepEqual(recorded.commands, [{ deviceId: MIC.id, op: "mute", value: true }]);
|
||||
|
||||
// And it goes away with the building.
|
||||
chrome.apply(chromeState(baseInputs()));
|
||||
assert.equal(id("device-section").hidden, true);
|
||||
assert.equal(id("device-host").children.length, 0);
|
||||
});
|
||||
|
||||
it("mounts the coach on a first visit and takes it away afterwards", () => {
|
||||
const { chrome, id } = setup();
|
||||
chrome.apply(chromeState(baseInputs({ firstVisit: true })));
|
||||
assert.equal(id("onboarding-host").children.length, 1);
|
||||
assert.match(id("onboarding-host").text(), /Fly the board/);
|
||||
|
||||
chrome.apply(chromeState(baseInputs({ firstVisit: false })));
|
||||
assert.equal(id("onboarding-host").children.length, 0);
|
||||
});
|
||||
|
||||
it("describes the canvas for whatever is happening on it", () => {
|
||||
const { chrome, id } = setup();
|
||||
chrome.apply(chromeState(baseInputs({ mode: "drive" })));
|
||||
assert.match(id("scene").getAttribute("aria-label") ?? "", /following your car/);
|
||||
chrome.apply(chromeState(baseInputs()));
|
||||
assert.match(id("scene").getAttribute("aria-label") ?? "", /Map of California/);
|
||||
});
|
||||
|
||||
it("unbinds everything and cleans the body on dispose", () => {
|
||||
const { doc, chrome, id, recorded } = setup();
|
||||
chrome.apply(chromeState(baseInputs({ panelOpen: false, firstVisit: true })));
|
||||
assert.equal(doc.body.classList.contains("panel-closed"), true);
|
||||
chrome.dispose();
|
||||
assert.equal(doc.body.classList.contains("panel-closed"), false);
|
||||
assert.equal(id("onboarding-host").children.length, 0);
|
||||
id("enter").click();
|
||||
id("panel-toggle").click();
|
||||
assert.deepEqual(recorded.clicks, []);
|
||||
assert.deepEqual(recorded.panel, []);
|
||||
});
|
||||
|
||||
it("does nothing at all when a handler was never wired", () => {
|
||||
// Every handler is optional so `integration` can adopt this a call site at a
|
||||
// time. An unwired control must be inert, not a thrown error.
|
||||
const doc = buildPage();
|
||||
const chrome = mountChrome(doc as unknown as Document, { storage: null });
|
||||
assert.doesNotThrow(() => chrome.apply(chromeState(baseInputs())));
|
||||
assert.doesNotThrow(() => doc.querySelector("#enter")?.click());
|
||||
assert.doesNotThrow(() => doc.querySelector("#mode-dock")?.children[1]?.click());
|
||||
chrome.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { FakeDocument, type FakeElement, fakeStorage } from "./fakeDom.ts";
|
||||
import {
|
||||
ONBOARDING_STEPS,
|
||||
ONBOARDING_STORAGE_KEY,
|
||||
hasSeenOnboarding,
|
||||
markOnboardingSeen,
|
||||
mountOnboarding,
|
||||
} from "../../ui/onboarding.ts";
|
||||
import type { OnboardingStorage } from "../../ui/onboarding.ts";
|
||||
|
||||
function setup(storage: OnboardingStorage | null, coarsePointer = false, force = false) {
|
||||
const doc = new FakeDocument();
|
||||
const host = doc.createElement("div");
|
||||
doc.body.append(host);
|
||||
const finished: string[] = [];
|
||||
const coach = mountOnboarding(host as unknown as HTMLElement, {
|
||||
coarsePointer,
|
||||
storage,
|
||||
force,
|
||||
onFinish: (reason) => finished.push(reason),
|
||||
});
|
||||
return { doc, host, coach, finished, root: coach.root as unknown as FakeElement | null };
|
||||
}
|
||||
|
||||
describe("the first-run coach", () => {
|
||||
it("teaches the two verbs the product is, and the control surface that reaches them", () => {
|
||||
// The product is "fly the board" and "walk into the studio", and before this
|
||||
// file neither was ever stated on screen at any tier in any layout.
|
||||
const verbs = ONBOARDING_STEPS.map((step) => step.verb.toLowerCase());
|
||||
assert.equal(ONBOARDING_STEPS.length, 3);
|
||||
assert.ok(verbs.some((verb) => verb.includes("fly")));
|
||||
assert.ok(verbs.some((verb) => verb.includes("walk into")));
|
||||
assert.ok(verbs.some((verb) => verb.includes("controls")));
|
||||
});
|
||||
|
||||
it("gives every step a touch sentence that is a different act, not a translation", () => {
|
||||
// The desktop's key strip is `display: none` below 600px, so on a phone this
|
||||
// is the only place the controls are ever named.
|
||||
for (const step of ONBOARDING_STEPS) {
|
||||
assert.ok(step.touch.trim().length > 10, `${step.id} has no touch sentence`);
|
||||
assert.notEqual(step.touch, step.pointer, `${step.id} just restates its pointer sentence`);
|
||||
}
|
||||
const dock = ONBOARDING_STEPS.find((step) => step.id === "dock");
|
||||
// The joystick is the control a phone visitor otherwise had no way to find.
|
||||
assert.match(dock?.touch ?? "", /joystick/);
|
||||
});
|
||||
|
||||
it("appears when storage is empty", () => {
|
||||
const storage = fakeStorage();
|
||||
const { coach, root, host } = setup(storage);
|
||||
assert.equal(coach.visible, true);
|
||||
assert.ok(root);
|
||||
assert.equal(host.children.length, 1);
|
||||
assert.match(root.text(), /Fly the board/);
|
||||
});
|
||||
|
||||
it("does not appear on the second mount", () => {
|
||||
const storage = fakeStorage();
|
||||
const first = setup(storage);
|
||||
assert.equal(first.coach.visible, true);
|
||||
|
||||
const second = setup(storage);
|
||||
assert.equal(second.coach.visible, false);
|
||||
assert.equal(second.root, null);
|
||||
assert.equal(second.host.children.length, 0, "a returning visitor's page carries no trace");
|
||||
});
|
||||
|
||||
it("records the visit at mount, not at completion", () => {
|
||||
/*
|
||||
* Deliberate. Somebody who read the first card and went straight to dragging
|
||||
* the map has been onboarded; a coach that reappears next visit because they
|
||||
* did not press "Next" three times is arguing with its own success
|
||||
* condition.
|
||||
*/
|
||||
const storage = fakeStorage();
|
||||
setup(storage);
|
||||
assert.equal(storage.map.has(ONBOARDING_STORAGE_KEY), true);
|
||||
});
|
||||
|
||||
it("still renders when every localStorage accessor throws", () => {
|
||||
// Safari private mode, and any embed with third-party storage blocked. An
|
||||
// onboarding card is not worth a blank page.
|
||||
const storage = fakeStorage("throwing");
|
||||
assert.doesNotThrow(() => hasSeenOnboarding(storage));
|
||||
assert.equal(hasSeenOnboarding(storage), false);
|
||||
assert.doesNotThrow(() => markOnboardingSeen(storage));
|
||||
|
||||
const { coach, root } = setup(storage);
|
||||
assert.equal(coach.visible, true);
|
||||
assert.ok(root);
|
||||
// And it keeps working: a throwing store means it will be offered again next
|
||||
// visit, which is survivable in a way that never showing it is not.
|
||||
const again = setup(storage);
|
||||
assert.equal(again.coach.visible, true);
|
||||
});
|
||||
|
||||
it("treats a missing store the same way", () => {
|
||||
assert.equal(hasSeenOnboarding(null), false);
|
||||
assert.doesNotThrow(() => markOnboardingSeen(null));
|
||||
const { coach } = setup(null);
|
||||
assert.equal(coach.visible, true);
|
||||
});
|
||||
|
||||
it("steps forward, marks progress, and finishes on the last card", () => {
|
||||
const { coach, root, finished } = setup(fakeStorage());
|
||||
assert.ok(root);
|
||||
assert.equal(coach.index(), 0);
|
||||
|
||||
coach.next();
|
||||
assert.equal(coach.index(), 1);
|
||||
assert.match(root.text(), /Take the controls/);
|
||||
|
||||
coach.next();
|
||||
assert.equal(coach.index(), 2);
|
||||
assert.match(root.text(), /Walk into the studio/);
|
||||
assert.match(root.text(), /Start exploring/);
|
||||
|
||||
coach.next();
|
||||
assert.equal(coach.index(), -1);
|
||||
assert.equal(root.hidden, true);
|
||||
assert.deepEqual(finished, ["finished"]);
|
||||
|
||||
// Idempotent: a double-press of the last button must not fire twice.
|
||||
coach.next();
|
||||
assert.deepEqual(finished, ["finished"]);
|
||||
});
|
||||
|
||||
it("is skippable from any card, in one press", () => {
|
||||
const { coach, root, finished } = setup(fakeStorage());
|
||||
assert.ok(root);
|
||||
coach.next();
|
||||
const skip = root.querySelector(".tera-coach__skip");
|
||||
assert.ok(skip);
|
||||
skip.click();
|
||||
assert.equal(root.hidden, true);
|
||||
assert.equal(coach.index(), -1);
|
||||
assert.deepEqual(finished, ["skipped"]);
|
||||
});
|
||||
|
||||
it("rings the control each step is naming, and stops when it is done", () => {
|
||||
// A coach that greys the scene to point at the scene has explained nothing.
|
||||
// This is a card in a corner plus one attribute on <body>.
|
||||
const { doc, coach } = setup(fakeStorage());
|
||||
assert.equal(doc.body.getAttribute("data-coach"), null, "step one is about the whole scene");
|
||||
coach.next();
|
||||
assert.equal(doc.body.getAttribute("data-coach"), "mode-dock");
|
||||
coach.next();
|
||||
assert.equal(doc.body.getAttribute("data-coach"), "enter");
|
||||
coach.skip();
|
||||
assert.equal(doc.body.getAttribute("data-coach"), null);
|
||||
});
|
||||
|
||||
it("leads with the touch sentence on a coarse pointer", () => {
|
||||
const withMouse = setup(fakeStorage(), false);
|
||||
assert.ok(withMouse.root);
|
||||
assert.match(withMouse.root.text(), /scroll to zoom/);
|
||||
|
||||
const withThumb = setup(fakeStorage(), true);
|
||||
assert.ok(withThumb.root);
|
||||
assert.match(withThumb.root.text(), /pinch to zoom/);
|
||||
});
|
||||
|
||||
it("can be shown again on request, without a second copy of the stylesheet", () => {
|
||||
const storage = fakeStorage();
|
||||
const first = setup(storage);
|
||||
first.coach.dispose();
|
||||
const again = setup(storage, false, true);
|
||||
assert.equal(again.coach.visible, true);
|
||||
assert.equal(again.doc.head.querySelectorAll("style").length, 1);
|
||||
});
|
||||
|
||||
it("leaves nothing behind on dispose", () => {
|
||||
const { doc, host, coach } = setup(fakeStorage());
|
||||
coach.next();
|
||||
assert.equal(doc.body.getAttribute("data-coach"), "mode-dock");
|
||||
coach.dispose();
|
||||
assert.equal(host.children.length, 0);
|
||||
assert.equal(doc.body.getAttribute("data-coach"), null);
|
||||
assert.equal(doc.head.querySelectorAll("style").length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { FakeDocument, type FakeElement } from "./fakeDom.ts";
|
||||
import {
|
||||
GODMODE_HOTKEY,
|
||||
KEYMAP,
|
||||
SECTION_TITLES,
|
||||
SHORTCUT_SECTIONS,
|
||||
boundKeys,
|
||||
controlForKey,
|
||||
edgeForKey,
|
||||
railHints,
|
||||
renderShortcutSheet,
|
||||
shortcutRows,
|
||||
} from "../../ui/shortcuts.ts";
|
||||
import type { ShortcutSheetInputs } from "../../ui/shortcuts.ts";
|
||||
|
||||
function inputs(overrides: Partial<ShortcutSheetInputs> = {}): ShortcutSheetInputs {
|
||||
return {
|
||||
coarsePointer: false,
|
||||
inside: false,
|
||||
god: false,
|
||||
degraded: [],
|
||||
credits: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every `<dt>` id in the rendered sheet, which is `shortcut-<KEYMAP id>`. */
|
||||
function renderedIds(root: FakeElement): string[] {
|
||||
return root
|
||||
.querySelectorAll("dt")
|
||||
.map((term) => term.id)
|
||||
.filter((id) => id.startsWith("shortcut-"))
|
||||
.map((id) => id.slice("shortcut-".length));
|
||||
}
|
||||
|
||||
describe("the keymap", () => {
|
||||
it("binds no key to two different meanings", () => {
|
||||
/*
|
||||
* The defect this table exists to stop, and it was live: `G` was
|
||||
* `secondary` (the crow's glide) in the play handler AND godmode's drawer
|
||||
* hotkey, registered in the capture phase with a `preventDefault()`. So for
|
||||
* the one visitor with godmode, glide silently did nothing — and the `?`
|
||||
* card grew a runtime-inserted `G` row saying the opposite of what the
|
||||
* printed table said.
|
||||
*/
|
||||
const keys = boundKeys();
|
||||
const duplicates = keys.filter((key, index) => keys.indexOf(key) !== index);
|
||||
assert.deepEqual(duplicates, [], `these keys are bound twice: ${duplicates.join(", ")}`);
|
||||
});
|
||||
|
||||
it("keeps godmode off the play bindings", () => {
|
||||
assert.equal(controlForKey(GODMODE_HOTKEY), null);
|
||||
assert.equal(edgeForKey(GODMODE_HOTKEY), null);
|
||||
// And the crow keeps its glide.
|
||||
assert.equal(controlForKey("g"), "secondary");
|
||||
assert.equal(controlForKey("G"), "secondary");
|
||||
});
|
||||
|
||||
it("is the source of the held-control lookup, not a description of it", () => {
|
||||
const expected: Record<string, string> = {
|
||||
w: "forward", s: "backward", a: "left", d: "right",
|
||||
q: "descend", e: "ascend", i: "pitch-up", k: "pitch-down",
|
||||
" ": "primary", g: "secondary",
|
||||
};
|
||||
for (const [key, control] of Object.entries(expected)) {
|
||||
assert.equal(controlForKey(key), control, `${key} should feed ${control}`);
|
||||
}
|
||||
assert.equal(controlForKey("z"), null);
|
||||
assert.equal(controlForKey("Escape"), null);
|
||||
// Space is compared before lower-casing. Every other binding is a letter.
|
||||
assert.equal(controlForKey(" "), "primary");
|
||||
});
|
||||
|
||||
it("derives the one-shot requests from the table's own edge field", () => {
|
||||
assert.equal(edgeForKey("p"), "assist");
|
||||
assert.equal(edgeForKey("P"), "assist");
|
||||
assert.equal(edgeForKey("r"), "reset");
|
||||
assert.equal(edgeForKey("c"), "camera");
|
||||
assert.equal(edgeForKey("w"), null);
|
||||
});
|
||||
|
||||
it("says what Space actually does, in every mode", () => {
|
||||
const primary = KEYMAP.find((entry) => entry.id === "primary");
|
||||
assert.ok(primary);
|
||||
// It read "Handbrake while driving" for as long as the card existed, which
|
||||
// is true in one of the four modes it fires in.
|
||||
assert.equal(primary.meaning.toLowerCase().includes("handbrake"), true);
|
||||
assert.equal(primary.meaning.toLowerCase().includes("sprint"), true);
|
||||
assert.equal(primary.meaning.toLowerCase().includes("throttle"), true);
|
||||
assert.equal(primary.meaning.toLowerCase().includes("climb"), true);
|
||||
});
|
||||
|
||||
it("gives every entry a unique id and a real meaning", () => {
|
||||
const ids = KEYMAP.map((entry) => entry.id);
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
for (const entry of KEYMAP) {
|
||||
assert.ok(entry.meaning.trim().length > 8, `${entry.id} has no meaning worth printing`);
|
||||
assert.ok(SHORTCUT_SECTIONS.includes(entry.section), `${entry.id} has an unknown section`);
|
||||
// A row is either keys or a gesture. A row that is neither is invisible.
|
||||
assert.ok(
|
||||
entry.keys.length > 0 || (entry.gesture ?? "") !== "",
|
||||
`${entry.id} names neither a key nor a gesture`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("mentions the controls that are not keys at all", () => {
|
||||
// The old sheet listed only the half of the product that needs a keyboard,
|
||||
// which on a phone is none of it.
|
||||
const ids = KEYMAP.map((entry) => entry.id);
|
||||
for (const id of ["mode-dock", "panel-sheet", "character", "office-screens", "aircraft-detail"]) {
|
||||
assert.ok(ids.includes(id), `the sheet never mentions ${id}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ? sheet", () => {
|
||||
it("has no orphan in either direction", () => {
|
||||
const doc = new FakeDocument();
|
||||
const sheet = renderShortcutSheet(doc as unknown as Document, inputs({ god: true }));
|
||||
const drawn = renderedIds(sheet.root as unknown as FakeElement);
|
||||
const expected = shortcutRows(inputs({ god: true })).map((row) => row.id);
|
||||
|
||||
// Every KEYMAP entry appears...
|
||||
assert.deepEqual([...drawn].sort(), [...expected].sort());
|
||||
// ...exactly once...
|
||||
assert.equal(new Set(drawn).size, drawn.length);
|
||||
// ...and every drawn row names a real entry.
|
||||
const known = new Set(KEYMAP.map((entry) => entry.id));
|
||||
for (const id of drawn) assert.ok(known.has(id), `the sheet drew an orphan row: ${id}`);
|
||||
});
|
||||
|
||||
it("hides the godmode row from everyone who does not have godmode", () => {
|
||||
// Hard-coding it would advertise an instrument nine visitors in ten have no
|
||||
// way to open; omitting it from a god's card would leave a live key
|
||||
// undocumented. Both halves are the same rule.
|
||||
assert.equal(shortcutRows(inputs({ god: false })).some((row) => row.id === "godmode"), false);
|
||||
assert.equal(shortcutRows(inputs({ god: true })).some((row) => row.id === "godmode"), true);
|
||||
});
|
||||
|
||||
it("groups rows under their section, in section order", () => {
|
||||
const doc = new FakeDocument();
|
||||
const sheet = renderShortcutSheet(doc as unknown as Document, inputs({ god: true }));
|
||||
const root = sheet.root as unknown as FakeElement;
|
||||
const headings = root.querySelectorAll("h3").map((heading) => heading.textContent);
|
||||
const wanted = SHORTCUT_SECTIONS.map((section) => SECTION_TITLES[section]);
|
||||
assert.deepEqual(headings, wanted);
|
||||
});
|
||||
|
||||
it("leads with the gesture on a coarse pointer and with the keycap otherwise", () => {
|
||||
const doc = new FakeDocument();
|
||||
const fine = renderShortcutSheet(doc as unknown as Document, inputs());
|
||||
const fineMove = (fine.root as unknown as FakeElement).querySelector("#shortcut-move");
|
||||
assert.ok(fineMove);
|
||||
assert.equal(fineMove.classList.contains("keys-gesture"), false);
|
||||
assert.equal(fineMove.querySelectorAll("kbd").length, 4);
|
||||
|
||||
const coarse = renderShortcutSheet(doc as unknown as Document, inputs({ coarsePointer: true }));
|
||||
const coarseMove = (coarse.root as unknown as FakeElement).querySelector("#shortcut-move");
|
||||
assert.ok(coarseMove);
|
||||
assert.equal(coarseMove.classList.contains("keys-gesture"), true);
|
||||
assert.match(coarseMove.textContent, /joystick/);
|
||||
});
|
||||
|
||||
it("always states the fabricated-markers rule", () => {
|
||||
const doc = new FakeDocument();
|
||||
const sheet = renderShortcutSheet(doc as unknown as Document, inputs());
|
||||
assert.match((sheet.root as unknown as FakeElement).text(), /fabricated/);
|
||||
assert.match((sheet.root as unknown as FakeElement).text(), /simulated/);
|
||||
});
|
||||
|
||||
it("surfaces degraded[] to an admin and to nobody else", () => {
|
||||
// Built by the server, served in the health body, and read by nobody since
|
||||
// it was written. Every line in it answers a question — "why is the weather
|
||||
// always clear" — that otherwise needs the source to answer.
|
||||
const doc = new FakeDocument();
|
||||
const degraded = ["No weather source is configured, so the sky is synthetic."];
|
||||
|
||||
const admin = renderShortcutSheet(doc as unknown as Document, inputs({ god: true, degraded }));
|
||||
assert.match((admin.root as unknown as FakeElement).text(), /No weather source/);
|
||||
|
||||
const member = renderShortcutSheet(doc as unknown as Document, inputs({ degraded }));
|
||||
assert.equal((member.root as unknown as FakeElement).text().includes("No weather source"), false);
|
||||
});
|
||||
|
||||
it("prints the live sources' credit lines when there are any", () => {
|
||||
const doc = new FakeDocument();
|
||||
const withCredit = renderShortcutSheet(
|
||||
doc as unknown as Document,
|
||||
inputs({ credits: ["Data from MET Norway"] }),
|
||||
);
|
||||
assert.match((withCredit.root as unknown as FakeElement).text(), /MET Norway/);
|
||||
|
||||
const without = renderShortcutSheet(doc as unknown as Document, inputs());
|
||||
assert.equal(
|
||||
(without.root as unknown as FakeElement).querySelector(".sheet-credits"),
|
||||
null,
|
||||
"a credit for data nobody is looking at is noise",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-renders in place rather than growing a second copy", () => {
|
||||
const doc = new FakeDocument();
|
||||
const sheet = renderShortcutSheet(doc as unknown as Document, inputs());
|
||||
const root = sheet.root as unknown as FakeElement;
|
||||
const before = renderedIds(root).length;
|
||||
sheet.apply(inputs({ god: true }));
|
||||
assert.equal(renderedIds(root).length, before + 1);
|
||||
sheet.apply(inputs());
|
||||
assert.equal(renderedIds(root).length, before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rail hint", () => {
|
||||
it("returns nothing at all on a coarse pointer", () => {
|
||||
// A keycap on a device with no keys is furniture.
|
||||
for (const mode of ["overview", "drive", "actor", "aircraft", "office-walk"] as const) {
|
||||
assert.deepEqual(railHints(mode, true), []);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns at most two hints, and they are about the mode you are in", () => {
|
||||
const drive = railHints("drive", false);
|
||||
assert.ok(drive.length <= 2);
|
||||
assert.deepEqual(drive.map((entry) => entry.id), ["primary", "assist"]);
|
||||
|
||||
const overview = railHints("overview", false);
|
||||
assert.deepEqual(overview.map((entry) => entry.id), ["chapters", "office"]);
|
||||
|
||||
// The old card printed five permanent hints in the corner of a 3D scene,
|
||||
// four fifths of them irrelevant to whatever you were doing.
|
||||
for (const mode of ["overview", "drive", "actor", "aircraft", "office-walk", "office-overview"] as const) {
|
||||
assert.ok(railHints(mode, false).length <= 2, `${mode} returns too many hints`);
|
||||
}
|
||||
});
|
||||
|
||||
it("only ever returns entries that exist in the table", () => {
|
||||
const known = new Set(KEYMAP.map((entry) => entry.id));
|
||||
for (const mode of ["overview", "drive", "actor", "aircraft", "office-walk", "office-overview"] as const) {
|
||||
for (const entry of railHints(mode, false)) assert.ok(known.has(entry.id));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { DEVICE_PANEL_CSS } from "../../ui/devicePanel.ts";
|
||||
import { ONBOARDING_CSS } from "../../ui/onboarding.ts";
|
||||
import { TOUCH_TARGET_PX } from "../../ui/tokens.ts";
|
||||
|
||||
function read(relative: string): string {
|
||||
return readFileSync(new URL(`../../../${relative}`, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
const html = read("index.html");
|
||||
|
||||
/**
|
||||
* The stylesheet with its own prose removed.
|
||||
*
|
||||
* Every structural assertion below runs against this rather than the raw file,
|
||||
* for two reasons that both bit once while writing it: a CSS comment quoting a
|
||||
* selector reads as that selector to a line scanner, and a comment containing a
|
||||
* `}` truncates any "slice to the closing brace" rule-body extraction. The raw
|
||||
* text is still used for the z-index assertion, deliberately — that one is meant
|
||||
* to be as literal as the `grep` in the build spec.
|
||||
*/
|
||||
const css = html.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
/** Any `z-index: 4`-shaped declaration. A `var(--z-…)` reference is not one. */
|
||||
const RAW_Z_INDEX = /z-index:\s*\d/g;
|
||||
|
||||
describe("the stylesheet", () => {
|
||||
it("writes no raw z-index outside the :root token block", () => {
|
||||
const start = html.indexOf(":root {");
|
||||
const end = html.indexOf("\n }", start);
|
||||
const outside = html.slice(0, start) + html.slice(end);
|
||||
const found = outside.match(RAW_Z_INDEX) ?? [];
|
||||
assert.deepEqual(
|
||||
found,
|
||||
[],
|
||||
`index.html writes ${found.length} raw z-index literal(s) outside :root — use var(--z-…)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("writes no raw z-index in any of the four injected stylesheets", () => {
|
||||
// These four modules mount a `<style>` at runtime and each used to carry its
|
||||
// own stacking literal, invisible to every other file on the page. Two of
|
||||
// them collided with rules in index.html.
|
||||
for (const file of [
|
||||
"src/tools/godmode.ts",
|
||||
"src/profile/webcamPanel.ts",
|
||||
"src/profile/editor.ts",
|
||||
"src/media/officeScreenPanel.ts",
|
||||
]) {
|
||||
const found = read(file).match(RAW_Z_INDEX) ?? [];
|
||||
assert.deepEqual(found, [], `${file} writes a raw z-index literal`);
|
||||
}
|
||||
for (const [name, css] of [
|
||||
["devicePanel", DEVICE_PANEL_CSS],
|
||||
["onboarding", ONBOARDING_CSS],
|
||||
] as const) {
|
||||
assert.deepEqual(css.match(RAW_Z_INDEX) ?? [], [], `${name} writes a raw z-index literal`);
|
||||
}
|
||||
});
|
||||
|
||||
it("has no trace of the two removed control-bar ids left anywhere", () => {
|
||||
// ~100 lines of CSS and two JS bindings survived the removal of the elements
|
||||
// themselves. Some of the rules were not merely dead — see the next test.
|
||||
//
|
||||
// The ids are assembled rather than written out, because the release gate
|
||||
// greps the whole tree for them and a test file containing the literal
|
||||
// string would be the last remaining hit.
|
||||
for (const id of ["drive", "walk"].map((name) => `${name}-controls`)) {
|
||||
assert.equal(html.includes(id), false, `index.html still references #${id}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("guards every .touch-play-controls offset rule behind a coarse pointer", () => {
|
||||
/*
|
||||
* The live layout bug this closes.
|
||||
*
|
||||
* `body:has(.touch-play-controls:not([hidden])) .mode-dock { bottom: 10rem }`
|
||||
* had no pointer guard, and `.touch-play-controls` is only `display: none`
|
||||
* on a fine pointer — `:has()` still matches an element that is not
|
||||
* displayed. So starting a drive with a mouse hid the key-hint strip and
|
||||
* shoved the mode dock up 160px to make room for controls that are not
|
||||
* drawn on that device, at exactly the moment a new driver needed the hints.
|
||||
*
|
||||
* The rule below is structural rather than textual: find every line that
|
||||
* both selects `.touch-play-controls` in a `:has()` and sets an offset, and
|
||||
* require each one to be inside a `(pointer: coarse)` block or prefixed with
|
||||
* `body.touch-capable`.
|
||||
*/
|
||||
const lines = css.split("\n");
|
||||
let coarseDepth = 0;
|
||||
let braceDepthAtCoarse = 0;
|
||||
let braceDepth = 0;
|
||||
const unguarded: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (coarseDepth === 0 && line.includes("@media (pointer: coarse)")) {
|
||||
coarseDepth = 1;
|
||||
braceDepthAtCoarse = braceDepth;
|
||||
}
|
||||
if (line.includes(":has(.touch-play-controls") && !line.includes("display")) {
|
||||
const guarded = coarseDepth > 0 || line.includes("body.touch-capable");
|
||||
if (!guarded) unguarded.push(line.trim());
|
||||
}
|
||||
braceDepth += (line.match(/{/g) ?? []).length - (line.match(/}/g) ?? []).length;
|
||||
if (coarseDepth > 0 && braceDepth <= braceDepthAtCoarse) coarseDepth = 0;
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
unguarded,
|
||||
[],
|
||||
"an offset rule keyed off .touch-play-controls escaped its pointer guard",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps page zoom, and keeps the canvas reaching the notch", () => {
|
||||
const viewport = html.match(/<meta name="viewport" content="([^"]+)"/)?.[1] ?? "";
|
||||
assert.ok(viewport.includes("viewport-fit=cover"));
|
||||
// Deliberately preserved. Taking page zoom away from everyone to protect one
|
||||
// element is the accessibility mistake the rule exists to avoid, and the
|
||||
// canvas is already protected by `touch-action: none`.
|
||||
assert.equal(viewport.includes("user-scalable=no"), false);
|
||||
assert.equal(viewport.includes("maximum-scale"), false);
|
||||
});
|
||||
|
||||
it("pays for viewport-fit=cover: every fixed edge carries a safe-area inset", () => {
|
||||
// The rule the notch imposes. A `bottom: var(--s4)` on a phone puts a
|
||||
// control under the home indicator: visible, unpressable.
|
||||
for (const selector of [".rail", ".source", ".mode-dock", ".panel-toggle"]) {
|
||||
const block = css.slice(css.indexOf(`${selector} {`));
|
||||
const body = block.slice(0, block.indexOf("}"));
|
||||
assert.match(
|
||||
body,
|
||||
/env\(safe-area-inset-/,
|
||||
`${selector} is pinned to an edge without a safe-area inset`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("cannot draw an office tab's badge on top of its own name", () => {
|
||||
/*
|
||||
* The defect that was on every screenshot of every studio.
|
||||
*
|
||||
* The old rule was `grid-template-columns: minmax(0, 1fr) auto` with nothing
|
||||
* clipping the first track, and `minmax(0, 1fr)` lets a track shrink below
|
||||
* its content while a plain `<span>` paints outside it — so at the ~85px
|
||||
* each tab gets in a three-up strip, "SF HQ · Studio" was drawn straight
|
||||
* over the ACTIVE badge.
|
||||
*
|
||||
* Two properties make that impossible now and both are asserted: the tab is
|
||||
* a single-column grid, so the name and the badge are on different rows and
|
||||
* cannot occupy the same box at any width; and the name clips.
|
||||
*/
|
||||
const board = css.slice(css.indexOf(".board {"));
|
||||
const boardBody = board.slice(0, board.indexOf("}"));
|
||||
assert.match(boardBody, /display:\s*grid/);
|
||||
assert.equal(
|
||||
/grid-template-columns/.test(boardBody),
|
||||
false,
|
||||
"an office tab must be one column: two tracks is what let the badge and the name collide",
|
||||
);
|
||||
|
||||
const name = css.slice(css.indexOf(".board__name {"));
|
||||
const nameBody = name.slice(0, name.indexOf("}"));
|
||||
assert.match(nameBody, /overflow:\s*hidden/);
|
||||
assert.match(nameBody, /text-overflow:\s*ellipsis/);
|
||||
assert.match(nameBody, /min-width:\s*0/);
|
||||
});
|
||||
|
||||
it("carries every element the chrome applier writes to", () => {
|
||||
// `mount.ts` resolves these by id once and then never checks again. A
|
||||
// renamed id would silently stop applying one decision rather than throwing.
|
||||
const required = [
|
||||
"scene", "panel", "panel-toggle", "panel-toggle-label", "scrim",
|
||||
"title", "subtitle", "clock", "cities", "boards-title",
|
||||
"enter", "walk", "fly", "screens", "devices",
|
||||
"device-section", "device-host", "office-invite", "office-note",
|
||||
"chapters", "blurb",
|
||||
"topright", "tier", "tier-label", "tier-who", "tier-signin", "tier-character",
|
||||
"tier-adds", "presence-host", "webcam-face-indicator", "corner", "minimap",
|
||||
"minimap-readout",
|
||||
"mode-dock", "play-hud", "play-hud-mode", "play-hud-primary", "play-hud-status",
|
||||
"rail", "detail", "detail-text", "detail-close", "hint", "plan-toggle", "help",
|
||||
"touch-play-controls", "play-stick", "play-stick-knob", "play-stick-label",
|
||||
"touch-primary", "touch-secondary", "touch-pitch-up", "touch-pitch-down",
|
||||
"touch-assist", "touch-reset", "touch-camera", "touch-map",
|
||||
"source", "shortcuts", "shortcuts-body", "shortcuts-close",
|
||||
"onboarding-host", "boot", "boot-step",
|
||||
];
|
||||
for (const id of required) {
|
||||
assert.ok(html.includes(`id="${id}"`), `index.html is missing #${id}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the boot card inline, above everything, and first", () => {
|
||||
// It is in the document on purpose: it is on screen at first paint, before
|
||||
// the module graph has been fetched, let alone before the ~2.3 s heightfield
|
||||
// build. Moving it into a module would make the page blank for that whole
|
||||
// time.
|
||||
assert.ok(html.includes('class="boot" id="boot"'));
|
||||
assert.ok(html.indexOf('id="boot"') < html.indexOf('src="/src/main.ts"'));
|
||||
const boot = css.slice(css.indexOf(".boot {"));
|
||||
assert.match(boot.slice(0, boot.indexOf("}")), /z-index:\s*var\(--z-boot\)/);
|
||||
});
|
||||
|
||||
it("clears the touch target in both injected panels", () => {
|
||||
for (const [name, css] of [
|
||||
["devicePanel", DEVICE_PANEL_CSS],
|
||||
["onboarding", ONBOARDING_CSS],
|
||||
] as const) {
|
||||
const targets = [...css.matchAll(/min-height:\s*(?:var\(--tap,\s*)?(\d+)px/g)].map((m) =>
|
||||
Number(m[1]),
|
||||
);
|
||||
assert.ok(targets.length > 0, `${name} declares no touch target at all`);
|
||||
for (const size of targets) {
|
||||
assert.ok(size >= TOUCH_TARGET_PX, `${name} has a ${size}px target, below ${TOUCH_TARGET_PX}px`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("respects prefers-reduced-motion everywhere something animates", () => {
|
||||
assert.ok(html.includes("@media (prefers-reduced-motion: reduce)"));
|
||||
assert.ok(ONBOARDING_CSS.includes("@media (prefers-reduced-motion: reduce)"));
|
||||
assert.ok(DEVICE_PANEL_CSS.includes("@media (prefers-reduced-motion: reduce)"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
CSS_TOKENS,
|
||||
GLASS,
|
||||
INK,
|
||||
SPACE,
|
||||
TOUCH_TARGET_PX,
|
||||
TYPE,
|
||||
Z_LAYERS,
|
||||
Z_LAYER_NAMES,
|
||||
tokenCss,
|
||||
zIndex,
|
||||
zVarName,
|
||||
} from "../../ui/tokens.ts";
|
||||
|
||||
const html = readFileSync(new URL("../../../index.html", import.meta.url), "utf8");
|
||||
|
||||
/**
|
||||
* The `:root` block, isolated.
|
||||
*
|
||||
* Several assertions below are about what is *outside* it — a raw `z-index`
|
||||
* literal anywhere else in the file is the defect the token scale exists to
|
||||
* remove — so the boundary has to be found rather than assumed.
|
||||
*/
|
||||
function rootBlock(): string {
|
||||
const start = html.indexOf(":root {");
|
||||
assert.ok(start > 0, "index.html must declare a :root token block");
|
||||
const end = html.indexOf("\n }", start);
|
||||
assert.ok(end > start, ":root block must be closed");
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
describe("design tokens", () => {
|
||||
it("declares every token exactly once, in :root, with the value tokens.ts holds", () => {
|
||||
const block = rootBlock();
|
||||
for (const [name, value] of CSS_TOKENS) {
|
||||
const declaration = `${name}: ${value};`;
|
||||
assert.ok(
|
||||
block.includes(declaration),
|
||||
`index.html's :root is missing ${declaration} — regenerate it from tokenCss()`,
|
||||
);
|
||||
// Once. A token declared twice is a token whose value depends on which
|
||||
// half of the file you read.
|
||||
const occurrences = block.split(declaration).length - 1;
|
||||
assert.equal(occurrences, 1, `${name} is declared ${occurrences} times`);
|
||||
}
|
||||
});
|
||||
|
||||
it("has no duplicate token names", () => {
|
||||
const names = CSS_TOKENS.map(([name]) => name);
|
||||
assert.equal(new Set(names).size, names.length);
|
||||
});
|
||||
|
||||
it("orders the stacking layers strictly, lowest first", () => {
|
||||
const values = Z_LAYER_NAMES.map((layer) => Z_LAYERS[layer]);
|
||||
for (let i = 1; i < values.length; i += 1) {
|
||||
const previous = values[i - 1] ?? 0;
|
||||
const current = values[i] ?? 0;
|
||||
assert.ok(
|
||||
current > previous,
|
||||
`${Z_LAYER_NAMES[i]} (${current}) must sit above ${Z_LAYER_NAMES[i - 1]} (${previous})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the two orderings that used to be accidents", () => {
|
||||
// The webcam "camera active" indicator over a modal, deliberately.
|
||||
assert.ok(Z_LAYERS.alert > Z_LAYERS.modal);
|
||||
// Godmode's HUD over the play HUD, deliberately.
|
||||
assert.ok(Z_LAYERS.instrument > Z_LAYERS.play);
|
||||
assert.ok(Z_LAYERS.play > Z_LAYERS.dock);
|
||||
assert.ok(Z_LAYERS.dock > Z_LAYERS.chrome);
|
||||
// And nothing above the boot card, which owns the screen until a real frame
|
||||
// is on the glass.
|
||||
assert.equal(Math.max(...Object.values(Z_LAYERS)), Z_LAYERS.boot);
|
||||
});
|
||||
|
||||
it("emits a var() reference with a fallback, for the injected stylesheets", () => {
|
||||
assert.equal(zVarName("modal"), "--z-modal");
|
||||
assert.equal(zIndex("modal"), "var(--z-modal, 10)");
|
||||
// The fallback matters: a `z-index: var(--z-modal)` that resolves to nothing
|
||||
// is invalid-at-computed-value-time, which silently drops the stacking
|
||||
// context rather than visibly failing.
|
||||
for (const layer of Z_LAYER_NAMES) {
|
||||
assert.match(zIndex(layer), /^var\(--z-[a-z]+, \d+\)$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the systems it claims to be: one accent, four inks, five type sizes", () => {
|
||||
assert.equal(Object.keys(INK).length, 4);
|
||||
assert.equal(Object.keys(TYPE).length, 5);
|
||||
// The 4px rhythm, actually 4px.
|
||||
for (const [step, value] of Object.entries(SPACE)) {
|
||||
assert.equal(Number(value.replace("px", "")) % 4, 0, `--s${step} is off the rhythm`);
|
||||
}
|
||||
assert.equal(TOUCH_TARGET_PX, 44);
|
||||
assert.equal(TOUCH_TARGET_PX % 4, 0);
|
||||
});
|
||||
|
||||
it("uses one glass recipe, with the strong weight genuinely more opaque", () => {
|
||||
const alpha = (color: string): number => Number(color.split(",").at(-1)?.replace(")", ""));
|
||||
assert.ok(alpha(GLASS.fillStrong) > alpha(GLASS.fill));
|
||||
});
|
||||
|
||||
it("generates the :root body it asserts against", () => {
|
||||
const generated = tokenCss(" ");
|
||||
for (const [name, value] of CSS_TOKENS) {
|
||||
assert.ok(generated.includes(` ${name}: ${value};`));
|
||||
}
|
||||
assert.equal(generated.split("\n").length, CSS_TOKENS.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
# Test home for the `vehicle` workstream.
|
||||
#
|
||||
# Each build workstream owns its own subdirectory so eight builders can add
|
||||
# suites in parallel without ever colliding on a path. `npm test` picks these
|
||||
# up through the widened `src/test/**/*.test.ts` glob in package.json.
|
||||
@@ -0,0 +1,301 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
MODEL_X_METRICS,
|
||||
buildModelX,
|
||||
disposeModelX,
|
||||
modelXInstanceParts,
|
||||
} from "../../assets/vehicles/index.ts";
|
||||
import { CALIFORNIA_TRANSPORT } from "../../transport/california.ts";
|
||||
import {
|
||||
METRE_SCALE_VEHICLE_OPTIONS,
|
||||
apronKindFor,
|
||||
apronMetrics,
|
||||
metreScaleVehicleOptions,
|
||||
PARK_JITTER,
|
||||
parkPose,
|
||||
} from "../../transport/exteriorVehicle.ts";
|
||||
import {
|
||||
VehicleController,
|
||||
replayVehicleInputs,
|
||||
type TimedVehicleInputFrame,
|
||||
} from "../../transport/vehicleController.ts";
|
||||
import { VEHICLE_WHEEL_RADIUS_M } from "../../transport/vehicleSim.ts";
|
||||
|
||||
const TRANSPORT_DIR = fileURLToPath(new URL("../../transport/", import.meta.url));
|
||||
|
||||
describe("the vehicle against the world it stands in", () => {
|
||||
it("turns its wheels at the radius the renderer actually draws", () => {
|
||||
// The agreement this pins used to be a comment reading "0.36 m is a
|
||||
// representative Model X tyre radius", hard-coded in two simulations while
|
||||
// the asset's own wheel was 0.405. It cannot be a shared import: the asset
|
||||
// imports three.js and nothing under `src/transport/` may, so the far side
|
||||
// of that boundary is enforced here instead — the same way a pack's site
|
||||
// identity is enforced rather than typed.
|
||||
assert.equal(VEHICLE_WHEEL_RADIUS_M, MODEL_X_METRICS.wheelRadius);
|
||||
});
|
||||
|
||||
it("rolls one metre of road for one metre of tread by default", () => {
|
||||
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
...metreScaleVehicleOptions("la-sf-us-101"),
|
||||
mode: "manual",
|
||||
initialSpeedMps: 10,
|
||||
fixedStepSeconds: 0.1,
|
||||
});
|
||||
const before = controller.state().wheelRadians;
|
||||
controller.stepFixed({ throttle: 0 });
|
||||
const travelled = controller.state().distanceM;
|
||||
const turned = controller.state().wheelRadians - before;
|
||||
// At travelScale 1 the route distance IS the physical distance, so the
|
||||
// wheel angle is the arc that distance subtends on the tyre. An 11 % error
|
||||
// here is invisible at 94 m to the scene unit and is a car whose wheels
|
||||
// visibly spin on a forecourt at 1 m to the unit.
|
||||
assert.ok(Math.abs(turned - travelled / MODEL_X_METRICS.wheelRadius) < 1e-9);
|
||||
});
|
||||
|
||||
it("stands at metre scale on the apron and at glyph scale on the board", () => {
|
||||
const rig = buildModelX({ detail: "corridor" });
|
||||
rig.root.updateMatrixWorld(true);
|
||||
const box = new THREE.Box3().setFromObject(rig.root);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
// The exterior adds the rig at scale 1, so these are the metres a viewer
|
||||
// standing next to it measures. `roadTraffic.ts` multiplies the same asset
|
||||
// by 0.18 because one scene unit there is 94 m.
|
||||
assert.ok(Math.abs(size.x - MODEL_X_METRICS.width) < 0.02, `width ${size.x}`);
|
||||
assert.ok(Math.abs(size.z - MODEL_X_METRICS.length) < 0.05, `length ${size.z}`);
|
||||
assert.ok(Math.abs(size.y - MODEL_X_METRICS.height) < 0.02, `height ${size.y}`);
|
||||
disposeModelX(rig);
|
||||
});
|
||||
|
||||
it("keeps the freeway corridor's instanced batch count exactly where it was", () => {
|
||||
// `engine/roadTraffic.ts` builds one `InstancedMesh` per part of the
|
||||
// corridor prototype, and the city board is at 90 % of its triangle cap.
|
||||
// Eighteen is what it was before this workstream and it must be what it is
|
||||
// after: nothing here may cost the board a draw call.
|
||||
const prototype = buildModelX({ detail: "corridor" });
|
||||
assert.equal(modelXInstanceParts(prototype).length, 18);
|
||||
disposeModelX(prototype);
|
||||
});
|
||||
});
|
||||
|
||||
describe("metre-scale controller options", () => {
|
||||
it("survives the controller's own clamps unchanged", () => {
|
||||
// `resolveOptions` clamps every one of these, and a preset that silently
|
||||
// landed on a clamp bound would be a preset that does not mean what it says.
|
||||
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
...metreScaleVehicleOptions("la-sf-us-101"),
|
||||
mode: "manual",
|
||||
fixedStepSeconds: 0.1,
|
||||
});
|
||||
for (let i = 0; i < 600; i += 1) controller.stepFixed({ throttle: 1 });
|
||||
const state = controller.state();
|
||||
assert.ok(
|
||||
Math.abs(state.speedMps - METRE_SCALE_VEHICLE_OPTIONS.maximumSpeedMps) < 0.5,
|
||||
`flat out at ${state.speedMps} m/s against a ${METRE_SCALE_VEHICLE_OPTIONS.maximumSpeedMps} ceiling`,
|
||||
);
|
||||
});
|
||||
|
||||
it("puts the guardrail where a forecourt's edge is, not a motorway's", () => {
|
||||
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
...metreScaleVehicleOptions("la-sf-us-101"),
|
||||
mode: "manual",
|
||||
initialSpeedMps: 12,
|
||||
fixedStepSeconds: 0.1,
|
||||
});
|
||||
for (let i = 0; i < 400; i += 1) controller.stepFixed({ throttle: 1, steering: 1 });
|
||||
assert.ok(
|
||||
Math.abs(controller.state().lateralOffsetM) <=
|
||||
METRE_SCALE_VEHICLE_OPTIONS.guardrailOffsetM + 1e-9,
|
||||
);
|
||||
assert.equal(controller.state().guardrailContact, true);
|
||||
});
|
||||
|
||||
it("advances route distance 900 times faster at board scale than at metre scale", () => {
|
||||
function distanceAfter(travelScale: number): number {
|
||||
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
mode: "manual",
|
||||
initialSpeedMps: 20,
|
||||
fixedStepSeconds: 0.1,
|
||||
travelScale,
|
||||
});
|
||||
for (let i = 0; i < 50; i += 1) controller.stepFixed({ throttle: 0.5 });
|
||||
return controller.state().distanceM;
|
||||
}
|
||||
const metre = distanceAfter(METRE_SCALE_VEHICLE_OPTIONS.travelScale);
|
||||
const board = distanceAfter(900);
|
||||
assert.equal(METRE_SCALE_VEHICLE_OPTIONS.travelScale, 1);
|
||||
assert.ok(Math.abs(board / metre - 900) < 1e-6, `ratio ${board / metre}`);
|
||||
});
|
||||
|
||||
it("lets a caller override one dial without restating the rest", () => {
|
||||
const options = metreScaleVehicleOptions("la-sf-i-5", { maximumSpeedMps: 8 });
|
||||
assert.equal(options.routeId, "la-sf-i-5");
|
||||
assert.equal(options.maximumSpeedMps, 8);
|
||||
assert.equal(options.travelScale, 1);
|
||||
assert.equal(options.wheelRadiusM, MODEL_X_METRICS.wheelRadius);
|
||||
});
|
||||
|
||||
it("imports no renderer anywhere under src/transport/", () => {
|
||||
// The arena imports these modules under Node's type stripping and the
|
||||
// server could too. A stray `import * as THREE` would break both, and it is
|
||||
// the kind of import that arrives by autocomplete rather than by decision.
|
||||
for (const entry of readdirSync(TRANSPORT_DIR)) {
|
||||
if (!entry.endsWith(".ts")) continue;
|
||||
const source = readFileSync(join(TRANSPORT_DIR, entry), "utf8");
|
||||
assert.ok(
|
||||
!/from\s+["']three/.test(source),
|
||||
`${entry} imports three.js and nothing under transport/ may`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bay is sized from the car", () => {
|
||||
it("fits the vehicle with room to open a door", () => {
|
||||
const metrics = apronMetrics(MODEL_X_METRICS, "street");
|
||||
assert.ok(metrics.stallWidth > MODEL_X_METRICS.width);
|
||||
assert.ok(metrics.stallLength > MODEL_X_METRICS.length);
|
||||
// Enough clearance each side to actually get out, which is the number a
|
||||
// marked bay exists to guarantee.
|
||||
assert.ok((metrics.stallWidth - MODEL_X_METRICS.width) / 2 >= 0.4);
|
||||
// The pad is bigger than the bay, and the bay is inside it.
|
||||
assert.ok(metrics.padWidth > metrics.stallWidth);
|
||||
assert.ok(metrics.padDepth > metrics.stallLength);
|
||||
// The charge post stands clear of the bay rather than inside it.
|
||||
assert.ok(Math.abs(metrics.postOffsetX) > metrics.stallWidth / 2);
|
||||
assert.ok(Math.abs(metrics.postOffsetX) + metrics.postWidth / 2 < metrics.padWidth / 2);
|
||||
});
|
||||
|
||||
it("grows with the vehicle rather than being authored twice", () => {
|
||||
const small = apronMetrics({ length: 3.6, width: 1.6 }, "street");
|
||||
const large = apronMetrics({ length: 5.6, width: 2.4 }, "street");
|
||||
assert.ok(large.stallWidth > small.stallWidth);
|
||||
assert.ok(large.stallLength > small.stallLength);
|
||||
assert.ok(large.padWidth > small.padWidth);
|
||||
});
|
||||
|
||||
it("reads a tower as a deck and everything else as a street", () => {
|
||||
assert.equal(apronKindFor(188), "deck");
|
||||
assert.equal(apronKindFor(4), "street");
|
||||
assert.equal(apronKindFor(1.2), "street");
|
||||
assert.equal(apronKindFor(0), "street");
|
||||
assert.equal(apronKindFor(Number.NaN), "street");
|
||||
assert.ok(apronMetrics(MODEL_X_METRICS, "deck").kerbHeight <
|
||||
apronMetrics(MODEL_X_METRICS, "street").kerbHeight);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parking a car off the line", () => {
|
||||
it("stays inside the tolerance the anchor promises", () => {
|
||||
const arrival = { position: { x: 22.6, z: -3.4 }, rotation: -Math.PI / 2 };
|
||||
for (let i = 0; i < 5_000; i += 1) {
|
||||
const pose = parkPose(arrival, Math.random);
|
||||
const offset = Math.hypot(pose.x - arrival.position.x, pose.z - arrival.position.z);
|
||||
assert.ok(offset <= Math.hypot(PARK_JITTER.lateralM, PARK_JITTER.longitudinalM) + 1e-9);
|
||||
assert.ok(Math.abs(pose.yaw - arrival.rotation) <= PARK_JITTER.yawRad + 1e-9);
|
||||
}
|
||||
});
|
||||
|
||||
it("nudges the car along its own bay rather than across the plan", () => {
|
||||
// A bay at 90° whose jitter ran in plan axes would push the car sideways
|
||||
// out of the painted rectangle it is meant to be sitting slightly askew in.
|
||||
const arrival = { position: { x: 0, z: 0 }, rotation: Math.PI / 2 };
|
||||
// rand() = 1 gives full positive lateral, then full positive longitudinal.
|
||||
const pose = parkPose(arrival, () => 1);
|
||||
// Yaw +90° turns the vehicle's right (+X) onto −Z and its forward (−Z) onto
|
||||
// −X, so a bay-frame nudge shows up entirely in the rotated axes.
|
||||
assert.ok(Math.abs(pose.z + PARK_JITTER.lateralM) < 1e-9, `z ${pose.z}`);
|
||||
assert.ok(Math.abs(pose.x + PARK_JITTER.longitudinalM) < 1e-9, `x ${pose.x}`);
|
||||
});
|
||||
|
||||
it("is a function of the generator alone, so a seed reproduces a car park", () => {
|
||||
const arrival = { position: { x: 3, z: 9 }, rotation: 0.4 };
|
||||
const make = () => {
|
||||
let a = 1234 >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
};
|
||||
assert.deepEqual(parkPose(arrival, make()), parkPose(arrival, make()));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The corridor must behave identically after this workstream.
|
||||
*
|
||||
* These are the exact figures the pre-workstream controller produced for this
|
||||
* input recording, captured before a line of it changed. They are route
|
||||
* *position* — the distance, the projected coordinate and the progress — which
|
||||
* is what "the freeway is unchanged" means and what a viewer sees.
|
||||
*
|
||||
* `wheelRadians` is deliberately not in here and is deliberately different: the
|
||||
* rolling radius moved from 0.36 to the asset's own 0.405, which is the whole
|
||||
* point of the metre-scale fix above. Nothing about where the car is on the
|
||||
* road changed, and the wheel now turns at the rate the road it covers implies.
|
||||
*/
|
||||
const FREEWAY_GOLDEN = {
|
||||
"la-sf-us-101": {
|
||||
distanceM: 34721.28615877114,
|
||||
lat: 34.13149371451207,
|
||||
lng: -118.6087188177537,
|
||||
progress: 0.05681942405244269,
|
||||
speedMps: 12.413457136746057,
|
||||
lateralOffsetM: -1.2242248525368438,
|
||||
headingDeg: -79.78730586053224,
|
||||
},
|
||||
"la-sf-i-5": {
|
||||
distanceM: 34721.286158771254,
|
||||
lat: 34.30464099897842,
|
||||
lng: -118.46596865860532,
|
||||
progress: 0.05852269146886045,
|
||||
speedMps: 12.413457136746057,
|
||||
lateralOffsetM: -1.2242248525368438,
|
||||
headingDeg: -40.55395656958177,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const FREEWAY_RECORDING: readonly TimedVehicleInputFrame[] = [
|
||||
{ steps: 90, actions: { throttle: 1 } },
|
||||
{ steps: 45, actions: { throttle: 0.6, steering: 0.35 } },
|
||||
{ steps: 30, actions: { brake: 0.8 } },
|
||||
{ steps: 60, actions: { modeRequest: "assisted" } },
|
||||
{ steps: 75, actions: { throttle: 0.4, steering: -0.5 } },
|
||||
];
|
||||
|
||||
describe("freeway behaviour is unchanged", () => {
|
||||
for (const [routeId, expected] of Object.entries(FREEWAY_GOLDEN)) {
|
||||
it(`replays ${routeId} at travelScale 900 to the same route position`, () => {
|
||||
const { final } = replayVehicleInputs(
|
||||
CALIFORNIA_TRANSPORT,
|
||||
{ routeId, mode: "manual", travelScale: 900 },
|
||||
FREEWAY_RECORDING,
|
||||
);
|
||||
assert.equal(final.distanceM, expected.distanceM);
|
||||
assert.equal(final.lat, expected.lat);
|
||||
assert.equal(final.lng, expected.lng);
|
||||
assert.equal(final.progress, expected.progress);
|
||||
assert.equal(final.speedMps, expected.speedMps);
|
||||
assert.equal(final.lateralOffsetM, expected.lateralOffsetM);
|
||||
assert.equal(final.headingDeg, expected.headingDeg);
|
||||
});
|
||||
}
|
||||
|
||||
it("still defaults travelScale to 1, so nothing inherits the corridor's 900", () => {
|
||||
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
mode: "manual",
|
||||
initialSpeedMps: 20,
|
||||
fixedStepSeconds: 0.1,
|
||||
});
|
||||
controller.stepFixed({ throttle: 0 });
|
||||
assert.ok(controller.state().distanceM < 3, "an unscaled step is metres, not kilometres");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import { MaterialRegistry } from "../../assets/materials.ts";
|
||||
import { kit } from "../../assets/kit.ts";
|
||||
import "../../assets/office/index.ts";
|
||||
import { createOfficeExterior, type OfficeExterior } from "../../engine/officeExterior.ts";
|
||||
import {
|
||||
FRONTIER_VALLEY_SITE,
|
||||
LUMBRIDGE_HQ_SITE,
|
||||
MATEO_COURT_SITE,
|
||||
} from "../../offices/sites.ts";
|
||||
import type { ExteriorArrival, OfficeSite } from "../../interiors/types.ts";
|
||||
import { PARK_JITTER } from "../../transport/exteriorVehicle.ts";
|
||||
import {
|
||||
createSimulatedVehicleTelemetry,
|
||||
type VehicleTelemetryState,
|
||||
} from "../../transport/vehicleTelemetry.ts";
|
||||
|
||||
const SITES: readonly (readonly [string, OfficeSite])[] = [
|
||||
["lumbridge-hq", LUMBRIDGE_HQ_SITE],
|
||||
["frontier-valley", FRONTIER_VALLEY_SITE],
|
||||
["mateo-court", MATEO_COURT_SITE],
|
||||
];
|
||||
|
||||
/** A deterministic generator, so a failure is reproducible from the seed alone. */
|
||||
function seeded(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function arrivalOf(site: OfficeSite): ExteriorArrival {
|
||||
assert.ok(site.arrival, "every shipped site authors an arrival anchor");
|
||||
return site.arrival;
|
||||
}
|
||||
|
||||
function build(
|
||||
site: OfficeSite,
|
||||
overrides: { rand?: () => number; detail?: "corridor" | "follow" } = {},
|
||||
): { exterior: OfficeExterior; materials: MaterialRegistry } {
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
const exterior = createOfficeExterior({
|
||||
site,
|
||||
arrival: arrivalOf(site),
|
||||
assets: kit,
|
||||
materials,
|
||||
rand: overrides.rand ?? seeded(1981),
|
||||
detail: overrides.detail ?? "corridor",
|
||||
});
|
||||
exterior.object.updateMatrixWorld(true);
|
||||
return { exterior, materials };
|
||||
}
|
||||
|
||||
function vehicleRoots(root: THREE.Object3D): THREE.Object3D[] {
|
||||
const found: THREE.Object3D[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object.userData.vehicleModel === "model-x") found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function stateOf(overrides: Partial<VehicleTelemetryState> = {}): VehicleTelemetryState {
|
||||
const source = createSimulatedVehicleTelemetry({
|
||||
seed: 5, fixedStepSeconds: 0.1, ambientC: 24,
|
||||
});
|
||||
return { ...source.current(), ...overrides };
|
||||
}
|
||||
|
||||
/** Signed smallest difference between two angles, radians. */
|
||||
function angleDelta(a: number, b: number): number {
|
||||
return Math.abs(Math.atan2(Math.sin(a - b), Math.cos(a - b)));
|
||||
}
|
||||
|
||||
describe("office exterior placement", () => {
|
||||
for (const [id, site] of SITES) {
|
||||
it(`stands one Model X on ${id}'s authored arrival anchor`, () => {
|
||||
const { exterior } = build(site);
|
||||
const arrival = arrivalOf(site);
|
||||
|
||||
const cars = vehicleRoots(exterior.object);
|
||||
assert.equal(cars.length, 1, "exactly one vehicle on the apron");
|
||||
const car = cars[0];
|
||||
assert.ok(car);
|
||||
|
||||
const position = car.getWorldPosition(new THREE.Vector3());
|
||||
assert.ok(
|
||||
Math.hypot(position.x - arrival.position.x, position.z - arrival.position.z) <= 0.5,
|
||||
`${id}: car at ${position.x}, ${position.z} against anchor ` +
|
||||
`${arrival.position.x}, ${arrival.position.z}`,
|
||||
);
|
||||
|
||||
const quaternion = car.getWorldQuaternion(new THREE.Quaternion());
|
||||
const yaw = new THREE.Euler().setFromQuaternion(quaternion, "YXZ").y;
|
||||
assert.ok(
|
||||
angleDelta(yaw, arrival.rotation) <= (2 * Math.PI) / 180,
|
||||
`${id}: yaw ${yaw} against anchor ${arrival.rotation}`,
|
||||
);
|
||||
|
||||
exterior.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
it("holds the anchor for any generator, including an unseeded one", () => {
|
||||
// The parking jitter is what makes a car look parked rather than placed, and
|
||||
// it is the one thing between the anchor and the assertion above. Bounding
|
||||
// it in `exteriorVehicle.ts` rather than trusting a particular seed is what
|
||||
// lets a caller hand this `Math.random` without breaking the contract.
|
||||
assert.ok(Math.hypot(PARK_JITTER.lateralM, PARK_JITTER.longitudinalM) < 0.5);
|
||||
assert.ok(PARK_JITTER.yawRad < (2 * Math.PI) / 180);
|
||||
const arrival = arrivalOf(MATEO_COURT_SITE);
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
const { exterior } = build(MATEO_COURT_SITE, { rand: Math.random });
|
||||
const car = vehicleRoots(exterior.object)[0];
|
||||
assert.ok(car);
|
||||
const position = car.getWorldPosition(new THREE.Vector3());
|
||||
assert.ok(
|
||||
Math.hypot(position.x - arrival.position.x, position.z - arrival.position.z) <= 0.5,
|
||||
);
|
||||
exterior.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a tower a podium deck and a street-level site a kerb", () => {
|
||||
// 188 m up a Transbay tower there is no pavement outside the west wall, and
|
||||
// `offices/sites.ts` explicitly left the question of what that means to this
|
||||
// layer. See `apronKindFor`.
|
||||
const tower = build(LUMBRIDGE_HQ_SITE);
|
||||
const street = build(MATEO_COURT_SITE);
|
||||
const kerbHeight = (exterior: OfficeExterior): number => {
|
||||
const kerb = meshes(exterior.object).find((mesh) => mesh.name.includes("skirting"));
|
||||
assert.ok(kerb, "the apron has a kerb");
|
||||
kerb.geometry.computeBoundingBox();
|
||||
const box = kerb.geometry.boundingBox;
|
||||
assert.ok(box);
|
||||
return box.max.y - box.min.y;
|
||||
};
|
||||
assert.ok(kerbHeight(tower.exterior) < kerbHeight(street.exterior));
|
||||
tower.exterior.dispose();
|
||||
street.exterior.dispose();
|
||||
});
|
||||
|
||||
it("puts the car on top of the paving rather than through it", () => {
|
||||
const { exterior } = build(MATEO_COURT_SITE);
|
||||
const car = vehicleRoots(exterior.object)[0];
|
||||
assert.ok(car);
|
||||
const wheelContact = car.getWorldPosition(new THREE.Vector3()).y;
|
||||
const pad = meshes(exterior.object).find((mesh) => mesh.name.includes("paving"));
|
||||
assert.ok(pad);
|
||||
const box = new THREE.Box3().setFromObject(pad);
|
||||
// The asset's origin is on the tyre contact plane, so it sits exactly on the
|
||||
// pad's top face — a millimetre of tolerance for the merged geometry's own
|
||||
// line thickness and nothing more.
|
||||
assert.ok(Math.abs(wheelContact - box.max.y) < 0.006, `${wheelContact} vs ${box.max.y}`);
|
||||
exterior.dispose();
|
||||
});
|
||||
|
||||
it("constructs no light, because Atmosphere owns lighting (CONTRACT §4)", () => {
|
||||
for (const [, site] of SITES) {
|
||||
const { exterior } = build(site);
|
||||
exterior.object.traverse((object) => {
|
||||
assert.ok(
|
||||
!(object instanceof THREE.Light),
|
||||
`${object.name} is a light and this layer may not create one`,
|
||||
);
|
||||
});
|
||||
exterior.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("office exterior telemetry", () => {
|
||||
it("shows a cable only while the car is plugged in", () => {
|
||||
const { exterior } = build(MATEO_COURT_SITE);
|
||||
const cable = exterior.object.getObjectByName("office-exterior:cable");
|
||||
assert.ok(cable);
|
||||
exterior.apply(stateOf({ pluggedIn: false }));
|
||||
assert.equal(cable.visible, false);
|
||||
exterior.apply(stateOf({ pluggedIn: true }));
|
||||
assert.equal(cable.visible, true);
|
||||
exterior.dispose();
|
||||
});
|
||||
|
||||
it("changes the charge indicator between plugged, charging and full", () => {
|
||||
const { exterior } = build(MATEO_COURT_SITE);
|
||||
const lamp = exterior.object.getObjectByName("office-exterior:lamp-charge") as THREE.Mesh;
|
||||
assert.ok(lamp);
|
||||
|
||||
exterior.apply(stateOf({ pluggedIn: false, socPct: 50 }));
|
||||
const unplugged = lamp.material;
|
||||
exterior.apply(stateOf({ pluggedIn: true, socPct: 50 }));
|
||||
const charging = lamp.material;
|
||||
exterior.apply(stateOf({ pluggedIn: true, socPct: 100 }));
|
||||
const full = lamp.material;
|
||||
|
||||
assert.notEqual(unplugged, charging);
|
||||
assert.notEqual(charging, full);
|
||||
// The flap on the car reads the same state as the post does, so a viewer who
|
||||
// can only see one side of the car still knows.
|
||||
const port = exterior.object.getObjectByName("office-exterior:charge-port") as THREE.Mesh;
|
||||
assert.ok(port);
|
||||
assert.equal(port.material, full);
|
||||
exterior.dispose();
|
||||
});
|
||||
|
||||
it("fills the charge bar in proportion to the pack", () => {
|
||||
const { exterior } = build(MATEO_COURT_SITE);
|
||||
const bar = exterior.object.getObjectByName("office-exterior:charge-bar");
|
||||
assert.ok(bar);
|
||||
exterior.apply(stateOf({ socPct: 20 }));
|
||||
const low = bar.scale.y;
|
||||
exterior.apply(stateOf({ socPct: 90 }));
|
||||
assert.ok(bar.scale.y > low);
|
||||
exterior.apply(stateOf({ socPct: 0 }));
|
||||
assert.equal(bar.visible, false);
|
||||
exterior.dispose();
|
||||
});
|
||||
|
||||
it("lights the cabin when the car is unlocked and dims it when it is not", () => {
|
||||
const { exterior } = build(MATEO_COURT_SITE);
|
||||
const glow = exterior.object.getObjectByName("office-exterior:cabin-glow") as THREE.Mesh;
|
||||
assert.ok(glow);
|
||||
const material = glow.material as THREE.MeshStandardMaterial;
|
||||
|
||||
exterior.apply(stateOf({ locked: true, climateOn: false }));
|
||||
assert.equal(glow.visible, false);
|
||||
assert.equal(material.emissiveIntensity, 0);
|
||||
|
||||
exterior.apply(stateOf({ locked: true, climateOn: true }));
|
||||
const preconditioning = material.emissiveIntensity;
|
||||
assert.ok(preconditioning > 0);
|
||||
|
||||
exterior.apply(stateOf({ locked: false, climateOn: false }));
|
||||
assert.equal(glow.visible, true);
|
||||
assert.ok(material.emissiveIntensity > preconditioning);
|
||||
exterior.dispose();
|
||||
});
|
||||
|
||||
it("mints a bounded number of indicator materials over a whole charge cycle", () => {
|
||||
// The whole point of quantising an indicator's brightness. An indicator in
|
||||
// this repo changes state by changing *material* — `materials.tinted` reaches
|
||||
// both `color` and `emissive` — and that registry cache is keyed on the
|
||||
// colour and never evicts. A charge lamp whose brightness tracked the state
|
||||
// of charge continuously would mint one material per step and hold it
|
||||
// forever, so five minutes of charging would be five minutes of leak.
|
||||
const { exterior } = build(MATEO_COURT_SITE);
|
||||
const indicators = [
|
||||
"office-exterior:lamp-charge",
|
||||
"office-exterior:lamp-climate",
|
||||
"office-exterior:lamp-lock",
|
||||
"office-exterior:charge-port",
|
||||
].map((name) => exterior.object.getObjectByName(name) as THREE.Mesh);
|
||||
for (const mesh of indicators) assert.ok(mesh);
|
||||
|
||||
const seen = new Set<THREE.Material | THREE.Material[]>();
|
||||
const source = createSimulatedVehicleTelemetry({
|
||||
seed: 3, fixedStepSeconds: 0.1, ambientC: 38, initialSocPct: 5,
|
||||
});
|
||||
source.command({ op: "charge", value: true });
|
||||
source.command({ op: "climate", value: true });
|
||||
for (let i = 0; i < 3_000; i += 1) {
|
||||
source.stepFixed();
|
||||
exterior.apply(source.current());
|
||||
for (const mesh of indicators) seen.add(mesh.material);
|
||||
}
|
||||
assert.ok(
|
||||
seen.size <= 16,
|
||||
`${seen.size} distinct indicator materials over 3,000 applies`,
|
||||
);
|
||||
// And the run genuinely moved through states rather than sitting still,
|
||||
// which is what makes the bound above worth anything.
|
||||
assert.ok(seen.size >= 3, `${seen.size} materials means nothing changed`);
|
||||
exterior.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("office exterior resources", () => {
|
||||
it("frees every geometry and every material it made, and nothing it borrowed", () => {
|
||||
// Prototype patches rather than post-hoc inspection: three.js has no
|
||||
// "was this disposed" flag, so the only honest way to assert it is to
|
||||
// record the calls.
|
||||
const disposedGeometries = new Set<THREE.BufferGeometry>();
|
||||
const disposedMaterials = new Set<THREE.Material>();
|
||||
const geometryDispose = THREE.BufferGeometry.prototype.dispose;
|
||||
const materialDispose = THREE.Material.prototype.dispose;
|
||||
THREE.BufferGeometry.prototype.dispose = function patched(this: THREE.BufferGeometry) {
|
||||
disposedGeometries.add(this);
|
||||
return geometryDispose.call(this);
|
||||
};
|
||||
THREE.Material.prototype.dispose = function patched(this: THREE.Material) {
|
||||
disposedMaterials.add(this);
|
||||
return materialDispose.call(this);
|
||||
};
|
||||
|
||||
try {
|
||||
const { exterior, materials } = build(MATEO_COURT_SITE, { detail: "follow" });
|
||||
const subtree = meshes(exterior.object);
|
||||
assert.ok(subtree.length > 10);
|
||||
const geometries = new Set(subtree.map((mesh) => mesh.geometry));
|
||||
const subtreeMaterials = new Set(
|
||||
subtree
|
||||
.map((mesh) => mesh.material)
|
||||
.filter((material): material is THREE.Material => material instanceof THREE.Material),
|
||||
);
|
||||
// The registry materials this layer borrowed. They must survive: the
|
||||
// office's own floor is drawn with `polishedConcrete` too, and freeing it
|
||||
// out here would blank the building the apron stands outside of.
|
||||
const borrowed = [
|
||||
materials.get("polishedConcrete"),
|
||||
materials.get("skirting"),
|
||||
materials.get("deviceShell"),
|
||||
materials.get("metalTrim"),
|
||||
];
|
||||
|
||||
exterior.dispose();
|
||||
|
||||
for (const geometry of geometries) {
|
||||
assert.ok(disposedGeometries.has(geometry), `undisposed geometry: ${geometry.name}`);
|
||||
}
|
||||
// Everything this layer minted carries a name that says who made it: the
|
||||
// vehicle asset's own materials and the one cabin-glow material.
|
||||
const owned = [...subtreeMaterials].filter(
|
||||
(material) =>
|
||||
material.name.startsWith("model-x.") || material.name.startsWith("office-exterior."),
|
||||
);
|
||||
assert.ok(owned.length >= 8, `expected the vehicle's own skin, saw ${owned.length}`);
|
||||
for (const material of owned) {
|
||||
assert.ok(disposedMaterials.has(material), `undisposed material: ${material.name}`);
|
||||
}
|
||||
for (const material of borrowed) {
|
||||
assert.ok(!disposedMaterials.has(material), `freed a borrowed ${material.name}`);
|
||||
}
|
||||
assert.equal(exterior.object.children.length, 0);
|
||||
} finally {
|
||||
THREE.BufferGeometry.prototype.dispose = geometryDispose;
|
||||
THREE.Material.prototype.dispose = materialDispose;
|
||||
}
|
||||
});
|
||||
|
||||
it("costs the office budget what this workstream reported it would", () => {
|
||||
// The city board is at 90 % of its triangle cap and the office at 8 %, so
|
||||
// this number is the one integration has to plan against when it mounts the
|
||||
// exterior. The caps are deliberately just above the measured figures: a
|
||||
// change that doubles the apron should have to come and edit this line.
|
||||
for (const [detail, maxDraws, maxTriangles] of [
|
||||
["corridor", 34, 9_000],
|
||||
["follow", 42, 19_000],
|
||||
] as const) {
|
||||
const { exterior } = build(MATEO_COURT_SITE, { detail });
|
||||
const subtree = meshes(exterior.object);
|
||||
const triangles = subtree.reduce((total, mesh) => {
|
||||
const geometry = mesh.geometry;
|
||||
const index = geometry.getIndex();
|
||||
const position = geometry.getAttribute("position");
|
||||
return total + (index ? index.count : (position?.count ?? 0)) / 3;
|
||||
}, 0);
|
||||
assert.ok(
|
||||
subtree.length <= maxDraws,
|
||||
`${detail}: ${subtree.length} draw calls against ${maxDraws}`,
|
||||
);
|
||||
assert.ok(
|
||||
triangles <= maxTriangles,
|
||||
`${detail}: ${triangles} triangles against ${maxTriangles}`,
|
||||
);
|
||||
exterior.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
chargeTaper,
|
||||
createNullVehicleTelemetry,
|
||||
createSimulatedVehicleTelemetry,
|
||||
normalizeVehicleTelemetryCommand,
|
||||
rangeFor,
|
||||
type VehicleTelemetryState,
|
||||
} from "../../transport/vehicleTelemetry.ts";
|
||||
|
||||
const STEP = 0.1;
|
||||
|
||||
function run(
|
||||
source: { current(): VehicleTelemetryState; stepFixed(): void },
|
||||
steps: number,
|
||||
): VehicleTelemetryState[] {
|
||||
const trace: VehicleTelemetryState[] = [];
|
||||
for (let i = 0; i < steps; i += 1) {
|
||||
source.stepFixed();
|
||||
trace.push(source.current());
|
||||
}
|
||||
return trace;
|
||||
}
|
||||
|
||||
function plugged(seed: number, ambientC = 24) {
|
||||
const source = createSimulatedVehicleTelemetry({
|
||||
seed,
|
||||
fixedStepSeconds: STEP,
|
||||
ambientC,
|
||||
initialSocPct: 22,
|
||||
});
|
||||
source.command({ op: "charge", value: true });
|
||||
return source;
|
||||
}
|
||||
|
||||
describe("simulated vehicle telemetry", () => {
|
||||
it("is bit-identical for the same seed over a long episode", () => {
|
||||
const a = createSimulatedVehicleTelemetry({ seed: 7, fixedStepSeconds: STEP, ambientC: 31 });
|
||||
const b = createSimulatedVehicleTelemetry({ seed: 7, fixedStepSeconds: STEP, ambientC: 31 });
|
||||
// Climate on for both, so the compressor ripple — the only randomness that
|
||||
// reaches the physics — is actually exercised rather than skipped.
|
||||
a.command({ op: "climate", value: true });
|
||||
b.command({ op: "climate", value: true });
|
||||
assert.deepEqual(run(a, 600), run(b, 600));
|
||||
});
|
||||
|
||||
it("gives two seeds two different cars", () => {
|
||||
const a = createSimulatedVehicleTelemetry({ seed: 7, fixedStepSeconds: STEP, ambientC: 20 });
|
||||
const b = createSimulatedVehicleTelemetry({ seed: 8, fixedStepSeconds: STEP, ambientC: 20 });
|
||||
assert.notEqual(a.current().socPct, b.current().socPct);
|
||||
assert.notEqual(a.current().odometerKm, b.current().odometerKm);
|
||||
});
|
||||
|
||||
it("resumes a snapshot into an identical remaining sequence", () => {
|
||||
const live = createSimulatedVehicleTelemetry({ seed: 42, fixedStepSeconds: STEP, ambientC: 34 });
|
||||
live.command({ op: "climate", value: true });
|
||||
live.command({ op: "charge", value: true });
|
||||
run(live, 150);
|
||||
const checkpoint = live.snapshot();
|
||||
const remainder = run(live, 250);
|
||||
|
||||
const resumed = createSimulatedVehicleTelemetry({
|
||||
// Deliberately a different seed and a different ambient: everything that
|
||||
// matters has to arrive through the snapshot, not through the options.
|
||||
seed: 999,
|
||||
fixedStepSeconds: STEP,
|
||||
ambientC: -5,
|
||||
});
|
||||
resumed.restore(checkpoint);
|
||||
assert.deepEqual(run(resumed, 250), remainder);
|
||||
});
|
||||
|
||||
it("survives a JSON round trip, because that is how a snapshot travels", () => {
|
||||
const live = createSimulatedVehicleTelemetry({ seed: 3, fixedStepSeconds: STEP, ambientC: 12 });
|
||||
run(live, 40);
|
||||
const checkpoint = JSON.parse(JSON.stringify(live.snapshot()));
|
||||
const expected = run(live, 60);
|
||||
const resumed = createSimulatedVehicleTelemetry({ seed: 3, fixedStepSeconds: STEP, ambientC: 12 });
|
||||
resumed.restore(checkpoint);
|
||||
assert.deepEqual(run(resumed, 60), expected);
|
||||
});
|
||||
|
||||
it("ignores a snapshot it cannot read rather than half-applying it", () => {
|
||||
const source = createSimulatedVehicleTelemetry({ seed: 5, fixedStepSeconds: STEP, ambientC: 18 });
|
||||
run(source, 20);
|
||||
const before = source.current();
|
||||
for (const rubbish of [null, undefined, 7, "nope", {}, { v: 2 }, { v: 1, socPct: "hot" }]) {
|
||||
source.restore(rubbish);
|
||||
assert.deepEqual(source.current(), before);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads no wall clock: observedAt is the elapsed simulated time", () => {
|
||||
const source = createSimulatedVehicleTelemetry({
|
||||
seed: 11,
|
||||
fixedStepSeconds: STEP,
|
||||
ambientC: 21,
|
||||
epochMs: 1_700_000_000_000,
|
||||
});
|
||||
assert.equal(source.current().observedAt, 1_700_000_000_000);
|
||||
run(source, 36_000);
|
||||
// Exactly an hour, not 3,599.9997 seconds: the step count is an integer and
|
||||
// the elapsed time is derived from it rather than accumulated.
|
||||
assert.equal(source.current().observedAt, 1_700_000_000_000 + 3_600_000);
|
||||
});
|
||||
|
||||
it("never claims to have observed anything", () => {
|
||||
const source = createSimulatedVehicleTelemetry({ seed: 1, fixedStepSeconds: STEP, ambientC: 21 });
|
||||
assert.equal(source.current().synthetic, true);
|
||||
// No invented GPS fix. See the note on `VehicleLocation`.
|
||||
assert.equal(source.current().location, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("charging physics", () => {
|
||||
it("rises monotonically while plugged in with no climate load", () => {
|
||||
const source = plugged(19);
|
||||
let previous = source.current().socPct;
|
||||
for (let i = 0; i < 900; i += 1) {
|
||||
source.stepFixed();
|
||||
const next = source.current().socPct;
|
||||
assert.ok(next > previous, `step ${i}: ${next} should exceed ${previous}`);
|
||||
previous = next;
|
||||
}
|
||||
});
|
||||
|
||||
it("tapers: the rise above 80 % is strictly slower than below 60 %", () => {
|
||||
function riseFrom(socPct: number): number {
|
||||
const source = createSimulatedVehicleTelemetry({
|
||||
seed: 4, fixedStepSeconds: STEP, ambientC: 21, initialSocPct: socPct,
|
||||
});
|
||||
source.command({ op: "charge", value: true });
|
||||
const start = source.current().socPct;
|
||||
run(source, 200);
|
||||
return source.current().socPct - start;
|
||||
}
|
||||
const low = riseFrom(40);
|
||||
const high = riseFrom(85);
|
||||
assert.ok(low > 0 && high > 0);
|
||||
assert.ok(high < low, `rise at 85 % (${high}) must be slower than at 40 % (${low})`);
|
||||
// And the curve underneath it is monotone, which is the property the
|
||||
// environment actually depends on when it plans a departure.
|
||||
assert.equal(chargeTaper(20), 1);
|
||||
assert.ok(chargeTaper(60) > chargeTaper(80));
|
||||
assert.ok(chargeTaper(80) > chargeTaper(95));
|
||||
assert.ok(chargeTaper(100) > 0);
|
||||
});
|
||||
|
||||
it("charges a 20 K climate delta out of the net gain", () => {
|
||||
function gain(climateOn: boolean): number {
|
||||
// 41 °C outside against a 21 °C setpoint: a genuinely hot LA afternoon.
|
||||
const source = createSimulatedVehicleTelemetry({
|
||||
seed: 6, fixedStepSeconds: STEP, ambientC: 41, initialSocPct: 30,
|
||||
});
|
||||
source.command({ op: "charge", value: true });
|
||||
if (climateOn) source.command({ op: "climate", value: true });
|
||||
const start = source.current().socPct;
|
||||
run(source, 600);
|
||||
return source.current().socPct - start;
|
||||
}
|
||||
const idle = gain(false);
|
||||
const conditioning = gain(true);
|
||||
assert.ok(conditioning < idle);
|
||||
// "Measurably" rather than "at all": a difference smaller than a tenth of a
|
||||
// percent over a minute would pass a strict inequality and be invisible.
|
||||
assert.ok(idle - conditioning > 0.05, `climate should cost more than ${idle - conditioning}`);
|
||||
});
|
||||
|
||||
it("holds at a full pack instead of oscillating around the cap", () => {
|
||||
// The regression this pins: charging stops at 100 % but the car keeps
|
||||
// drawing its parasitic load, so without the post carrying that load the
|
||||
// charge falls below the cap and charging restarts — forever, at every
|
||||
// step, flickering the exterior's charge lamp for as long as the tab is
|
||||
// open. Climate is on as well, because a plugged-in car preconditioning at
|
||||
// 100 % is the case where the load is largest.
|
||||
const source = createSimulatedVehicleTelemetry({
|
||||
seed: 2, fixedStepSeconds: 5, ambientC: 38, initialSocPct: 99,
|
||||
});
|
||||
source.command({ op: "charge", value: true });
|
||||
source.command({ op: "climate", value: true });
|
||||
const trace = run(source, 4_000);
|
||||
assert.equal(source.current().socPct, 100);
|
||||
const settled = trace.slice(200).map((state) => state.socPct);
|
||||
assert.deepEqual([...new Set(settled)], [100]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("climate and range physics", () => {
|
||||
it("relaxes the cabin toward ambient when nothing is running", () => {
|
||||
const hot = createSimulatedVehicleTelemetry({ seed: 13, fixedStepSeconds: 1, ambientC: 40 });
|
||||
const startGap = Math.abs(hot.current().cabinC - 40);
|
||||
run(hot, 1_800);
|
||||
const endGap = Math.abs(hot.current().cabinC - 40);
|
||||
assert.ok(endGap < startGap * 0.2, `cabin should approach ambient: ${startGap} -> ${endGap}`);
|
||||
});
|
||||
|
||||
it("drives the cabin toward the setpoint against the outside", () => {
|
||||
const source = createSimulatedVehicleTelemetry({
|
||||
seed: 21, fixedStepSeconds: 1, ambientC: 41,
|
||||
});
|
||||
run(source, 900);
|
||||
const drifted = source.current().cabinC;
|
||||
source.command({ op: "climate", value: true });
|
||||
source.command({ op: "climateSetpointC", value: 19 });
|
||||
run(source, 900);
|
||||
assert.ok(source.current().cabinC < drifted, "preconditioning must cool a hot cabin");
|
||||
// And it never fully wins, because the cabin keeps leaking to a 41 °C
|
||||
// outside — which is exactly why it keeps costing kilowatts.
|
||||
assert.ok(source.current().cabinC > 19);
|
||||
});
|
||||
|
||||
it("costs range at both ends of the thermometer", () => {
|
||||
const mild = rangeFor(80, 21);
|
||||
assert.ok(rangeFor(80, 41) < mild);
|
||||
assert.ok(rangeFor(80, -6) < mild);
|
||||
assert.equal(rangeFor(0, 21), 0);
|
||||
// Range is reported through `current()`, not only by the helper.
|
||||
const source = createSimulatedVehicleTelemetry({
|
||||
seed: 8, fixedStepSeconds: STEP, ambientC: 41, initialSocPct: 80,
|
||||
});
|
||||
assert.equal(source.current().rangeKm, rangeFor(80, 41));
|
||||
source.setAmbientC(21);
|
||||
assert.ok(source.current().rangeKm > rangeFor(80, 41));
|
||||
});
|
||||
|
||||
it("moves the odometer only when somebody reports a distance", () => {
|
||||
const source = createSimulatedVehicleTelemetry({
|
||||
seed: 9, fixedStepSeconds: STEP, ambientC: 21, initialOdometerKm: 12_000,
|
||||
});
|
||||
run(source, 500);
|
||||
assert.equal(source.current().odometerKm, 12_000);
|
||||
const beforeSoc = source.current().socPct;
|
||||
source.command({ op: "trip", value: 4_000 });
|
||||
assert.equal(source.current().odometerKm, 12_004);
|
||||
assert.ok(source.current().socPct < beforeSoc, "a trip has to cost charge");
|
||||
});
|
||||
|
||||
it("charges a hot trip more than a mild one", () => {
|
||||
function cost(ambientC: number): number {
|
||||
const source = createSimulatedVehicleTelemetry({
|
||||
seed: 9, fixedStepSeconds: STEP, ambientC, initialSocPct: 70,
|
||||
});
|
||||
const before = source.current().socPct;
|
||||
source.command({ op: "trip", value: 20_000 });
|
||||
return before - source.current().socPct;
|
||||
}
|
||||
assert.ok(cost(41) > cost(21));
|
||||
});
|
||||
});
|
||||
|
||||
describe("command normalisation", () => {
|
||||
it("refuses everything it cannot make sense of", () => {
|
||||
assert.equal(normalizeVehicleTelemetryCommand(null), null);
|
||||
assert.equal(normalizeVehicleTelemetryCommand(undefined), null);
|
||||
// Wrong value type for the op.
|
||||
assert.equal(normalizeVehicleTelemetryCommand({ op: "lock", value: 1 }), null);
|
||||
assert.equal(normalizeVehicleTelemetryCommand({ op: "climateSetpointC", value: true }), null);
|
||||
// An op nobody declared.
|
||||
assert.equal(
|
||||
normalizeVehicleTelemetryCommand({ op: "launch" as never, value: true }),
|
||||
null,
|
||||
);
|
||||
// An odometer does not run backwards.
|
||||
assert.equal(normalizeVehicleTelemetryCommand({ op: "trip", value: -10 }), null);
|
||||
assert.equal(normalizeVehicleTelemetryCommand({ op: "trip", value: Number.NaN }), null);
|
||||
});
|
||||
|
||||
it("clamps a setpoint into the range the car will accept", () => {
|
||||
assert.deepEqual(
|
||||
normalizeVehicleTelemetryCommand({ op: "climateSetpointC", value: 90 }),
|
||||
{ op: "climateSetpointC", value: 28 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeVehicleTelemetryCommand({ op: "climateSetpointC", value: -40 }),
|
||||
{ op: "climateSetpointC", value: 15 },
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a fresh object so a caller cannot alias the command it sent", () => {
|
||||
const sent = { op: "lock", value: false } as const;
|
||||
const normalized = normalizeVehicleTelemetryCommand(sent);
|
||||
assert.notEqual(normalized, sent);
|
||||
assert.deepEqual(normalized, { op: "lock", value: false });
|
||||
});
|
||||
|
||||
it("leaves the simulation untouched for a refused command", () => {
|
||||
const source = createSimulatedVehicleTelemetry({ seed: 17, fixedStepSeconds: STEP, ambientC: 21 });
|
||||
const before = source.current();
|
||||
source.command({ op: "climate", value: 1 as never });
|
||||
source.command({ op: "nonsense" as never, value: true });
|
||||
assert.deepEqual(source.current(), before);
|
||||
});
|
||||
|
||||
it("actually applies the commands it accepts", () => {
|
||||
const source = createSimulatedVehicleTelemetry({ seed: 17, fixedStepSeconds: STEP, ambientC: 21 });
|
||||
source.command({ op: "lock", value: false });
|
||||
source.command({ op: "charge", value: true });
|
||||
source.command({ op: "climate", value: true });
|
||||
const state = source.current();
|
||||
assert.equal(state.locked, false);
|
||||
assert.equal(state.pluggedIn, true);
|
||||
assert.equal(state.climateOn, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the null source", () => {
|
||||
it("renders as an instrument at rest rather than as an absence", () => {
|
||||
const source = createNullVehicleTelemetry();
|
||||
const state = source.current();
|
||||
assert.equal(state.synthetic, true);
|
||||
assert.equal(state.locked, true);
|
||||
assert.equal(state.pluggedIn, false);
|
||||
assert.equal(state.climateOn, false);
|
||||
assert.equal(state.socPct, 0);
|
||||
assert.equal(state.rangeKm, 0);
|
||||
});
|
||||
|
||||
it("absorbs every call without moving", () => {
|
||||
const source = createNullVehicleTelemetry();
|
||||
const before = source.current();
|
||||
source.command({ op: "charge", value: true });
|
||||
source.stepFixed();
|
||||
source.restore(source.snapshot());
|
||||
assert.deepEqual(source.current(), before);
|
||||
});
|
||||
|
||||
it("hands out a fresh state each call, like the simulator does", () => {
|
||||
const source = createNullVehicleTelemetry();
|
||||
assert.notEqual(source.current(), source.current());
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { mergeVehicleActions, sampleStandardGamepad } from "../input/vehicle.ts";
|
||||
|
||||
function pad(over: { axes?: number[]; buttons?: Record<number, number> } = {}) {
|
||||
const buttons = Array.from({ length: 8 }, (_, index) => ({
|
||||
pressed: (over.buttons?.[index] ?? 0) > 0.5,
|
||||
value: over.buttons?.[index] ?? 0,
|
||||
}));
|
||||
return { axes: over.axes ?? [0, 0, 0, 0], buttons };
|
||||
}
|
||||
|
||||
describe("vehicle input adapters", () => {
|
||||
it("maps a standard gamepad with a steering deadzone and analogue triggers", () => {
|
||||
const sample = sampleStandardGamepad(pad({ axes: [0.5], buttons: { 6: 0.2, 7: 0.75, 1: 1 } }));
|
||||
assert.ok(sample.actions.steering > 0 && sample.actions.steering < 0.5);
|
||||
assert.equal(sample.actions.brake, 0.2);
|
||||
assert.equal(sample.actions.throttle, 0.75);
|
||||
assert.equal(sample.actions.handbrake, true);
|
||||
assert.equal(sampleStandardGamepad(pad({ axes: [0.05] })).actions.steering, 0);
|
||||
});
|
||||
|
||||
it("publishes assisted/reset buttons only on their rising edge", () => {
|
||||
const first = sampleStandardGamepad(pad({ buttons: { 2: 1, 3: 1 } }));
|
||||
assert.equal(first.actions.modeRequest, "assisted");
|
||||
assert.equal(first.actions.reset, true);
|
||||
const held = sampleStandardGamepad(pad({ buttons: { 2: 1, 3: 1 } }), first.buttons);
|
||||
assert.equal(held.actions.modeRequest, "none");
|
||||
assert.equal(held.actions.reset, false);
|
||||
});
|
||||
|
||||
it("merges simultaneous adapters by strongest analogue and any safety input", () => {
|
||||
assert.deepEqual(
|
||||
mergeVehicleActions(
|
||||
{ throttle: 1, steering: -0.4 },
|
||||
{ brake: 0.7, steering: 0.8, handbrake: true },
|
||||
),
|
||||
{
|
||||
throttle: 1,
|
||||
brake: 0.7,
|
||||
steering: 0.8,
|
||||
handbrake: true,
|
||||
modeRequest: "none",
|
||||
reset: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user