264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
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("includes visible contextual controls in its focus trap and skips hidden ones", () => {
|
|
const { document, editor } = setup();
|
|
const cameraStart = document.createElement("button");
|
|
const cameraStop = document.createElement("button");
|
|
cameraStop.hidden = true;
|
|
editor.registerFocusables([
|
|
cameraStart as unknown as HTMLElement,
|
|
cameraStop as unknown as HTMLElement,
|
|
]);
|
|
editor.open();
|
|
const root = editor.root as unknown as FakeElement;
|
|
const first = root.find("data-field", "displayName");
|
|
cameraStart.focus();
|
|
assert.equal(root.dispatch("keydown", { key: "Tab" }).defaultPrevented, true);
|
|
assert.equal(document.activeElement, first);
|
|
cameraStart.hidden = true;
|
|
const save = root.find("data-action", "save");
|
|
save.focus();
|
|
assert.equal(root.dispatch("keydown", { key: "Tab" }).defaultPrevented, true);
|
|
assert.equal(document.activeElement, first);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|