feat: add character studio and aircraft foundation
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
AircraftController,
|
||||
ELECTRIC_AIRCRAFT_METRICS,
|
||||
aircraftChaseCameraPose,
|
||||
advanceAircraftFans,
|
||||
buildElectricAircraft,
|
||||
createElectricAircraftMaterials,
|
||||
disposeElectricAircraft,
|
||||
normalizeAircraftActions,
|
||||
replayAircraftInputs,
|
||||
setAircraftControlSurfaces,
|
||||
setAircraftFanRotation,
|
||||
} from "../aircraft/index.ts";
|
||||
|
||||
const ROUTE = [
|
||||
{ id: "los-angeles", lat: 34.0522, lng: -118.2437, altitudeM: 1_300 },
|
||||
{ id: "san-francisco", lat: 37.7749, lng: -122.4194, altitudeM: 1_700 },
|
||||
] as const;
|
||||
|
||||
describe("procedural electric aircraft", () => {
|
||||
it("builds an original metre-scale fixed wing facing -Z with articulated parts", () => {
|
||||
const rig = buildElectricAircraft();
|
||||
assert.equal(rig.root.name, "electric-aircraft");
|
||||
assert.equal(rig.fans.length, 2);
|
||||
assert.equal(rig.ownsMaterials, true);
|
||||
assert.ok(ELECTRIC_AIRCRAFT_METRICS.wingspan > ELECTRIC_AIRCRAFT_METRICS.length);
|
||||
const bounds = new THREE.Box3().setFromObject(rig.root);
|
||||
const size = bounds.getSize(new THREE.Vector3());
|
||||
assert.ok(size.x > 10);
|
||||
assert.ok(size.z > 7);
|
||||
assert.ok(size.y > 1.5);
|
||||
disposeElectricAircraft(rig);
|
||||
assert.equal(rig.root.children.length, 0);
|
||||
});
|
||||
|
||||
it("animates opposing ailerons, V-tail surfaces, and electric fans", () => {
|
||||
const rig = buildElectricAircraft();
|
||||
setAircraftControlSurfaces(rig, { roll: 0.8, pitch: 0.5, yaw: -0.4 });
|
||||
assert.ok(rig.leftAileron.rotation.x > 0);
|
||||
assert.ok(rig.rightAileron.rotation.x < 0);
|
||||
assert.notEqual(rig.leftVTail.rotation.x, rig.rightVTail.rotation.x);
|
||||
setAircraftFanRotation(rig, 1);
|
||||
advanceAircraftFans(rig, 0.5);
|
||||
assert.equal(rig.fans[0]?.rotation.z, 1.5);
|
||||
assert.equal(rig.fans[1]?.rotation.z, 1.5);
|
||||
disposeElectricAircraft(rig);
|
||||
});
|
||||
|
||||
it("does not dispose caller-owned materials", () => {
|
||||
const materials = createElectricAircraftMaterials();
|
||||
let disposals = 0;
|
||||
for (const material of Object.values(materials)) {
|
||||
material.addEventListener("dispose", () => { disposals += 1; });
|
||||
}
|
||||
const rig = buildElectricAircraft({ materials });
|
||||
assert.equal(rig.ownsMaterials, false);
|
||||
disposeElectricAircraft(rig);
|
||||
assert.equal(disposals, 0);
|
||||
for (const material of Object.values(materials)) material.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("aircraft controller", () => {
|
||||
it("normalizes device-neutral flight axes", () => {
|
||||
assert.deepEqual(normalizeAircraftActions({
|
||||
throttle: 5,
|
||||
yaw: -4,
|
||||
pitch: Number.NaN,
|
||||
roll: 2,
|
||||
modeRequest: "manual",
|
||||
reset: true,
|
||||
}), {
|
||||
throttle: 1,
|
||||
yaw: -1,
|
||||
pitch: 0,
|
||||
roll: 1,
|
||||
modeRequest: "manual",
|
||||
reset: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("follows route and altitude deterministically in assisted mode", () => {
|
||||
const options = {
|
||||
route: ROUTE,
|
||||
initialPosition: ROUTE[0],
|
||||
initialHeadingDeg: 0,
|
||||
initialAltitudeM: 900,
|
||||
assistedAltitudeM: 1_300,
|
||||
};
|
||||
const a = new AircraftController(options);
|
||||
const b = new AircraftController(options);
|
||||
for (let index = 0; index < 600; index += 1) {
|
||||
a.stepFixed();
|
||||
b.stepFixed();
|
||||
}
|
||||
assert.deepEqual(a.snapshot(), b.snapshot());
|
||||
assert.equal(a.state().mode, "assisted");
|
||||
assert.equal(a.state().routeWaypointId, "san-francisco");
|
||||
assert.ok(a.state().altitudeM > 900);
|
||||
assert.ok(a.state().headingDeg > 180, "aircraft turns northwest toward San Francisco");
|
||||
});
|
||||
|
||||
it("takes manual input immediately and resumes assistance without a state jump", () => {
|
||||
const controller = new AircraftController({ route: ROUTE });
|
||||
for (let index = 0; index < 120; index += 1) {
|
||||
controller.stepFixed({ throttle: 0.8, roll: 0.7, pitch: -0.3 });
|
||||
}
|
||||
assert.equal(controller.state().mode, "manual");
|
||||
const before = controller.snapshot();
|
||||
controller.stepFixed({ modeRequest: "assisted", roll: 0.8 });
|
||||
assert.equal(controller.state().mode, "manual", "manual controls beat simultaneous resume");
|
||||
controller.stepFixed({ modeRequest: "assisted" });
|
||||
assert.equal(controller.state().mode, "assisted");
|
||||
assert.ok(Math.abs(controller.state().rollDeg - before.rollDeg) < 3);
|
||||
assert.ok(Math.abs(controller.state().pitchDeg - before.pitchDeg) < 3);
|
||||
});
|
||||
|
||||
it("enforces geographic, altitude, and speed boundaries", () => {
|
||||
const controller = new AircraftController({
|
||||
mode: "manual",
|
||||
initialPosition: { lat: 100, lng: -200 },
|
||||
initialAltitudeM: 100_000,
|
||||
initialSpeedMps: 1_000,
|
||||
maximumSpeedMps: 80,
|
||||
});
|
||||
assert.equal(controller.state().lat, 42.1);
|
||||
assert.equal(controller.state().lng, -124.6);
|
||||
assert.equal(controller.state().altitudeM, 6_000);
|
||||
assert.equal(controller.state().speedMps, 80);
|
||||
for (let index = 0; index < 180; index += 1) {
|
||||
controller.stepFixed({ throttle: 1, pitch: 1, roll: 1 });
|
||||
}
|
||||
assert.ok(controller.state().lat >= 32.4 && controller.state().lat <= 42.1);
|
||||
assert.ok(controller.state().lng >= -124.6 && controller.state().lng <= -114);
|
||||
assert.ok(controller.state().altitudeM >= 75 && controller.state().altitudeM <= 6_000);
|
||||
assert.ok(controller.state().speedMps <= 80);
|
||||
assert.equal(controller.state().envelopeContact, true);
|
||||
});
|
||||
|
||||
it("resets exactly, caps sleeping-tab time, and replays bit-for-bit", () => {
|
||||
const options = { route: ROUTE, initialAltitudeM: 1_200, initialSpeedMps: 48 } as const;
|
||||
const controller = new AircraftController(options);
|
||||
const spawn = controller.snapshot();
|
||||
assert.ok(controller.tick(600) <= 15);
|
||||
controller.stepFixed({ throttle: 1, roll: -1 });
|
||||
controller.stepFixed({ reset: true });
|
||||
assert.deepEqual(controller.snapshot(), spawn);
|
||||
|
||||
const frames = [
|
||||
{ steps: 90, actions: { throttle: 0.9, roll: 0.4 } },
|
||||
{ steps: 1, actions: { modeRequest: "assisted" as const } },
|
||||
{ steps: 180 },
|
||||
];
|
||||
assert.deepEqual(
|
||||
replayAircraftInputs(options, frames),
|
||||
replayAircraftInputs(options, frames),
|
||||
);
|
||||
});
|
||||
|
||||
it("derives a finite geographic chase camera behind the aircraft", () => {
|
||||
const controller = new AircraftController({ initialHeadingDeg: 0 });
|
||||
const camera = aircraftChaseCameraPose(controller.state());
|
||||
assert.ok(camera.position.lat < controller.state().lat);
|
||||
assert.ok(camera.target.lat > controller.state().lat);
|
||||
assert.ok(camera.position.altitudeM > controller.state().altitudeM);
|
||||
assert.ok(Object.values(camera.position).every(Number.isFinite));
|
||||
assert.ok(Object.values(camera.target).every(Number.isFinite));
|
||||
});
|
||||
|
||||
it("rejects malformed route and envelope configuration", () => {
|
||||
assert.throws(() => new AircraftController({
|
||||
route: [{ id: "bad", lat: Number.NaN, lng: 0, altitudeM: 1_000 }],
|
||||
}), /waypoints/);
|
||||
assert.throws(() => new AircraftController({
|
||||
envelope: {
|
||||
minLat: 40,
|
||||
maxLat: 30,
|
||||
minLng: -124,
|
||||
maxLng: -114,
|
||||
minAltitudeM: 10,
|
||||
maxAltitudeM: 100,
|
||||
},
|
||||
}), /envelope/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import { MaterialRegistry } from "../assets/materials.ts";
|
||||
import "../assets/office/index.ts";
|
||||
import { createFurnishings } from "../interiors/furnish.ts";
|
||||
import { Plan } from "../interiors/plan.ts";
|
||||
import type { Level, Office, Room } from "../interiors/types.ts";
|
||||
import { createOfficeMediaPresentation } from "../media/presentation.ts";
|
||||
|
||||
const ROOM: Room = {
|
||||
id: "room",
|
||||
name: "Room",
|
||||
floor: "floor" as never,
|
||||
outline: [{ x: 0, z: 0 }, { x: 8, z: 0 }, { x: 8, z: 6 }, { x: 0, z: 6 }],
|
||||
};
|
||||
|
||||
function fixture(depth: "full" | "public" = "full") {
|
||||
const level: Level = {
|
||||
id: "ground",
|
||||
name: "Ground",
|
||||
elevation: 0,
|
||||
wallHeight: 3,
|
||||
wallThickness: 0.1,
|
||||
floorplan: { rooms: [ROOM], walls: [], props: [
|
||||
{ id: "wall-display", kind: "tera:screen.wall-display", position: { x: 2, z: 2 }, rotation: 0 },
|
||||
{ id: "desk-monitor", kind: "tera:screen.monitor", position: { x: 4, z: 2 }, rotation: 0, elevation: 0.73 },
|
||||
{ id: "ordinary-desk", kind: "tera:desk.workstation", position: { x: 6, z: 2 }, rotation: 0 },
|
||||
{
|
||||
id: "private-display",
|
||||
kind: "tera:screen.wall-display",
|
||||
position: { x: 2, z: 4 },
|
||||
rotation: 0,
|
||||
audience: "private",
|
||||
},
|
||||
] },
|
||||
};
|
||||
const office: Office = { id: "media-office", name: "Media Office", levels: [level], viewpoints: [] };
|
||||
const plan = new Plan(office, { depth, warn: false });
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
const furnishings = createFurnishings(plan, { materials });
|
||||
const presentation = createOfficeMediaPresentation(plan, furnishings.group);
|
||||
return { plan, materials, furnishings, presentation };
|
||||
}
|
||||
|
||||
describe("procedural office media presentation", () => {
|
||||
it("discovers only authored screen props and keeps exact prop ids and rooms", () => {
|
||||
const f = fixture();
|
||||
const listed = f.presentation.list();
|
||||
assert.deepEqual(listed.map((item) => item.screenId).sort(), ["desk-monitor", "private-display", "wall-display"]);
|
||||
assert.ok(listed.every((item) => item.officeId === "media-office" && item.roomId === "room"));
|
||||
assert.ok(!listed.some((item) => item.screenId === "ordinary-desk"));
|
||||
assert.equal(f.presentation.group.children.length, 3);
|
||||
f.presentation.dispose();
|
||||
f.furnishings.dispose();
|
||||
f.materials.dispose();
|
||||
});
|
||||
|
||||
it("keeps public depth private-prop-free by discovering only the resolved plan", () => {
|
||||
const f = fixture("public");
|
||||
assert.deepEqual(f.presentation.list().map((item) => item.screenId).sort(), ["desk-monitor", "wall-display"]);
|
||||
f.presentation.dispose();
|
||||
f.furnishings.dispose();
|
||||
f.materials.dispose();
|
||||
});
|
||||
|
||||
it("requires authorization plus opt-in and returns to a safe placeholder", () => {
|
||||
const f = fixture();
|
||||
let textureDisposals = 0;
|
||||
const texture = new THREE.VideoTexture({} as HTMLVideoElement);
|
||||
texture.dispose = () => { textureDisposals += 1; };
|
||||
assert.equal(f.presentation.bind("wall-display", { canView: true, optedIn: false }, texture), false);
|
||||
assert.equal(f.presentation.list().find((item) => item.screenId === "wall-display")?.bound, false);
|
||||
assert.equal(f.presentation.bind("wall-display", { canView: true, optedIn: true }, texture), true);
|
||||
const mesh = f.presentation.group.getObjectByName("media-surface:wall-display") as THREE.Mesh;
|
||||
assert.equal((mesh.material as THREE.MeshBasicMaterial).map, texture);
|
||||
assert.equal(f.presentation.clear("wall-display"), true);
|
||||
assert.equal((mesh.material as THREE.MeshBasicMaterial).map, null);
|
||||
assert.equal(textureDisposals, 0);
|
||||
f.presentation.dispose();
|
||||
assert.equal(textureDisposals, 0, "caller-owned texture was disposed");
|
||||
f.furnishings.dispose();
|
||||
f.materials.dispose();
|
||||
});
|
||||
|
||||
it("returns defensive descriptors and contains unknown/disposed operations", () => {
|
||||
const f = fixture();
|
||||
const leaked = f.presentation.list()[0];
|
||||
assert.ok(leaked);
|
||||
leaked.screenId = "changed";
|
||||
assert.notEqual(f.presentation.list()[0]?.screenId, "changed");
|
||||
const texture = new THREE.VideoTexture({} as HTMLVideoElement);
|
||||
assert.equal(f.presentation.bind("missing", { canView: true, optedIn: true }, texture), false);
|
||||
f.presentation.dispose();
|
||||
f.presentation.dispose();
|
||||
assert.equal(f.presentation.bind("wall-display", { canView: true, optedIn: true }, texture), false);
|
||||
assert.equal(f.presentation.clear("wall-display"), false);
|
||||
f.furnishings.dispose();
|
||||
f.materials.dispose();
|
||||
texture.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
ACCENTS,
|
||||
BODY_SHAPES,
|
||||
HAIR_COLORS,
|
||||
OUTFITS,
|
||||
SKIN_TONES,
|
||||
createDefaultLocalProfile,
|
||||
createProfileEditor,
|
||||
type LocalProfile,
|
||||
} from "../profile/index.ts";
|
||||
|
||||
type Listener = (event: FakeEvent) => void;
|
||||
|
||||
class FakeEvent {
|
||||
defaultPrevented = false;
|
||||
readonly type: string;
|
||||
readonly target: FakeElement;
|
||||
readonly key: string;
|
||||
readonly ctrlKey: boolean;
|
||||
readonly metaKey: boolean;
|
||||
readonly shiftKey: boolean;
|
||||
constructor(
|
||||
type: string,
|
||||
target: FakeElement,
|
||||
key = "",
|
||||
ctrlKey = false,
|
||||
metaKey = false,
|
||||
shiftKey = false,
|
||||
) {
|
||||
this.type = type;
|
||||
this.target = target;
|
||||
this.key = key;
|
||||
this.ctrlKey = ctrlKey;
|
||||
this.metaKey = metaKey;
|
||||
this.shiftKey = shiftKey;
|
||||
}
|
||||
preventDefault(): void { this.defaultPrevented = true; }
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
readonly children: FakeElement[] = [];
|
||||
readonly attributes = new Map<string, string>();
|
||||
readonly listeners = new Map<string, Listener[]>();
|
||||
parentElement: FakeElement | null = null;
|
||||
className = "";
|
||||
textContent = "";
|
||||
hidden = false;
|
||||
value = "";
|
||||
id = "";
|
||||
type = "";
|
||||
maxLength = 0;
|
||||
autocomplete = "";
|
||||
disabled = false;
|
||||
readonly ownerDocument: FakeDocument;
|
||||
readonly tagName: string;
|
||||
|
||||
constructor(ownerDocument: FakeDocument, tagName: string) {
|
||||
this.ownerDocument = ownerDocument;
|
||||
this.tagName = tagName;
|
||||
}
|
||||
|
||||
append(...nodes: FakeElement[]): void {
|
||||
for (const node of nodes) {
|
||||
node.parentElement = this;
|
||||
this.children.push(node);
|
||||
}
|
||||
}
|
||||
setAttribute(name: string, value: string): void { this.attributes.set(name, value); }
|
||||
getAttribute(name: string): string | null { return this.attributes.get(name) ?? null; }
|
||||
addEventListener(type: string, listener: Listener): void {
|
||||
const found = this.listeners.get(type);
|
||||
if (found) found.push(listener);
|
||||
else this.listeners.set(type, [listener]);
|
||||
}
|
||||
dispatch(type: string, init: Partial<Pick<FakeEvent, "key" | "ctrlKey" | "metaKey" | "shiftKey">> = {}): FakeEvent {
|
||||
const event = new FakeEvent(type, this, init.key, init.ctrlKey, init.metaKey, init.shiftKey);
|
||||
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
||||
return event;
|
||||
}
|
||||
focus(): void { this.ownerDocument.activeElement = this; }
|
||||
remove(): void {
|
||||
if (!this.parentElement) return;
|
||||
const index = this.parentElement.children.indexOf(this);
|
||||
if (index >= 0) this.parentElement.children.splice(index, 1);
|
||||
this.parentElement = null;
|
||||
}
|
||||
find(attribute: string, value: string): FakeElement {
|
||||
if (this.attributes.get(attribute) === value) return this;
|
||||
for (const child of this.children) {
|
||||
try { return child.find(attribute, value); } catch { /* continue */ }
|
||||
}
|
||||
throw new Error(`missing [${attribute}=${value}]`);
|
||||
}
|
||||
descendants(tagName: string): FakeElement[] {
|
||||
const result: FakeElement[] = [];
|
||||
for (const child of this.children) {
|
||||
if (child.tagName === tagName.toUpperCase()) result.push(child);
|
||||
result.push(...child.descendants(tagName));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDocument {
|
||||
activeElement: FakeElement | null = null;
|
||||
createElement(tagName: string): FakeElement { return new FakeElement(this, tagName.toUpperCase()); }
|
||||
}
|
||||
|
||||
function setup(profile = createDefaultLocalProfile("member-22", "Avery")) {
|
||||
const document = new FakeDocument();
|
||||
const container = document.createElement("div");
|
||||
const previews: LocalProfile[] = [];
|
||||
const saves: LocalProfile[] = [];
|
||||
let cancels = 0;
|
||||
const editor = createProfileEditor({
|
||||
container: container as unknown as HTMLElement,
|
||||
profile,
|
||||
identityId: "member-22",
|
||||
onPreview: (next) => previews.push(next),
|
||||
onSave: (next) => saves.push(next),
|
||||
onCancel: () => cancels++,
|
||||
});
|
||||
return { document, container, editor, previews, saves, cancels: () => cancels };
|
||||
}
|
||||
|
||||
describe("profile editor DOM adapter", () => {
|
||||
it("builds a closed labelled dialog with every enumerated appearance choice", () => {
|
||||
const { container, editor } = setup();
|
||||
const root = editor.root as unknown as FakeElement;
|
||||
assert.equal(container.children.length, 1);
|
||||
assert.equal(root.hidden, true);
|
||||
assert.equal(root.getAttribute("role"), "dialog");
|
||||
assert.equal(root.getAttribute("aria-modal"), "true");
|
||||
assert.ok(root.getAttribute("aria-labelledby"));
|
||||
assert.equal(root.find("data-field", "displayName").getAttribute("aria-describedby"), root.descendants("p")[1]?.id);
|
||||
assert.equal(root.find("data-field", "skinTone").children.length, SKIN_TONES.length);
|
||||
assert.equal(root.find("data-field", "outfit").children.length, OUTFITS.length);
|
||||
assert.equal(root.find("data-field", "accent").children.length, ACCENTS.length);
|
||||
assert.equal(root.find("data-field", "hair").children.length, HAIR_COLORS.length);
|
||||
assert.equal(root.find("data-field", "bodyShape").children.length, BODY_SHAPES.length);
|
||||
assert.equal(editor.state().open, false);
|
||||
});
|
||||
|
||||
it("opens with focus, edits preview data, saves, and restores invoker focus", () => {
|
||||
const { document, editor, previews, saves } = setup();
|
||||
const trigger = document.createElement("button");
|
||||
trigger.focus();
|
||||
editor.open();
|
||||
const root = editor.root as unknown as FakeElement;
|
||||
const name = root.find("data-field", "displayName");
|
||||
assert.equal(document.activeElement, name);
|
||||
assert.equal(root.getAttribute("aria-hidden"), "false");
|
||||
|
||||
name.value = "Avery Chen";
|
||||
name.dispatch("input");
|
||||
const outfit = root.find("data-field", "outfit");
|
||||
outfit.value = "sage";
|
||||
outfit.dispatch("change");
|
||||
assert.equal(editor.state().draft.displayName, "Avery Chen");
|
||||
assert.equal(editor.state().draft.appearance.outfit, "sage");
|
||||
assert.equal(editor.state().dirty, true);
|
||||
assert.equal(previews.at(-1)?.appearance.outfit, "sage");
|
||||
|
||||
root.find("data-action", "save").dispatch("click");
|
||||
assert.equal(saves.length, 1);
|
||||
assert.equal(saves[0]?.displayName, "Avery Chen");
|
||||
assert.equal(editor.state().open, false);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
});
|
||||
|
||||
it("blocks invalid names and exposes accessible validation", () => {
|
||||
const { document, editor, saves } = setup();
|
||||
editor.open();
|
||||
const root = editor.root as unknown as FakeElement;
|
||||
const name = root.find("data-field", "displayName");
|
||||
const save = root.find("data-action", "save");
|
||||
name.value = " ";
|
||||
name.dispatch("input");
|
||||
assert.equal(editor.state().valid, false);
|
||||
assert.equal(name.getAttribute("aria-invalid"), "true");
|
||||
assert.equal(save.disabled, true);
|
||||
// Form submit covers Enter/assistive submit even though a disabled click cannot fire in a browser.
|
||||
root.descendants("form")[0]?.dispatch("submit");
|
||||
assert.equal(saves.length, 0);
|
||||
assert.equal(document.activeElement, name);
|
||||
});
|
||||
|
||||
it("resets appearance deterministically and Escape cancels the draft", () => {
|
||||
const profile = createDefaultLocalProfile("some-other-id", "River");
|
||||
const { editor, previews, cancels } = setup(profile);
|
||||
editor.open();
|
||||
const root = editor.root as unknown as FakeElement;
|
||||
const outfit = root.find("data-field", "outfit");
|
||||
outfit.value = outfit.value === "ink" ? "clay" : "ink";
|
||||
outfit.dispatch("change");
|
||||
const name = root.find("data-field", "displayName");
|
||||
name.value = "River Two";
|
||||
name.dispatch("input");
|
||||
root.find("data-action", "reset").dispatch("click");
|
||||
assert.deepEqual(editor.state().draft.appearance, createDefaultLocalProfile("member-22", "River").appearance);
|
||||
assert.equal(editor.state().draft.displayName, "River Two");
|
||||
|
||||
const event = root.dispatch("keydown", { key: "Escape" });
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.equal(cancels(), 1);
|
||||
assert.deepEqual(editor.state().draft, profile);
|
||||
assert.deepEqual(previews.at(-1), profile);
|
||||
});
|
||||
|
||||
it("traps keyboard focus and supports command-enter save", () => {
|
||||
const { document, editor, saves } = setup();
|
||||
editor.open();
|
||||
const root = editor.root as unknown as FakeElement;
|
||||
const first = root.find("data-field", "displayName");
|
||||
const last = root.find("data-action", "save");
|
||||
last.focus();
|
||||
assert.equal(root.dispatch("keydown", { key: "Tab" }).defaultPrevented, true);
|
||||
assert.equal(document.activeElement, first);
|
||||
first.focus();
|
||||
assert.equal(root.dispatch("keydown", { key: "Tab", shiftKey: true }).defaultPrevented, true);
|
||||
assert.equal(document.activeElement, last);
|
||||
assert.equal(root.dispatch("keydown", { key: "Enter", ctrlKey: true }).defaultPrevented, true);
|
||||
assert.equal(saves.length, 1);
|
||||
});
|
||||
|
||||
it("strictly updates caller data, then disposes without leaving DOM", () => {
|
||||
const { container, editor, previews } = setup();
|
||||
const next = createDefaultLocalProfile("new-person", "Morgan");
|
||||
const updated = editor.update(next);
|
||||
assert.deepEqual(updated.profile, next);
|
||||
assert.deepEqual(updated.draft, next);
|
||||
assert.deepEqual(previews.at(-1), next);
|
||||
assert.throws(() => editor.update({ ...next, displayName: "" }), RangeError);
|
||||
editor.open();
|
||||
editor.dispose();
|
||||
editor.dispose();
|
||||
assert.equal(container.children.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
createWebcamFaceConsent,
|
||||
createWebcamFaceTexture,
|
||||
type WebcamFaceTextureState,
|
||||
} from "../profile/index.ts";
|
||||
|
||||
function media() {
|
||||
let stops = 0;
|
||||
let pauses = 0;
|
||||
const track = {
|
||||
kind: "video",
|
||||
stop: () => { stops += 1; },
|
||||
} as unknown as MediaStreamTrack;
|
||||
const stream = {
|
||||
getVideoTracks: () => [track],
|
||||
getTracks: () => [track],
|
||||
} as unknown as MediaStream;
|
||||
const video = {
|
||||
srcObject: stream,
|
||||
pause: () => { pauses += 1; },
|
||||
} as unknown as HTMLVideoElement;
|
||||
return { stream, video, stops: () => stops, pauses: () => pauses };
|
||||
}
|
||||
|
||||
describe("ephemeral webcam face texture", () => {
|
||||
it("is off by default and refuses media before explicit active consent", () => {
|
||||
const consent = createWebcamFaceConsent();
|
||||
const adapter = createWebcamFaceTexture({ consent });
|
||||
const input = media();
|
||||
assert.deepEqual(adapter.state(), {
|
||||
status: "off",
|
||||
active: false,
|
||||
indicatorVisible: false,
|
||||
consentRevision: 0,
|
||||
ephemeral: true,
|
||||
persistence: "none",
|
||||
recording: false,
|
||||
uploading: false,
|
||||
});
|
||||
assert.throws(() => adapter.bind(input), /active consent/);
|
||||
assert.equal(adapter.texture(), null);
|
||||
adapter.dispose();
|
||||
});
|
||||
|
||||
it("accepts only caller-attached video after consent and owns only its texture", () => {
|
||||
const consent = createWebcamFaceConsent();
|
||||
consent.requestStart();
|
||||
consent.start(true);
|
||||
const input = media();
|
||||
const wrongVideo = { srcObject: null } as HTMLVideoElement;
|
||||
const adapter = createWebcamFaceTexture({ consent });
|
||||
assert.throws(() => adapter.bind({ stream: input.stream, video: wrongVideo }), /attach/);
|
||||
const texture = adapter.bind(input);
|
||||
let textureDisposals = 0;
|
||||
const dispose = texture.dispose.bind(texture);
|
||||
texture.dispose = () => { textureDisposals += 1; dispose(); };
|
||||
assert.equal(adapter.texture(), texture);
|
||||
assert.equal(adapter.state().active, true);
|
||||
adapter.clear();
|
||||
assert.equal(textureDisposals, 1);
|
||||
assert.equal(adapter.texture(), null);
|
||||
assert.equal(input.stops(), 0, "caller-owned track was stopped");
|
||||
assert.equal(input.pauses(), 0, "caller-owned video was paused");
|
||||
adapter.dispose();
|
||||
});
|
||||
|
||||
it("clears on stop/revoke and publishes defensive active-indicator transitions", () => {
|
||||
const consent = createWebcamFaceConsent();
|
||||
consent.requestStart();
|
||||
consent.start(true);
|
||||
const input = media();
|
||||
const indicators: WebcamFaceTextureState[] = [];
|
||||
const adapter = createWebcamFaceTexture({
|
||||
consent,
|
||||
onIndicatorChange: (state) => {
|
||||
state.status = "disposed";
|
||||
indicators.push({ ...state });
|
||||
},
|
||||
});
|
||||
adapter.bind(input);
|
||||
assert.equal(adapter.state().status, "active", "callback mutation escaped into adapter");
|
||||
consent.stop();
|
||||
assert.equal(adapter.sync().active, false);
|
||||
assert.equal(adapter.texture(), null);
|
||||
assert.equal(input.stops(), 0);
|
||||
assert.deepEqual(indicators.map((state) => state.indicatorVisible), [true, false]);
|
||||
adapter.dispose();
|
||||
});
|
||||
|
||||
it("is idempotent after disposal and rejects reuse", () => {
|
||||
const consent = createWebcamFaceConsent();
|
||||
consent.requestStart();
|
||||
consent.start(true);
|
||||
const input = media();
|
||||
const adapter = createWebcamFaceTexture({ consent });
|
||||
adapter.bind(input);
|
||||
adapter.dispose();
|
||||
adapter.dispose();
|
||||
assert.equal(adapter.state().status, "disposed");
|
||||
assert.equal(adapter.texture(), null);
|
||||
assert.throws(() => adapter.bind(input), /disposed/);
|
||||
assert.equal(input.stops(), 0);
|
||||
assert.equal(input.pauses(), 0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user