The client gets tests, starting with the two files that most needed them
Nine test files on the server, none on the client, and no test script in the root package at all — so CI's only gate on the half of this project that runs in a stranger's browser was `tsc --noEmit`, which will tell you the types line up and nothing about whether an anonymous visitor is handed the private office. Both files here were built to be tested and never were. `access.ts` is the only module in the bundle whose output is a set of decisions about what a stranger may see, and its own header carries the reason a test is owed: a shipped line 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 got the private view while the config still said the box was private. There is now a test named after that bug. The assertions are weighted toward the closed direction on purpose: one that a member gets what a member is owed, and half a dozen that nobody gets more than nothing, because showing a member the public office is a bad afternoon and showing a stranger the private one is what the tiers exist to prevent. The 5xx case is in there too — a box mid-restart is `anon`, not `member` — and so is the `javascript:` entry URL, which a CSP of `script-src 'self' 'unsafe-inline'` does not stop from navigating. `plan.ts` says in its own header that it imports no three.js "so the splitting pass is testable without a WebGL context", and that it "drops rather than throws … every one of those is reported through `problems`" — an array whose whole purpose is to be asserted on, which nothing asserted on. So: a pack wrong in five ways still builds and files five reports; a zero-length wall does not put a NaN in the bounds; an office with no levels stays finite so nothing downstream divides by it; a pack with the required arrays missing is taken, because HTTP will send one. And the wall pass gets the check CONTRACT.md §2's argument deserves — one decomposition, two products — by walking a walker through the door and into the wall beside it, and through a window and being stopped. Also the yaw convention, which nothing stated and `officeMinimap.ts` draws straight from: get the sign wrong and every wall mirrors about its own centre, invisible on a square and obvious on anything else. No new dependency. The server already runs `node --test` over `.ts` on native type stripping, so the client does the same — which matters here, because this repo's "no surprise dependencies" check is an allowlist naming why each one is permitted, and a test runner would have needed an entry and an argument. One source change was needed to make any of it possible. `session.ts` read `import.meta.env.VITE_IDENTITY_URL` at module scope, and `access.ts` imports `authFetch` from it — so one property access made the file that decides what an anonymous visitor sees unreachable from a plain test runner, which is most of why it had no tests. It now reads the way `plan.ts` already reads `DEV`, by the idiom that file documents as being there "so this module stays importable from a plain test runner". 31 tests, 9 suites, all passing, wired into `npm test` and into the CI job beside the server's. `vite build` is unchanged and no test code reaches the bundle.
This commit is contained in:
@@ -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<string, () => Response | Promise<Response>>): 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("<!doctype html><html></html>", {
|
||||
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,<script>alert(1)</script>",
|
||||
"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);
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user