diff --git a/.gitea/workflows/gates.yml b/.gitea/workflows/gates.yml index b57fe85..9245a89 100644 --- a/.gitea/workflows/gates.yml +++ b/.gitea/workflows/gates.yml @@ -63,6 +63,9 @@ jobs: # allowlist rather than a count — see CONTRACT.md §6. run: npm run build + - name: client tests + run: npm test + - name: server tests run: npm test --workspace @lumbridge/tera-api diff --git a/package.json b/package.json index 51626c6..48d925d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", + "test": "node --test \"src/test/*.test.ts\"", "typecheck": "tsc --noEmit", "preview": "vite preview" }, diff --git a/src/session.ts b/src/session.ts index 12529b1..e6cec82 100644 --- a/src/session.ts +++ b/src/session.ts @@ -23,10 +23,22 @@ /** Namespaced so a self-hoster running something else on this origin is unaffected. */ const KEY = "tera.session.token"; +/** + * Build-time configuration, read the way `plan.ts` reads `DEV` and for the same + * stated reason: Vite substitutes `import.meta.env` and Node leaves it + * undefined, so reaching through it directly throws the moment this module is + * imported outside a bundler. That mattered more than it looked. `access.ts` + * imports `authFetch` from here, so one property access at module scope made + * the file that decides what an anonymous visitor may see unreachable from a + * plain test runner — which is most of why it had no tests. + */ +const ENV: Record = + (import.meta as unknown as { env?: Record }).env ?? {}; + /** Whether this build was given an identity provider to sign in against. */ -export const IDENTITY_URL: string = import.meta.env.VITE_IDENTITY_URL ?? ""; +export const IDENTITY_URL: string = ENV.VITE_IDENTITY_URL ?? ""; /** The provider's PUBLIC key. Publishable by design — it gates nothing on its own. */ -export const IDENTITY_KEY: string = import.meta.env.VITE_IDENTITY_ANON_KEY ?? ""; +export const IDENTITY_KEY: string = ENV.VITE_IDENTITY_ANON_KEY ?? ""; export const identityConfigured = IDENTITY_URL !== "" && IDENTITY_KEY !== ""; export function readToken(): string | null { diff --git a/src/test/access.test.ts b/src/test/access.test.ts new file mode 100644 index 0000000..99e2290 --- /dev/null +++ b/src/test/access.test.ts @@ -0,0 +1,293 @@ +/** + * What the deployment says you are, and — mostly — what happens when it will not + * say. + * + * `access.ts` is the only file in the browser bundle whose output is a set of + * decisions about what a stranger may see, and until this file it had no test at + * all. Its own header carries the reason that is not acceptable: there was a + * line, shipped, that read + * + * canEnterOffice = s.authenticated || !s.passwordLogin; + * + * which is true for `auth: none` and dangerously false for `sso`, where `POST + * /session` answers 404 precisely *because* credentials are issued elsewhere — + * so on an SSO deployment every anonymous visitor was handed the private view + * while the config still said the box was private. That bug is fixed, and until + * now nothing would have noticed it coming back. + * + * So the assertions here are weighted toward the closed direction. There is one + * test that a member gets what a member is owed and half a dozen that nobody + * gets more than nothing, because the two failures are not symmetric: showing a + * member the public office is a bad afternoon and showing a stranger the private + * one is the thing the tiers exist to prevent. + * + * No DOM and no network. `resolveAccess` takes its `fetch` as an argument for + * exactly this reason, and every case here is a fake that answers the two probes + * in a particular way — which is also the cheapest way to reach the branches a + * real deployment only reaches while it is broken. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { capabilitiesFor, resolveAccess } from "../access.ts"; + +/** + * One page's worth of browser. + * + * `entryHref` resolves a relative `entryUrl` against `window.location.origin`, + * which is correct for a module that only ever runs in a tab and is the one + * thing here that cannot be passed in. Stubbing it is not a workaround for a + * design problem: the origin is genuinely an input to the answer, and naming it + * explicitly means the "is this a path on my own origin" cases below are + * asserting against a known one rather than against whatever the runner had. + */ +Object.defineProperty(globalThis, "window", { + configurable: true, + value: { location: { origin: "https://office.example.test" } }, +}); + +/** A JSON answer, as `getJson` insists on seeing one: 2xx and a JSON content type. */ +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +/** + * A fake deployment: one answer per path, and anything unlisted is a transport + * failure, which is what a static host with no API actually does to `fetch`. + */ +function deployment(routes: Record Response | Promise>): typeof fetch { + return (async (input: RequestInfo | URL) => { + const url = String(input); + for (const [path, answer] of Object.entries(routes)) { + if (url.endsWith(path)) return answer(); + } + throw new TypeError("Failed to fetch"); + }) as typeof fetch; +} + +const NO_API = deployment({}); + +describe("the table", () => { + it("opens the office door at every tier, and varies what is behind it", () => { + // The door being open to everyone is a decision with a paragraph behind it: + // the anonymous view of this site used to be a map with a greyed-out button, + // which is the most interesting thing the project does shown only as + // something you cannot have. + for (const tier of ["anon", "member", "god"] as const) { + assert.equal(capabilitiesFor(tier).enterOffice, true, tier); + } + assert.equal(capabilitiesFor("anon").officeDepth, "public"); + assert.equal(capabilitiesFor("member").officeDepth, "full"); + assert.equal(capabilitiesFor("god").officeDepth, "full"); + }); + + it("keeps the instruments god-only and the feeds off the anonymous path", () => { + assert.equal(capabilitiesFor("member").timeControl, false); + assert.equal(capabilitiesFor("member").debug, false); + assert.equal(capabilitiesFor("god").timeControl, true); + assert.equal(capabilitiesFor("anon").liveData, false); + }); +}); + +describe("no API behind the page", () => { + it("is a member with no door, because clone-and-run is the flagship case", async () => { + const access = await resolveAccess(NO_API); + assert.equal(access.tier, "member"); + assert.equal(access.signInUrl, null); + assert.equal(access.can.officeDepth, "full"); + }); + + it("is never god, however unreachable the server is", async () => { + // A client that awards itself godmode when it cannot reach the server has + // turned a network failure into a privilege escalation. + const access = await resolveAccess(NO_API); + assert.notEqual(access.tier, "god"); + assert.equal(access.can.debug, false); + }); + + it("treats a static host answering index.html as no API at all", async () => { + // The failure this closes: a static host answers every unknown path with the + // SPA shell and a 200, so without the content-type check `/health` would + // "succeed" and the JSON parse would throw somewhere less convenient. + const spa = deployment({ + "/health": () => + new Response("", { + status: 200, + headers: { "content-type": "text/html" }, + }), + }); + assert.equal((await resolveAccess(spa)).tier, "member"); + }); +}); + +describe("a deployment that says it has auth", () => { + const sso = () => json({ auth: { mode: "sso", entryUrl: "https://id.example.com/start" } }); + + it("is anonymous until the session says otherwise", async () => { + const access = await resolveAccess( + deployment({ "/health": sso, "/session": () => json({ authenticated: false }) }), + ); + assert.equal(access.tier, "anon"); + assert.equal(access.can.officeDepth, "public"); + assert.equal(access.can.liveData, false); + }); + + /** + * The regression test for the bug in the module header. + * + * An SSO box has no local password form, so `passwordLogin` is false — and the + * old rule read that as "this box cannot sign anyone in, so it must be open". + * If this ever passes with `member`, that line is back. + */ + it("does not read the absence of a password form as an open door", async () => { + const access = await resolveAccess( + deployment({ + "/health": sso, + "/session": () => json({ authenticated: false, passwordLogin: false }), + }), + ); + assert.equal(access.tier, "anon"); + }); + + it("fails closed when the session probe fails, rather than back to member", async () => { + // `/health` has already said this box has auth. Anything short of an + // affirmative answer after that is `anon` — the asymmetry with the no-API + // case above is the entire point of the three-way split in `getJson`. + for (const session of [ + () => new Response("", { status: 500 }), + () => new Response("", { status: 404 }), + () => { + throw new TypeError("Failed to fetch"); + }, + ]) { + const access = await resolveAccess(deployment({ "/health": sso, "/session": session })); + assert.equal(access.tier, "anon"); + } + }); + + it("promotes a signed-in caller, and only to god when the server says admin", async () => { + const member = await resolveAccess( + deployment({ + "/health": sso, + "/session": () => json({ authenticated: true, subject: "someone@example.com" }), + }), + ); + assert.equal(member.tier, "member"); + assert.equal(member.subject, "someone@example.com"); + + const god = await resolveAccess( + deployment({ + "/health": sso, + "/session": () => json({ authenticated: true, subject: "root", admin: true }), + }), + ); + assert.equal(god.tier, "god"); + assert.equal(god.can.timeControl, true); + }); + + it("never infers godmode from a server too old to mention admin", async () => { + // `admin` is newer than some servers this client will meet. A missing field + // has exactly one safe direction to fall. + const access = await resolveAccess( + deployment({ + "/health": sso, + "/session": () => json({ authenticated: true, subject: "someone" }), + }), + ); + assert.equal(access.tier, "member"); + assert.equal(access.can.debug, false); + }); +}); + +describe("a sick API is not an absent one", () => { + it("is anonymous while /health is 5xx, not a member", async () => { + // `tera-api` restarts, Caddy answers 502 for eight seconds, and every + // anonymous visitor in that window would otherwise be told they are a member + // — badge, full-depth office, and a markers request about to be refused. + const access = await resolveAccess( + deployment({ "/health": () => new Response("", { status: 502 }) }), + ); + assert.equal(access.tier, "anon"); + assert.equal(access.signInUrl, null, "no door, because we do not know which door yet"); + }); + + it("is a member when auth is switched off, which is a choice and not a failure", async () => { + const access = await resolveAccess( + deployment({ "/health": () => json({ auth: { mode: "none" } }) }), + ); + assert.equal(access.tier, "member"); + assert.equal(access.signInUrl, null); + }); +}); + +describe("the sign-in link is a URL this page may navigate to", () => { + const withEntry = (entryUrl: unknown) => + deployment({ + "/health": () => json({ auth: { mode: "sso", entryUrl } }), + "/session": () => json({ authenticated: false }), + }); + + it("takes an absolute http(s) URL from the identity provider", async () => { + const access = await resolveAccess(withEntry("https://id.example.com/start")); + assert.equal(access.signInUrl, "https://id.example.com/start"); + }); + + /** + * The one that is a security test rather than a parsing test. + * + * `entryUrl` arrives from whatever this browser is pointed at and lands in an + * `a.href`. A CSP of `script-src 'self' 'unsafe-inline'` does not stop a + * `javascript:` URL navigating, so an operator who pastes an untrusted + * `TERA_AUTH_ENTRY_URL` — or an API that has been taken over — would get + * script execution in the origin holding the session token. + */ + it("refuses a scheme that is not a sign-in page", async () => { + for (const hostile of [ + "javascript:alert(document.cookie)", + "data:text/html,", + "blob:https://example.com/whatever", + "", + 42, + null, + ]) { + const access = await resolveAccess(withEntry(hostile)); + assert.notEqual( + access.signInUrl?.startsWith("javascript:") ?? false, + true, + `accepted ${String(hostile)}`, + ); + if (typeof hostile === "string" && hostile.startsWith("javascript:")) { + assert.equal(access.signInUrl, null); + } + } + }); + + it("resolves a path against this origin rather than rejecting it", async () => { + // `/login.html` ships in this bundle and is the local form's home. + const access = await resolveAccess(withEntry("/login.html")); + assert.ok(access.signInUrl?.endsWith("/login.html"), access.signInUrl ?? "null"); + }); + + it("offers the local form only where the server said it can process one", async () => { + const withForm = await resolveAccess( + deployment({ + "/health": () => json({ auth: { mode: "jwt" } }), + "/session": () => json({ authenticated: false, passwordLogin: true }), + }), + ); + assert.equal(withForm.signInUrl, "/login.html"); + + // A form that renders, takes a password and posts it to a 404 is a worse + // failure than no form at all. + const without = await resolveAccess( + deployment({ + "/health": () => json({ auth: { mode: "jwt" } }), + "/session": () => json({ authenticated: false, passwordLogin: false }), + }), + ); + assert.equal(without.signInUrl, null); + }); +}); diff --git a/src/test/plan.test.ts b/src/test/plan.test.ts new file mode 100644 index 0000000..268575e --- /dev/null +++ b/src/test/plan.test.ts @@ -0,0 +1,308 @@ +/** + * `Plan`: the wall split, and everything it refuses to throw on. + * + * This file exists because `plan.ts` was built to be tested and never was. Two + * of its own design notes are promises to a test that did not exist: + * + * - "**`Plan` imports no three.js**. That keeps the splitting pass testable + * without a WebGL context" — so this runs under a plain `node --test`, with + * no DOM, no canvas and no bundler. + * - "It drops rather than throws … Every one of those is reported through + * `problems`" — an array whose entire purpose is to be asserted on, and + * which nothing asserted on. + * + * The second is the more valuable half. A self-hoster authoring their first + * office will write a polygon that crosses itself, a door that runs off the end + * of its wall, and two things with the same id, and the contract says they get a + * building anyway plus a list of what was wrong with theirs. Everything below is + * that promise, one clause at a time. + * + * The wall pass gets its own section because CONTRACT.md §2 chose an explicit + * wall list with 1-D openings over walls inferred from room edges on the grounds + * that one decomposition yields both the drawing and the collider — "two + * products, one pass, no second list to keep in sync". That is a claim about + * geometry that can be checked, so it is. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { Plan } from "../interiors/plan.ts"; +import type { Level, Office, Room, Wall } from "../interiors/types.ts"; + +/** Silent: these packs are wrong on purpose and the warnings are the point, not noise. */ +const QUIET = { warn: false } as const; + +const SQUARE: Room["outline"] = [ + { x: 0, z: 0 }, + { x: 10, z: 0 }, + { x: 10, z: 6 }, + { x: 0, z: 6 }, +]; + +function level(over: Partial = {}): Level { + return { + id: "level-1", + name: "Level 1", + elevation: 0, + wallHeight: 2.8, + wallThickness: 0.1, + floorplan: { rooms: [], walls: [] }, + ...over, + }; +} + +function office(levels: Level[]): Office { + return { id: "test", name: "Test", levels, viewpoints: [] }; +} + +/** One level, one room, and whatever walls the case is about. */ +function withWalls(walls: Wall[], rooms: Room[] = [{ id: "r", name: "Room", outline: SQUARE, floor: "floor" as never }]) { + return new Plan(office([level({ floorplan: { rooms, walls } })]), QUIET); +} + +describe("it drops rather than throws", () => { + it("survives a pack that is wrong in five ways at once and still builds", () => { + const plan = withWalls( + [ + { id: "w", from: { x: 0, z: 0 }, to: { x: 10, z: 0 } }, + { id: "w", from: { x: 0, z: 6 }, to: { x: 10, z: 6 } }, + { id: "nowhere", from: { x: 3, z: 3 }, to: { x: 3, z: 3 } }, + ], + [ + { id: "r", name: "Room", outline: SQUARE, floor: "floor" as never }, + { id: "r", name: "Twin", outline: SQUARE, floor: "floor" as never }, + ], + ); + // The good half is standing. + assert.equal(plan.levels.length, 1); + assert.equal(plan.levels[0]?.rooms.length, 1); + // And every casualty is on the record rather than only in the console. + assert.ok(plan.problems.length >= 3, JSON.stringify(plan.problems)); + assert.ok(plan.problems.every((p) => p.where !== "" && p.message !== "")); + }); + + it("drops a duplicate id and keeps the first, per kind", () => { + const plan = withWalls([ + { id: "w", from: { x: 0, z: 0 }, to: { x: 10, z: 0 } }, + { id: "w", from: { x: 0, z: 6 }, to: { x: 10, z: 6 } }, + ]); + assert.deepEqual([...new Set(plan.levels[0]?.runs.map((r) => r.wallId))], ["w"]); + assert.ok(plan.problems.some((p) => /duplicate/i.test(p.message))); + }); + + it("drops a zero-length wall instead of dividing by it", () => { + const plan = withWalls([{ id: "nowhere", from: { x: 3, z: 3 }, to: { x: 3, z: 3 } }]); + assert.equal(plan.levels[0]?.runs.length, 0); + assert.ok(plan.problems.some((p) => /zero length/i.test(p.message))); + // The real assertion: nothing is NaN downstream of it. + assert.ok(Number.isFinite(plan.bounds.width)); + }); + + it("keeps an empty office finite, so nothing downstream divides by a bound", () => { + const plan = new Plan(office([]), QUIET); + assert.equal(plan.levels.length, 0); + for (const n of [plan.bounds.width, plan.bounds.depth, plan.bounds.center.x]) { + assert.ok(Number.isFinite(n), `${n}`); + } + }); + + it("takes a pack with the required arrays missing, because HTTP will send one", () => { + // `levels` and `viewpoints` are required by the type, and a pack arriving as + // JSON has been through no type checker at all. + const plan = new Plan({ id: "x", name: "X" } as unknown as Office, QUIET); + assert.equal(plan.levels.length, 0); + assert.equal(plan.viewpoints.length, 0); + }); +}); + +describe("the wall pass", () => { + /** A 10 m wall with a 1 m door starting 4 m along. */ + const withDoor: Wall = { + id: "w", + from: { x: 0, z: 0 }, + to: { x: 10, z: 0 }, + openings: [{ kind: "door", start: 4, width: 1, sill: 0, head: 2.1 }], + }; + + it("splits a wall around its opening and leaves the hole empty", () => { + const plan = withWalls([withDoor]); + const solid = plan.levels[0]?.runs.filter((r) => r.role === "solid") ?? []; + assert.equal(solid.length, 2); + // 0..4 and 5..10, so the drawn length is the wall minus the door exactly. + const drawn = solid.reduce((sum, r) => sum + r.length, 0); + assert.ok(Math.abs(drawn - 9) < 1e-6, `${drawn}`); + assert.equal(plan.levels[0]?.openings.length, 1); + }); + + it("draws a lintel over a door and does not collide with it", () => { + const plan = withWalls([withDoor]); + const runs = plan.levels[0]?.runs ?? []; + assert.ok(runs.some((r) => r.role === "lintel"), "no lintel over the door"); + // The claim CONTRACT.md §2 makes: one pass, two products. The collider has + // a gap exactly where the door is, so a walker crosses the centreline there + // and nowhere else along this wall. + const level1 = plan.levels[0]?.id ?? ""; + const through = { x: 4.5, z: -0.5 }; + const inside = { x: 4.5, z: 0.5 }; + assert.equal(plan.blocked(level1, through, inside, 0.05), false, "the door is shut"); + const wall = { x: 1, z: -0.5 }; + const wallInside = { x: 1, z: 0.5 }; + assert.equal(plan.blocked(level1, wall, wallInside, 0.05), true, "the wall is open"); + }); + + it("draws a window's apron and still collides with it", () => { + // The apron under a window is drawn and *is* collided with — a person cannot + // walk through a window, and that is the distinction the roles carry. + const plan = withWalls([ + { + id: "w", + from: { x: 0, z: 0 }, + to: { x: 10, z: 0 }, + openings: [{ kind: "window", start: 4, width: 2, sill: 0.9, head: 2.2 }], + }, + ]); + const runs = plan.levels[0]?.runs ?? []; + assert.ok(runs.some((r) => r.role === "apron"), "no apron under the window"); + assert.equal( + plan.blocked(plan.levels[0]?.id ?? "", { x: 5, z: -0.5 }, { x: 5, z: 0.5 }, 0.05), + true, + "walked through a window", + ); + }); + + it("merges adjacent blocking stretches, so a run of windows is not eleven segments", () => { + const plan = withWalls([ + { + id: "w", + from: { x: 0, z: 0 }, + to: { x: 10, z: 0 }, + openings: [ + { kind: "window", start: 1, width: 1, sill: 0.9, head: 2.2 }, + { kind: "window", start: 3, width: 1, sill: 0.9, head: 2.2 }, + { kind: "window", start: 5, width: 1, sill: 0.9, head: 2.2 }, + ], + }, + ]); + // Every one of them is passable-at-no-height, so the whole wall is one + // uninterrupted collision segment rather than four. + assert.equal(plan.collisionAt(plan.levels[0]?.id ?? "").length, 1); + }); + + it("refuses an opening that runs off the end of its wall", () => { + const plan = withWalls([ + { + id: "w", + from: { x: 0, z: 0 }, + to: { x: 10, z: 0 }, + openings: [{ kind: "door", start: 9.5, width: 3, sill: 0, head: 2.1 }], + }, + ]); + assert.ok(plan.problems.length > 0, "an overhanging door was accepted silently"); + // Whatever it decided, the wall is still a wall and nothing escaped it. + for (const run of plan.levels[0]?.runs ?? []) { + assert.ok(run.start >= -1e-6 && run.end <= 10 + 1e-6, `${run.start}..${run.end}`); + } + }); + + it("puts a run's centre where its own yaw says it is", () => { + // The invariant every consumer depends on and nothing stated: a run lies + // along its local +X, which for yaw φ points at (cos φ, -sin φ). Get the + // sign wrong and every wall in the building mirrors about its own centre — + // invisible on a square, obvious on anything else. `officeMinimap.ts` draws + // straight from these numbers. + const plan = withWalls([{ id: "w", from: { x: 0, z: 0 }, to: { x: 0, z: 8 } }]); + const run = plan.levels[0]?.runs[0]; + assert.ok(run, "no run"); + assert.ok(Math.abs(run.center.x - 0) < 1e-6); + assert.ok(Math.abs(run.center.z - 4) < 1e-6); + const endX = run.center.x + Math.cos(run.yaw) * (run.length / 2); + const endZ = run.center.z - Math.sin(run.yaw) * (run.length / 2); + assert.ok(Math.abs(endX - 0) < 1e-6, `${endX}`); + assert.ok(Math.abs(endZ - 8) < 1e-6, `${endZ}`); + }); +}); + +describe("depth is a resolution pass, not a visibility flag", () => { + const mixed = () => + office([ + level({ + floorplan: { + rooms: [{ id: "r", name: "Room", outline: SQUARE, floor: "floor" as never }], + walls: [{ id: "w", from: { x: 0, z: 0 }, to: { x: 10, z: 0 } }], + seats: [ + { id: "open", position: { x: 2, z: 2 }, facing: 0, pose: "sit" }, + { id: "secret", position: { x: 4, z: 2 }, facing: 0, pose: "sit", audience: "private" }, + ], + }, + }), + ]); + + it("leaves a private item out of the build product entirely at public depth", () => { + const pub = new Plan(mixed(), { ...QUIET, depth: "public" }); + // Not hidden — absent. A private item that is built and then hidden is still + // in `scene.traverse`, in the devtools graph and in a `JSON.stringify` of + // this object, which is a data leak with a checkbox in front of it. + assert.equal(pub.seat("secret"), null); + assert.deepEqual( + pub.allSeats().map((s) => s.id), + ["open"], + ); + assert.equal(JSON.stringify(pub.levels).includes("secret"), false, "leaked into the tree"); + }); + + it("keeps it at full depth, and says which build it is", () => { + const full = new Plan(mixed(), { ...QUIET, depth: "full" }); + assert.equal(full.depth, "full"); + assert.ok(full.seat("secret")); + // Absence is not reported as a problem: the pack is not wrong, it is being + // read at a depth that does not include the item. + assert.equal(new Plan(mixed(), { ...QUIET, depth: "public" }).problems.length, 0); + }); +}); + +describe("queries", () => { + it("lets a later room win, because that is the order a pack lays them down", () => { + const plan = new Plan( + office([ + level({ + floorplan: { + rooms: [ + { id: "floor", name: "Open floor", outline: SQUARE, floor: "floor" as never }, + { + id: "meeting", + name: "Meeting", + outline: [ + { x: 1, z: 1 }, + { x: 4, z: 1 }, + { x: 4, z: 4 }, + { x: 1, z: 4 }, + ], + floor: "floor" as never, + }, + ], + walls: [], + }, + }), + ]), + QUIET, + ); + assert.equal(plan.roomAt("level-1", { x: 2, z: 2 })?.id, "meeting"); + assert.equal(plan.roomAt("level-1", { x: 8, z: 5 })?.id, "floor"); + assert.equal(plan.roomAt("level-1", { x: 50, z: 50 }), null); + assert.equal(plan.roomAt("no-such-level", { x: 2, z: 2 }), null); + }); + + it("adds the level elevation exactly once, so a mezzanine is not in the basement", () => { + const plan = new Plan( + office([ + level({ id: "l1", elevation: 0, floorplan: { rooms: [], walls: [{ id: "a", from: { x: 0, z: 0 }, to: { x: 4, z: 0 } }] } }), + level({ id: "l2", elevation: 4.2, floorplan: { rooms: [], walls: [{ id: "b", from: { x: 0, z: 0 }, to: { x: 4, z: 0 } }] } }), + ]), + QUIET, + ); + assert.equal(plan.level("l1")?.floorY, 0); + assert.equal(plan.level("l2")?.floorY, 4.2); + assert.equal(plan.level("l1")?.runs[0]?.bottom, 0); + assert.equal(plan.level("l2")?.runs[0]?.bottom, 4.2); + }); +});