feat: add character studio and aircraft foundation
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user