/** @vitest-environment jsdom */
import * as React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createScumMapFixture, scumMapContext, scumMapPage } from "../acceptance/fixtures/scum-map";
import { loadPluginPageBundle } from "./pluginPageBundles";
let root: Root;
let container: HTMLDivElement;
const MapPage = await loadPluginPageBundle(scumMapPage);
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-19T00:10:00Z"));
globalThis.__PLUGIN_PAGE_REACT__ = React;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.useRealTimers();
vi.restoreAllMocks();
});
async function mount(fixture = createScumMapFixture(), context = scumMapContext) {
const loadSurface = vi.spyOn(fixture.actions.scum!, "surface");
const list = vi.spyOn(fixture.actions.pluginData!, "list");
await act(async () => root.render());
return { ...fixture, loadSurface, list };
}
function button(label: string) {
const result = [...container.querySelectorAll("button")].find((node) => node.getAttribute("aria-label") === label || node.textContent === label);
expect(result, `button ${label}`).toBeDefined();
return result!;
}
async function click(node: HTMLElement) { await act(async () => node.click()); }
async function tick(ms: number) { await act(async () => vi.advanceTimersByTime(ms)); }
function zoom() { return Number(container.querySelector(".map-zoom-badge")?.textContent?.replace("×", "")); }
describe("SCUM map through the platform bundle host", () => {
it("starts from the first sample, follows one player, preserves paused markers, and resets for a new selection", async () => {
const { loadSurface, list } = await mount();
await click(button("播放轨迹回放"));
expect(zoom()).toBe(8);
expect(container.querySelectorAll(".map-track-live")).toHaveLength(1);
expect(container.querySelector(".map-track-live-riding")).toBeNull();
const initialTransform = container.querySelector(".map-scene")?.getAttribute("style");
await tick(6000);
expect(container.querySelector(".map-scene")?.getAttribute("style")).not.toBe(initialTransform);
await click(button("暂停轨迹回放"));
const marker = container.querySelector(".map-track-live")!;
expect(marker.classList.contains("is-active")).toBe(true);
await act(async () => marker.focus());
expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("Mira");
expect(container.querySelector('[role="tooltip"]')?.textContent).not.toContain("乘坐Laika");
await click(marker);
expect(container.querySelector(".map-point-strip")?.textContent).toContain("Mira");
await click(button("全选"));
expect(container.querySelector(".map-playback-time")?.textContent).toContain("选择用户与时间后开始回放");
await click(button("播放轨迹回放"));
expect(container.querySelectorAll(".map-track-live")).toHaveLength(2);
expect(zoom()).toBe(8);
expect(loadSurface).toHaveBeenCalledTimes(1);
expect(list).toHaveBeenCalledTimes(7);
});
it("fits moving users together and changes ride icons at the recorded sample time", async () => {
await mount();
await click(button("全选"));
await click(button("播放轨迹回放"));
await tick(12000);
expect(zoom()).toBeLessThan(8);
expect(container.querySelector(".map-track-live-riding")).toBeNull();
await tick(4000);
const riding = container.querySelector(".map-track-live-riding")!;
expect(riding.getAttribute("aria-label")).toContain("Laika");
expect(riding.querySelector("img")?.getAttribute("src")).toContain("ico_");
await click(button("暂停轨迹回放"));
await act(async () => riding.focus());
expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("乘坐Laika");
const start = container.querySelector('[aria-label="轨迹开始时间"]')!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(start, "2026-09-19T00:04");
start.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(container.querySelector(".map-playback-time")?.textContent).toContain("选择用户与时间后开始回放");
const end = container.querySelector('[aria-label="轨迹结束时间"]')!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(end, "2026-09-18T00:00");
end.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(container.querySelectorAll(".map-track-live")).toHaveLength(0);
expect(container.querySelectorAll(".map-layer-players")).toHaveLength(0);
expect(button("播放轨迹回放").disabled).toBe(true);
});
it("shows player, vehicle lock and flag owner details without extra reads", async () => {
const { loadSurface, list } = await mount();
await act(async () => button("Laika").focus());
expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("Mira 上锁");
expect(container.querySelector('[role="tooltip"]')?.textContent).toContain("耐久未同步");
await act(async () => button("Wolves Flag").focus());
const tooltip = container.querySelector('[role="tooltip"]')?.textContent;
expect(tooltip).toContain("归属Mira(mira)");
expect(tooltip).toContain("Wolves(wolves · 2 人)");
expect(loadSurface).toHaveBeenCalledTimes(1);
expect(list).toHaveBeenCalledTimes(7);
});
it("excludes unselected and out-of-window players, including stale retained current positions", async () => {
const fixture = createScumMapFixture();
fixture.surface.users.items.push({ id: "old", serverInstanceId: "map-test", steamId: "old", displayName: "Old Player", online: false, x: 100, y: 100, lastSeenAt: "2026-09-18T00:00:00Z" });
await mount(fixture);
const labels = () => [...container.querySelectorAll(".map-track-live-label")].map((node) => node.textContent);
expect(labels()).toEqual(["Mira"]);
expect(container.querySelector('[aria-label="SCUM 地图视图"]')?.textContent).not.toContain("Nova");
await click(button("清空"));
expect(labels()).toEqual([]);
expect(container.querySelectorAll(".map-layer-players")).toHaveLength(0);
expect(button("播放轨迹回放").disabled).toBe(true);
await click(button("时段内全部"));
expect(labels().sort()).toEqual(["Mira", "Nova"]);
expect(container.querySelector('[aria-label="SCUM 地图视图"]')?.textContent).not.toContain("Old Player");
});
it("draws the travelled path up to the moving marker and keeps the final frame without duplicate endpoints", async () => {
await mount();
const start = button("播放轨迹回放");
expect(start.closest("details")).toBeNull();
await click(start);
await tick(6000);
const polyline = container.querySelector(".map-track-layer polyline")!;
const last = polyline.getAttribute("points")!.split(" ").at(-1)!.split(",").map(Number);
const marker = container.querySelector(".map-track-live")!;
expect(last[0]).toBeCloseTo(parseFloat(marker.style.left) * 40.96, 0);
expect(last[1]).toBeCloseTo(parseFloat(marker.style.top) * 40.96, 0);
expect(polyline.getAttribute("vector-effect")).toBe("non-scaling-stroke");
await tick(24100);
expect(container.querySelector(".map-playback-time")?.textContent).toContain("回放结束");
expect(container.querySelectorAll(".map-track-live")).toHaveLength(1);
expect(container.querySelectorAll(".map-track-endpoint")).toHaveLength(0);
expect(container.querySelector("progress")?.value).toBe(1);
});
it("reuses marker nodes and geometry during zoom without reparsing the loaded trajectory history", async () => {
const fixture = createScumMapFixture();
const sample = fixture.surface.userTrajectories.items[0];
fixture.surface.userTrajectories.items = Array.from({ length: 1000 }, (_, index) => ({ ...sample, id: `sample-${index}`, x: index * 5, sampledAt: new Date(Date.parse("2026-09-19T00:00:00Z") + index * 500).toISOString() }));
await mount(fixture);
const marker = container.querySelector(".map-track-live")!;
await click(marker);
const line = container.querySelector(".map-track-layer polyline");
const parse = vi.spyOn(Date, "parse");
await click(button("地图缩小"));
expect(container.querySelector(".map-track-live")).toBe(marker);
expect(container.querySelector(".map-track-layer polyline")).toBe(line);
// Only small control/detail labels may parse times; all 1,000 trajectory rows stay cached.
expect(parse.mock.calls.length).toBeLessThan(30);
expect(zoom()).toBeLessThan(8);
expect(line?.getAttribute("points")?.split(" ")).toHaveLength(1000);
});
it("keeps manual zoom during playback and loads only activity in a non-focused time window", async () => {
await mount(createScumMapFixture(), { ...scumMapContext, pageQuery: { from: scumMapContext.pageQuery.from, to: scumMapContext.pageQuery.to } });
expect(container.querySelectorAll(".map-track-live")).toHaveLength(2);
await click(button("播放轨迹回放"));
await click(button("地图缩小"));
const manualZoom = zoom();
await tick(1000);
expect(zoom()).toBe(manualZoom);
});
});