Skip to content

Commit 0df22d5

Browse files
committed
feat(player-surface-adapter): add player surface adapter and associated tests
1 parent 15e2945 commit 0df22d5

2 files changed

Lines changed: 351 additions & 0 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
export const X01_PLAYER_DISPLAY_ROOT_SELECTOR = "#ad-ext-player-display";
2+
export const X01_PLAYER_CARD_SELECTOR = ".ad-ext-player, [id^=\"ad-ext-player-\"]";
3+
export const X01_PLAYER_SCORE_SELECTOR = ".ad-ext-player-score";
4+
export const X01_PLAYER_NAME_SELECTOR = ".ad-ext-player-name";
5+
export const X01_PLAYER_SURFACE_SOURCE_TOOLS = "tools-for-autodarts";
6+
export const X01_PLAYER_SURFACE_SOURCE_NONE = "none";
7+
8+
const PLAYER_ID_PATTERN = /^ad-ext-player-\d+$/;
9+
10+
function normalizeText(value) {
11+
return String(value || "")
12+
.replaceAll("\u00a0", " ")
13+
.replaceAll(/\s+/g, " ")
14+
.trim();
15+
}
16+
17+
function queryOne(rootNode, selector) {
18+
if (!rootNode || typeof rootNode.querySelector !== "function") {
19+
return null;
20+
}
21+
22+
try {
23+
return rootNode.querySelector(selector);
24+
} catch (_) {
25+
return null;
26+
}
27+
}
28+
29+
function queryAll(rootNode, selector) {
30+
if (!rootNode || typeof rootNode.querySelectorAll !== "function") {
31+
return [];
32+
}
33+
34+
try {
35+
return Array.from(rootNode.querySelectorAll(selector));
36+
} catch (_) {
37+
return [];
38+
}
39+
}
40+
41+
function isElementNode(node) {
42+
return Boolean(node && Number(node.nodeType) === 1);
43+
}
44+
45+
function isPlayerCardNode(node, playerDisplayRoot = null) {
46+
if (!isElementNode(node) || node === playerDisplayRoot) {
47+
return false;
48+
}
49+
50+
return (
51+
node.classList?.contains?.("ad-ext-player") === true ||
52+
PLAYER_ID_PATTERN.test(String(node.id || "").trim())
53+
);
54+
}
55+
56+
function collectDescendantPlayerCards(rootNode) {
57+
const cards = [];
58+
const seen = new Set();
59+
const stack = Array.from(rootNode?.children || []);
60+
61+
while (stack.length) {
62+
const node = stack.shift();
63+
if (!node) {
64+
continue;
65+
}
66+
67+
if (isPlayerCardNode(node, rootNode) && !seen.has(node)) {
68+
seen.add(node);
69+
cards.push(node);
70+
}
71+
72+
stack.push(...Array.from(node.children || []));
73+
}
74+
75+
return cards;
76+
}
77+
78+
function getPlayerCards(playerDisplayRoot) {
79+
const seen = new Set();
80+
const cards = [];
81+
82+
queryAll(playerDisplayRoot, X01_PLAYER_CARD_SELECTOR).forEach((node) => {
83+
if (!isPlayerCardNode(node, playerDisplayRoot) || seen.has(node)) {
84+
return;
85+
}
86+
seen.add(node);
87+
cards.push(node);
88+
});
89+
90+
collectDescendantPlayerCards(playerDisplayRoot).forEach((node) => {
91+
if (!seen.has(node)) {
92+
seen.add(node);
93+
cards.push(node);
94+
}
95+
});
96+
97+
return cards.filter((node) => node?.isConnected !== false);
98+
}
99+
100+
function readPlayerId(node) {
101+
const id = String(node?.id || "").trim();
102+
return id || "";
103+
}
104+
105+
function readScopedText(node, selector) {
106+
return normalizeText(queryOne(node, selector)?.textContent || "");
107+
}
108+
109+
function isPlayerActive(node) {
110+
if (node?.classList?.contains?.("ad-ext-player-active")) {
111+
return true;
112+
}
113+
if (node?.classList?.contains?.("ad-ext-player-inactive")) {
114+
return false;
115+
}
116+
return false;
117+
}
118+
119+
function toPlayerEntry(node, index) {
120+
return {
121+
node,
122+
index,
123+
id: readPlayerId(node),
124+
nameText: readScopedText(node, X01_PLAYER_NAME_SELECTOR),
125+
scoreText: readScopedText(node, X01_PLAYER_SCORE_SELECTOR),
126+
isActive: isPlayerActive(node),
127+
};
128+
}
129+
130+
function createEmptySnapshot() {
131+
return {
132+
playerDisplayRoot: null,
133+
playerCards: [],
134+
players: [],
135+
source: X01_PLAYER_SURFACE_SOURCE_NONE,
136+
};
137+
}
138+
139+
export function getX01PlayerSurfaceSnapshot(documentRef) {
140+
const playerDisplayRoot = queryOne(documentRef, X01_PLAYER_DISPLAY_ROOT_SELECTOR);
141+
if (!playerDisplayRoot) {
142+
return createEmptySnapshot();
143+
}
144+
145+
const playerCards = getPlayerCards(playerDisplayRoot);
146+
return {
147+
playerDisplayRoot,
148+
playerCards,
149+
players: playerCards.map(toPlayerEntry),
150+
source: X01_PLAYER_SURFACE_SOURCE_TOOLS,
151+
};
152+
}
153+
154+
export function findX01PlayerSurface(documentRef) {
155+
return getX01PlayerSurfaceSnapshot(documentRef);
156+
}
157+
158+
export function createX01PlayerSurfaceObserveOptions() {
159+
return {
160+
childList: true,
161+
subtree: true,
162+
characterData: true,
163+
attributes: true,
164+
attributeFilter: ["class"],
165+
};
166+
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import {
5+
X01_PLAYER_CARD_SELECTOR,
6+
X01_PLAYER_DISPLAY_ROOT_SELECTOR,
7+
X01_PLAYER_NAME_SELECTOR,
8+
X01_PLAYER_SCORE_SELECTOR,
9+
createX01PlayerSurfaceObserveOptions,
10+
findX01PlayerSurface,
11+
getX01PlayerSurfaceSnapshot,
12+
} from "../../src/features/shared/x01-player-surface-adapter.js";
13+
import { FakeDocument } from "./fake-dom.js";
14+
15+
function removeDefaultPlayerNodes(documentRef) {
16+
documentRef.activePlayerRow.remove();
17+
documentRef.winnerNode.remove();
18+
}
19+
20+
function appendPlayerDisplayRoot(documentRef) {
21+
removeDefaultPlayerNodes(documentRef);
22+
const root = documentRef.createElement("div");
23+
root.id = "ad-ext-player-display";
24+
documentRef.main.appendChild(root);
25+
return root;
26+
}
27+
28+
function appendPlayerCard(documentRef, root, options = {}) {
29+
const wrapper = options.nested ? documentRef.createElement("div") : null;
30+
const card = documentRef.createElement("div");
31+
card.id = options.id || "";
32+
card.classList.add("ad-ext-player");
33+
34+
if (options.active === true) {
35+
card.classList.add("ad-ext-player-active");
36+
} else if (options.inactive === true) {
37+
card.classList.add("ad-ext-player-inactive");
38+
}
39+
40+
if (typeof options.scoreText !== "undefined") {
41+
const scoreNode = documentRef.createElement("p");
42+
scoreNode.classList.add("ad-ext-player-score");
43+
scoreNode.textContent = options.scoreText;
44+
card.appendChild(scoreNode);
45+
}
46+
47+
if (typeof options.nameText !== "undefined") {
48+
const nameNode = documentRef.createElement("span");
49+
nameNode.classList.add("ad-ext-player-name");
50+
nameNode.textContent = options.nameText;
51+
card.appendChild(nameNode);
52+
}
53+
54+
if (wrapper) {
55+
wrapper.appendChild(card);
56+
root.appendChild(wrapper);
57+
} else {
58+
root.appendChild(card);
59+
}
60+
61+
return card;
62+
}
63+
64+
test("x01 player surface adapter exports the external baseline selectors", () => {
65+
assert.equal(X01_PLAYER_DISPLAY_ROOT_SELECTOR, "#ad-ext-player-display");
66+
assert.equal(X01_PLAYER_CARD_SELECTOR, ".ad-ext-player, [id^=\"ad-ext-player-\"]");
67+
assert.equal(X01_PLAYER_SCORE_SELECTOR, ".ad-ext-player-score");
68+
assert.equal(X01_PLAYER_NAME_SELECTOR, ".ad-ext-player-name");
69+
});
70+
71+
test("x01 player surface adapter returns an empty snapshot for invalid document input", () => {
72+
assert.deepEqual(getX01PlayerSurfaceSnapshot(null), {
73+
playerDisplayRoot: null,
74+
playerCards: [],
75+
players: [],
76+
source: "none",
77+
});
78+
assert.deepEqual(findX01PlayerSurface({}), {
79+
playerDisplayRoot: null,
80+
playerCards: [],
81+
players: [],
82+
source: "none",
83+
});
84+
});
85+
86+
test("x01 player surface adapter returns source none when player display root is absent", () => {
87+
const documentRef = new FakeDocument();
88+
89+
const snapshot = getX01PlayerSurfaceSnapshot(documentRef);
90+
91+
assert.equal(snapshot.playerDisplayRoot, null);
92+
assert.deepEqual(snapshot.playerCards, []);
93+
assert.deepEqual(snapshot.players, []);
94+
assert.equal(snapshot.source, "none");
95+
});
96+
97+
test("x01 player surface adapter reads two players with names and scores", () => {
98+
const documentRef = new FakeDocument();
99+
const root = appendPlayerDisplayRoot(documentRef);
100+
const firstCard = appendPlayerCard(documentRef, root, {
101+
id: "ad-ext-player-0",
102+
nameText: "TORNADO TOM",
103+
scoreText: "423",
104+
active: true,
105+
});
106+
const secondCard = appendPlayerCard(documentRef, root, {
107+
id: "ad-ext-player-1",
108+
nameText: "TEST2",
109+
scoreText: "501",
110+
inactive: true,
111+
});
112+
113+
const snapshot = getX01PlayerSurfaceSnapshot(documentRef);
114+
115+
assert.equal(snapshot.playerDisplayRoot, root);
116+
assert.deepEqual(snapshot.playerCards, [firstCard, secondCard]);
117+
assert.equal(snapshot.source, "tools-for-autodarts");
118+
assert.deepEqual(
119+
snapshot.players.map((player) => ({
120+
index: player.index,
121+
id: player.id,
122+
nameText: player.nameText,
123+
scoreText: player.scoreText,
124+
isActive: player.isActive,
125+
})),
126+
[
127+
{
128+
index: 0,
129+
id: "ad-ext-player-0",
130+
nameText: "TORNADO TOM",
131+
scoreText: "423",
132+
isActive: true,
133+
},
134+
{
135+
index: 1,
136+
id: "ad-ext-player-1",
137+
nameText: "TEST2",
138+
scoreText: "501",
139+
isActive: false,
140+
},
141+
]
142+
);
143+
});
144+
145+
test("x01 player surface adapter handles missing score and name nodes defensively", () => {
146+
const documentRef = new FakeDocument();
147+
const root = appendPlayerDisplayRoot(documentRef);
148+
appendPlayerCard(documentRef, root, {
149+
id: "ad-ext-player-0",
150+
});
151+
152+
const snapshot = getX01PlayerSurfaceSnapshot(documentRef);
153+
154+
assert.equal(snapshot.players.length, 1);
155+
assert.equal(snapshot.players[0].nameText, "");
156+
assert.equal(snapshot.players[0].scoreText, "");
157+
assert.equal(snapshot.players[0].isActive, false);
158+
});
159+
160+
test("x01 player surface adapter collects nested player cards safely", () => {
161+
const documentRef = new FakeDocument();
162+
const root = appendPlayerDisplayRoot(documentRef);
163+
const nestedCard = appendPlayerCard(documentRef, root, {
164+
id: "ad-ext-player-0",
165+
nameText: "Nested",
166+
scoreText: "170",
167+
nested: true,
168+
});
169+
170+
const snapshot = getX01PlayerSurfaceSnapshot(documentRef);
171+
172+
assert.deepEqual(snapshot.playerCards, [nestedCard]);
173+
assert.equal(snapshot.players[0].nameText, "Nested");
174+
assert.equal(snapshot.players[0].scoreText, "170");
175+
});
176+
177+
test("x01 player surface observe options stay scoped to class changes", () => {
178+
assert.deepEqual(createX01PlayerSurfaceObserveOptions(), {
179+
childList: true,
180+
subtree: true,
181+
characterData: true,
182+
attributes: true,
183+
attributeFilter: ["class"],
184+
});
185+
});

0 commit comments

Comments
 (0)