1
0

feat: add character studio and aircraft foundation

This commit is contained in:
2026-08-11 19:32:32 -07:00
parent 1c37ef8f3e
commit c0c7fcf974
16 changed files with 2250 additions and 25 deletions
+241
View File
@@ -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);
});
});