/** @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); }); });