Files
browser/platform_web/utils/scumMapPlayback.test.tsx
T

181 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** @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(<MapPage context={context} workspaceActions={fixture.actions} availability={{ available: true }} />));
return { ...fixture, loadSurface, list };
}
function button(label: string) {
const result = [...container.querySelectorAll<HTMLButtonElement>("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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLInputElement>('[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<HTMLInputElement>('[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("归属Miramira");
expect(tooltip).toContain("Wolveswolves · 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<HTMLElement>(".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<HTMLProgressElement>("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<HTMLElement>(".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);
});
});