fix: improve SCUM map playback filters
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/** @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("归属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<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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user