Files
browser/plugins/tests/scum-feature-module.test.ts
T
npc0-hue a822e9250a Store SCUM facts in typed platform tables
Run pushes typed SCUM facts through POST /api/v1/run/scum/facts, but the
production router rejected that path before the signature middleware because
runServiceRequest listed individual Run channel prefixes, and MySQL kept SCUM
rows inside the whole metadata snapshot instead of platform tables.

- /api/v1/run/ is now the signed machine channel space while
  /api/v1/run/endpoints keeps normal bearer/admin authorization.
- MySQL gets real scum_user, scum_user_trajectory, scum_vehicle,
  scum_vehicle_trajectory and scum_vehicle_lock tables with parameterized
  per-row repositories instead of full snapshot rewrites. Snapshot-shaped
  tables from the unreleased interim build are replaced, and SCUM rows still
  inside a metadata snapshot are migrated once.
- Facts ingest verifies the target server plugin type and converges stale
  online users to offline after SCUMUserOfflineAfter.
- The plugin page and browser read one bounded /scum/surface response instead
  of five list calls per refresh.

Call-count budget for one server: per 5s facts batch, one SELECT plus one
INSERT/UPDATE per reported user and vehicle, one INSERT per moved trajectory
sample or new lock row, one bounded stale-user SELECT, and a trajectory
retention DELETE at most once per hour. One browser refresh issues one surface
request every 15s instead of five list requests.
2026-09-15 12:12:11 +08:00

386 lines
33 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.
import { describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js";
import { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, parseGiftCommands, parseGiftItems, queueGiftDelivery, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js";
import { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js";
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js";
import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js";
import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js";
const featureRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features");
const pageSource = readFileSync(resolve(featureRoot, "page.ts"), "utf8");
const dataClientSource = readFileSync(resolve(featureRoot, "page-data.ts"), "utf8");
const surfaceData: SCUMSurfaceData = {
players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", squadId: "squad-1", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, lastLoginIp: "203.0.113.7", position: { x: 10, y: 20, z: 3 }, freshness: { status: "fresh" } }],
squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 1, memberLimit: 12, leaderProfileId: "profile-1", score: 88, message: "Hold the north", freshness: { status: "fresh" } }],
members: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", displayName: "Mira", squadId: "squad-1", rank: "Leader", score: 42, lastLoginAt: "2026-08-10T00:00:00Z", freshness: { status: "fresh" } }],
events: [{ id: "event-1", name: "Friday Range", eventType: "range", class: 1, corn: "0 20 * * 5", rconCommand: "#start_event event-1", placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2, status: "enabled" }],
eventProduces: [{ _recordKey: "event-1:produce-1", id: "produce-1", eventId: "event-1", tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }],
eventRuns: [{ id: "run-1", eventId: "event-1", status: "running", startedAt: "2026-08-10T00:00:00Z", summary: "Round 1" }],
nativeEventRounds: [{ eventRecordId: "native-1", eventId: "native-event", state: "active", startTime: "2026-08-10T00:00:00Z", enemyKills: 2 }],
tasks: [{ taskRecordId: "task-1", taskKind: "active-task", state: "active", userProfileId: "profile-1" }],
activityEvents: [{ id: "activity-1", type: "reward", subjectName: "Mira", status: "delivered", occurredAt: "2026-08-10T00:02:00Z" }],
tradeGoods: [
{ code: "goods-1", name: "Cargo Drop", catalogType: "item", lastSeenAt: "2026-08-10T00:01:00Z" },
{ code: "#spawnvehicle BPC_Laika_C", className: "BPC_Laika_C", catalogType: "vehicle", type: "21", typeName: "其他载具", imagePath: "/original/BPC_Laika_C.webp", lastSeenAt: "2026-08-10T00:00:03Z" }
],
tradeEvents: [{ steamId: "76561198000000001", itemCode: "goods-1", tradeVerb: "purchased", quantity: "2", price: "120", observedAt: "2026-08-10T00:01:00Z" }],
gifts: [{ code: "starter-pack", name: "Starter Pack", class: 5, audience: "all", number: 1, achievement: 2, achievementNumber: 10, status: "active", items: [{ catalogCode: "BP_Cash_01", quantity: 2 }], commands: [{ command: "#announce Starter pack" }] }],
giftClaims: [{ id: "claim-1", playerId: "steam-1", giftCode: "starter-pack", status: "claimed", claimedAt: "2026-08-10T00:03:00Z" }],
pendingGifts: [{ id: "pending-1", playerId: "steam-1", giftCode: "starter-pack", status: "pending", createdAt: "2026-08-10T00:03:30Z" }],
giftDeliveries: [{ id: "delivery-1", playerId: "steam-1", giftCode: "starter-pack", status: "delivered", deliveredAt: "2026-08-10T00:04:00Z" }],
timedGiftEvents: [{ timedGiftId: "timed-1", userProfileId: "profile-1", mapId: "map-1", spawnTime: 1, spawnAt: "2026-08-10T00:05:00Z" }],
mapPoints: [{ id: "poi-1", name: "Airfield", layer: "other", x: 800, y: 900, z: 10, source: "plugin-map" }],
mapRegions: [{ id: "region-1", name: "Safe Zone", x: 500, y: 600, z: 0, source: "server-config" }],
mapSettings: [],
vehicles: [{ id: "scum-vehicle-1", vehicleId: "scum-vehicle-1", gameVehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", locked: true, position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }],
vehicleLocks: [{ id: "lock-1", scumVehicleId: "scum-vehicle-1", gameVehicleId: "veh-1", scumUserId: "scum-user-1", steamId: "76561198000000001", lockedAt: "2026-08-10T00:00:04Z", source: "platform.scum_vehicle_lock" }],
trajectories: [
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, sampledAt: "2026-08-10T00:00:03Z", source: "platform.scum_user_trajectory" },
{ subjectType: "vehicle", subjectId: "veh-1", vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", x: 400, y: 200, z: 0, sampledAt: "2026-08-10T00:00:03Z", source: "platform.scum_vehicle_trajectory" }
],
flags: [{ flagId: "flag-1", name: "Wolves Flag", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { x: 100, y: 80, z: 0 }, freshness: { status: "fresh" } }]
};
describe("SCUM plugin feature module", () => {
it("owns runtime catalogs without a version gate", () => {
expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message");
expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围");
expect(validateStatePatch([{ fieldKey: "skills.running", before: 1, after: 2 }])).toBeNull();
expect(validateStatePatch([{ fieldKey: "unknown", before: 1, after: 2 }])).toBeNull();
expect(vehicleSpawnCatalog.map((vehicle) => vehicle.code)).toEqual(["BPC_Laika_C", "BPC_WolfsWagen_C"]);
expect(validateVehicleSpawn({ vehicleCode: "BPC_Laika_C" })).toBeNull();
expect(validateVehicleSpawn({ vehicleCode: "#spawnvehicle BPC_Laika_C" })).toContain("格式无效");
expect(validateVehicleSpawn({ vehicleCode: "BPC_Unknown_C" })).toContain("插件目录");
});
it("maps transitional records only as read-only provenance", () => {
expect(migratePlayerRecord({ id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" })).toMatchObject({ provenance: "transitional-read-only", readOnly: true, payload: { gamePlayerId: "steam-1" } });
expect(migrateTrajectoryRecord({ playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ provenance: "transitional-read-only", points: [{ x: 10, y: 20 }] });
});
it("preserves only declared transitional history for every feature area", () => {
expect(migrateConfigurationRecord({ id: "cfg-1", version: "0.9.700.90357", fields: { MaxPlayers: 64 }, observedAt: "2026-07-29T00:00:00Z", hostPath: "C:/secret" })).toMatchObject({ readOnly: true, payload: { fields: { MaxPlayers: "64" } } });
expect(migratePlayerProfileRecord({ player: { id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" }, sessions: [{ id: "s-1", gamePlayerRecordId: "p-1", startedAt: "2026-07-29T00:00:00Z", networkFingerprint: "never-copy" }], accessAttempts: [{ occurredAt: "2026-07-29T00:01:00Z", outcome: "review", reason: "manual" }] })).toMatchObject({ payload: { sessions: [{ kind: "login" }], risks: [{ summary: "manual" }] } });
expect(migrateGiftGrantRecord({ id: "gift-1", revisionId: "r-1", gamePlayerRecordId: "p-1", status: "unknown", createdAt: "2026-07-29T00:00:00Z" })).toMatchObject({ payload: { status: "unknown" }, readOnly: true });
expect(migrateStatePatchRecord({ id: "patch-1", gamePlayerRecordId: "p-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", status: "confirmed", createdAt: "2026-07-29T00:00:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] })).toMatchObject({ payload: { status: "succeeded" }, readOnly: true });
expect(migrateTrajectoryHistoryRecord({ id: "track-1", playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ sourceRecordId: "track-1", readOnly: true });
});
it("matches transitional fixtures without carrying sensitive fields into plugin history", () => {
expect(migrateConfigurationRecord(scumMigrationParityFixtures.configuration.source)).toEqual(scumMigrationParityFixtures.configuration.expected);
expect(migratePlayerProfileRecord(scumMigrationParityFixtures.playerHistory.source)).toEqual(scumMigrationParityFixtures.playerHistory.expected);
expect(migrateGiftGrantRecord(scumMigrationParityFixtures.gift.source)).toEqual(scumMigrationParityFixtures.gift.expected);
expect(migrateStatePatchRecord(scumMigrationParityFixtures.statePatch.source)).toEqual(scumMigrationParityFixtures.statePatch.expected);
expect(migrateTrajectoryHistoryRecord(scumMigrationParityFixtures.trajectory.source)).toEqual(scumMigrationParityFixtures.trajectory.expected);
expect(migrateConfigurationRecord({ version: "0.9.700.90357", observedAt: "2026-07-29T00:00:00Z", fields: { hostPath: "/srv/scum" } })).toBeNull();
});
it("enables plugin authority only for one exact server-feature flag", () => {
const flags = [{ serverInstanceId: "server-1", feature: "configuration" as const, authority: "plugin" as const }];
expect(migrationStatus(flags, "server-1", "configuration")).toMatchObject({ authority: "plugin", pluginWritesEnabled: true, readOnlyHistory: true });
expect(migrationStatus(flags, "server-2", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
});
it("loads page data through plugin collections plus platform SCUM tables", async () => {
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
const scum = scumActions();
const data = await loadSCUMSurface({ pluginData: pluginDataActions({ list }), scum }, "gifts");
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.gifts, scumCollections.giftClaims, scumCollections.pendingGifts, scumCollections.giftDeliveries, scumCollections.timedGiftEvents, scumCollections.tradeGoods]);
expect(scum.surface).toHaveBeenCalledTimes(1);
expect(data.gifts[0]).toMatchObject({ collection: scumCollections.gifts, _recordKey: `${scumCollections.gifts}-1` });
});
it("reads player records from platform scum_user table only", async () => {
const pluginData = pluginDataActions();
const scum = scumActions({ surface: async () => ({ users: { items: [{ gamePlayerId: "steam-1", steamId: "steam-1", displayName: "Mira", online: false, source: "platform.scum_user" }], count: 1 }, vehicles: { items: [], count: 0 }, userTrajectories: { items: [], count: 0 }, vehicleTrajectories: { items: [], count: 0 }, vehicleLocks: { items: [], count: 0 } }) });
const data = await loadSCUMSurface({ pluginData, scum }, "players");
expect(pluginData.list).not.toHaveBeenCalledWith("scum_users");
expect(scum.surface).toHaveBeenCalledTimes(1);
expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", displayName: "Mira", online: false });
expect(dataClientSource).not.toContain("scum-client-manager");
});
it("does not queue SCUM database reads from the plugin page", async () => {
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
const scum = scumActions();
await expect(loadSCUMSurface({ pluginData: pluginDataActions(), scum, dispatch }, "live-map")).resolves.toMatchObject({ players: expect.any(Array), vehicles: expect.any(Array), trajectories: expect.any(Array) });
expect(dispatch).not.toHaveBeenCalled();
expect(scum.surface).toHaveBeenCalledTimes(1);
expect(dataClientSource).not.toContain("remote.run.db.sqlite");
expect(dataClientSource).not.toContain("input.templateKey");
expect(dataClientSource).not.toContain("projectSCUMLoginLogs");
expect(dataClientSource).not.toContain("logs.query");
});
it("loads live-map page data without page-triggered SCUM database dispatch", async () => {
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async () => ({ status: "queued" }));
await expect(loadSCUMSurface({ pluginData: pluginDataActions({ list }), scum: scumActions(), dispatch }, "live-map")).resolves.toMatchObject({ players: expect.any(Array), vehicles: expect.any(Array) });
expect(dispatch).not.toHaveBeenCalled();
});
it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => {
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
await loadSCUMSurface({ pluginData: pluginDataActions({ list }), scum: scumActions() }, "workflows");
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents, scumCollections.tradeGoods, scumCollections.tradeEvents]);
await loadSCUMSurface({ pluginData: pluginDataActions({ list }), scum: scumActions() }, "activity");
expect(list.mock.calls.slice(-8).map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents, scumCollections.tradeGoods, scumCollections.tradeEvents]);
});
it("uses transaction, put, and delete for plugin-owned gift data", async () => {
const pluginData = pluginDataActions();
const actions = { pluginData };
expect(parseGiftItems("BP_Cash_01:2, Water-Bottle.01:1")).toEqual([{ catalogCode: "BP_Cash_01", quantity: 2 }, { catalogCode: "Water-Bottle.01", quantity: 1 }]);
expect(parseGiftCommands("#announce Hello\n#spawnitem BP_Cash_01 2")).toEqual([{ command: "#announce Hello" }, { command: "#spawnitem BP_Cash_01 2" }]);
expect(() => parseGiftItems("cash:0")).toThrow("格式无效");
expect(parseGiftItems("a:101,b:1,c:1,d:1,e:1,f:1,g:1,h:1,i:1")).toHaveLength(9);
await saveGiftDefinition(actions, { code: "starter", name: "Starter", items: [] });
await createGiftDelivery(actions, { id: "delivery-1", giftCode: "starter", playerId: "steam-1" });
await deleteGiftDefinition(actions, "starter");
expect(pluginData.transact).toHaveBeenCalledWith(scumCollections.gifts, [{ operation: "put", key: "starter", value: { code: "starter", name: "Starter", items: [] } }]);
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.giftDeliveries, "delivery-1", expect.objectContaining({ giftCode: "starter", playerId: "steam-1" }));
expect(pluginData.delete).toHaveBeenCalledWith(scumCollections.gifts, "starter");
});
it("persists event produces, event runs, and gift resets in plugin-owned collections", async () => {
const pluginData = pluginDataActions();
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
const actions: SCUMWorkspaceActions = { pluginData, dispatch };
await saveEventProduce(actions, { id: "produce-1", eventId: "event-1", tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 });
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.eventProduces, "event-1:produce-1", expect.objectContaining({ eventId: "event-1", tradeGoodsId: "goods-1" }));
await startEvent(actions, { ...surfaceData.events[0], rconCommand: "#start_event event-1" }, surfaceData.eventProduces);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ action: "remote.access.request", payload: expect.objectContaining({
capability: "remote.run.rcon.command", "input.command": "#start_event event-1"
}) }));
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.eventRuns, expect.any(String), expect.objectContaining({ eventId: "event-1", status: "queued", produces: surfaceData.eventProduces }));
await resetGiftClaim(actions, { _recordKey: "claim-1" });
await resetPendingGift(actions, { _recordKey: "pending-1", status: "received", receivedAt: "now" });
expect(pluginData.delete).toHaveBeenCalledWith(scumCollections.giftClaims, "claim-1");
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.pendingGifts, "pending-1", expect.objectContaining({ status: "pending", receivedAt: null }));
});
it("queues gift and event commands through platform-mediated SCUM RCON", async () => {
const pluginData = pluginDataActions();
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
const actions: SCUMWorkspaceActions = { pluginData, dispatch };
await queueGiftDelivery(actions, { ...surfaceData.gifts[0], operations: ["#announce Starter pack", "#SetFamePoints 250"] }, surfaceData.players[0]);
expect(dispatch.mock.calls.map(([request]) => request.payload?.["input.command"])).toEqual(["#SpawnItem BP_Cash_01 2", "#announce Starter pack", "#SetFamePoints 250"]);
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.giftDeliveries, expect.any(String), expect.objectContaining({ giftCode: "starter-pack", playerId: "steam-1", status: "queued" }));
await startEvent(actions, { ...surfaceData.events[0], rconCommand: "#start_event event-1" }, surfaceData.eventProduces);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: expect.objectContaining({ "input.command": "#start_event event-1" }) }));
});
it("defaults activity class to range and strips collection metadata from queued produces", async () => {
const pluginData = pluginDataActions();
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
await startEvent({ pluginData, dispatch }, { id: "event-default", name: "Default Event", rconCommand: "#start_event event-default" }, [{
_recordKey: "event-default:produce-1", id: "produce-1", eventId: "event-default", updatedAt: "2026-08-10T00:00:00Z",
tradeGoodsId: "cargo-drop", percent: 80, value: 2, r: 500, x: 1000, y: 2000, z: 300
}]);
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ payload: expect.objectContaining({ "input.command": "#start_event event-default" }) }));
});
it("keeps positive event duration and counts above the removed arbitrary limits", async () => {
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
await startEvent({ pluginData: pluginDataActions(), dispatch }, { id: "event-large", name: "Large Event", rconCommand: "#start_event event-large", durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004 }, [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }]);
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ payload: expect.objectContaining({ "input.command": "#start_event event-large" }) }));
});
it("renders searchable user management from real collection values", () => {
const view = renderAndCollect();
expect(view.nodes).toContain("section:用户管理");
expect(view.texts).not.toContain("SCUM 用户真实记录");
expect(view.texts).toEqual(expect.arrayContaining(["在线用户 ", "1 / 1"]));
expect(view.texts).toEqual(expect.arrayContaining(["用户名", "Steam ID", "渣币", "登录 IP", "队伍", "状态", "最后活动", "操作"]));
expect(view.texts).not.toContain("筛选结果");
expect(view.texts).not.toContain("通用数据/机器动作可用");
expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining([`同步 ${"SCUM"}.db`, "重新读取", "编辑属性"]));
expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["礼包状态", "他的物品", "登录历史", "用户轨迹"]));
expect(view.inputs.map((input) => input.label)).toContain("搜索用户");
expect(pageSource).toContain("console-panel scum-workbench");
expect(pageSource).toContain("resource-filter-bar scum-filter-bar");
expect(pageSource).toContain("provider-table-wrap scum-table-wrap");
expect(view.texts).toContain("Mira");
expect(view.texts).toEqual(expect.arrayContaining(["76561198000000001", "1000", "203.0.113.7"]));
expect(view.texts).not.toEqual(expect.arrayContaining(["UUID / Steam", "Gold 3"]));
const ipSearch = renderAndCollect({ playerSearch: "203.0.113.7" });
expect(ipSearch.texts).toContain("Mira");
const uuidSearch = renderAndCollect({ playerSearch: "profile-1" });
expect(uuidSearch.texts).toContain("Mira");
});
it("expires online users after five minutes without activity", () => {
const staleAt = new Date(Date.now() - 5 * 60 * 1000 - 1).toISOString();
const view = renderAndCollect({ data: { ...surfaceData, players: [{ ...surfaceData.players[0], lastSeenAt: staleAt }] } });
expect(view.texts).toEqual(expect.arrayContaining(["在线用户 ", "0 / 1"]));
expect(view.texts).toContain("离线/未知");
});
it("keeps user management off SCUM database SQL execution paths", () => {
const source = `${pageSource}\n${dataClientSource}`;
expect(source).toContain("平台 scum_user_trajectory 表");
expect(source).toContain("平台 SCUM 用户与载具表");
expect(source).not.toContain("remote.run.db.sqlite.execute");
expect(source).not.toContain("scum-database");
expect(source).not.toContain("input.sqlText");
});
it("does not invent users when the collection is empty", () => {
const view = renderAndCollect({ data: { ...surfaceData, players: [] } });
expect(view.texts.join("\n")).toContain("没有符合筛选条件的真实用户记录");
expect(view.texts).not.toContain("Mira");
expect(pageSource).not.toContain("samplePlayers");
expect(pageSource).not.toContain("fallbackFiles");
});
it("renders squad filtering, roster, and flag details", () => {
const view = renderAndCollect({ pageKey: "squads", pageTitle: "队伍管理" });
expect(view.inputs.map((input) => input.label)).toContain("搜索队伍");
expect(view.texts).toContain("Wolves");
expect(view.texts).toContain("队伍成员");
expect(view.texts).toContain("Mira");
expect(view.texts.join("\n")).toContain("verified");
});
it("renders activity definitions, status filters, runs, and records", () => {
const view = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理" });
expect(view.inputs.map((input) => input.label)).toContain("活动状态");
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "RCON 命令", "活动公告", "活动概率", "活动持续秒数", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"]));
expect(view.texts).toContain("Friday Range");
expect(view.texts).toContain("running");
expect(view.texts).toContain("最近活动记录");
expect(view.texts).toContain("Mira");
expect(view.texts).toContain("活动生成项");
expect(view.texts).toEqual(expect.arrayContaining(["物品列表", "最近商人交易", "Cargo Drop", "买入 × 2"]));
expect(pageSource).toContain("setEventEditorOpen(detailOpen(event))");
expect(pageSource).not.toContain("open: Boolean(view.eventId || view.eventName)");
});
it("renders gift definitions, claims, and delivery records", () => {
const definitions = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
expect(definitions.texts).toContain("礼包定义");
expect(definitions.texts).toContain("物品列表");
expect(definitions.texts).toContain("Starter Pack");
expect(definitions.texts).toContain("Cargo Drop");
expect(definitions.buttons.find((button) => button.label === "保存礼包")?.disabled).toBe(false);
expect(definitions.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["礼包周期", "适用玩家", "发放次数", "成就类型", "成就值", "礼包物品", "礼包命令"]));
const claims = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理", giftTab: "claims" });
expect(claims.texts).toContain("领取记录");
expect(claims.texts).toContain("claimed");
const deliveries = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理", giftTab: "deliveries" });
expect(deliveries.texts).toContain("发放记录");
expect(deliveries.texts).toContain("delivered");
expect(deliveries.buttons.find((button) => button.label === "立即发放")?.disabled).toBe(false);
});
it("renders map layers, filter controls, points, and selected-point details", () => {
const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" });
expect(view.nodes).toContain("div:SCUM 地图图层");
expect(view.inputs.map((input) => input.label)).toContain("筛选地图点");
for (const layer of ["用户", "载具", "旗帜", "区域", "其他"]) expect(view.texts).toContain(layer);
expect(view.texts).toContain("地图点详情");
expect(view.texts).toContain("Airfield");
expect(view.texts.join("\n")).toContain("X 800 / Y 900 / Z 10");
expect(pageSource).toContain('new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href');
expect(view.elements.find((element) => element.label === "SCUM 地图图层")?.style?.backgroundImage).toContain("scum-map-overview.jpg");
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["启用自定义地图", "地图中心 X", "地图中心 Y", "地图宽度公里", "地图高度公里"]));
const vehicleView = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: { ...surfaceData, players: [], mapPoints: [], flags: [], mapRegions: [] } });
expect(vehicleView.texts).toEqual(expect.arrayContaining(["锁 已上锁", "scum-user-1", "76561198000000001"]));
});
it("deduplicates map entities, keeps every point, and computes custom map bounds", async () => {
const duplicateData: SCUMSurfaceData = { ...surfaceData, mapPoints: [{ id: "direct-player", subjectType: "player", subjectId: "76561198000000001", name: "Mira", x: 10, y: 20, z: 3 }, ...Array.from({ length: 260 }, (_, index) => ({ id: `poi-${index}`, name: `POI ${index}`, layer: "other", x: index * 10, y: index * 10 }))] };
expect(collectMapPoints(duplicateData)).toHaveLength(264);
expect(collectMapPoints(surfaceData).find((point) => point.gameVehicleId === "veh-1")).toMatchObject({ imagePath: "/original/BPC_Laika_C.webp", spawnCommand: "#spawnvehicle BPC_Laika_C" });
const bounds = resolveMapBounds({ customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 });
expect(bounds).toEqual({ worldMinX: -100000, worldMinY: 100000, worldMaxX: 300000, worldMaxY: 300000 });
expect(mapPointStyle({ x: -100000, y: 100000 }, bounds)).toEqual({ left: "99%", top: "99%" });
const pluginData = pluginDataActions();
await saveMapSettings({ pluginData }, { customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 });
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.mapSettings, "current", expect.objectContaining(bounds));
expect(pageSource).toContain('textField(point, "imagePath", "image_path")');
expect(pageSource).toContain('"className", "vehicleClass", "entityClass", "vehicleType"');
expect(pageSource).not.toContain("visible.slice(0, 240)");
});
it("contains no specialized host callbacks, machine paths, or fake-data branches", () => {
const source = `${pageSource}\n${dataClientSource}`;
for (const forbidden of ["listSCUM", "gameGift", "createSCUMOperation", "createSCUMWorkflow", "C:/", "/Users/", "hostPath", "sampleCoordinates", "samplePlayers"]) expect(source).not.toContain(forbidden);
expect(source).toContain("pluginData");
expect(source).toContain("remote.access.request");
expect(source).not.toContain("input.templateKey");
expect(source).not.toContain("remote.run.db.sqlite");
expect(source).not.toContain(`${"SCUM"}.db`);
expect(source).not.toContain("projectSCUMLoginLogs");
expect(source).not.toContain("logs.query");
expect(source).not.toContain("requestSCUMPageQueries");
expect(pageSource).toContain("scumSurfaceRefreshMs = 15000");
expect(pageSource).toContain("setInterval(refresh, scumSurfaceRefreshMs)");
expect(pageSource).toContain("clearInterval(interval)");
});
});
function pluginDataActions(overrides: Partial<{ list: (collection: string, key?: string) => Promise<unknown> }> = {}) {
return {
list: vi.fn(overrides.list ?? (async () => ({ items: [], count: 0 }))),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => undefined),
transact: vi.fn(async () => ({ items: [], count: 0 }))
};
}
function scumActions(overrides: Partial<NonNullable<SCUMWorkspaceActions["scum"]>> = {}): NonNullable<SCUMWorkspaceActions["scum"]> {
return {
surface: vi.fn(overrides.surface ?? (async () => ({
users: { items: surfaceData.players, count: surfaceData.players.length },
vehicles: { items: surfaceData.vehicles, count: surfaceData.vehicles.length },
userTrajectories: { items: surfaceData.trajectories.filter((row) => row.subjectType === "player"), count: 1 },
vehicleTrajectories: { items: surfaceData.trajectories.filter((row) => row.subjectType === "vehicle"), count: 1 },
vehicleLocks: { items: surfaceData.vehicleLocks, count: surfaceData.vehicleLocks.length }
})))
};
}
function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed"; playerSearch?: string } = {}) {
const nodes: string[] = [];
const texts: string[] = [];
const buttons: Array<{ label: string; disabled: boolean; onClick?: () => void }> = [];
const inputs: Array<{ label: string; value: unknown; onChange?: (event: unknown) => void }> = [];
const elements: Array<{ label: string; style?: Record<string, unknown> }> = [];
const collectText = (value: unknown): void => { if (typeof value === "string") texts.push(value); else if (Array.isArray(value)) value.forEach(collectText); else if (value && typeof value === "object" && "children" in value) collectText((value as { children?: unknown }).children); };
let stateCall = 0;
const react = {
createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => {
if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`);
if (typeof type === "string") elements.push({ label: String(props?.["aria-label"] ?? ""), style: props?.style as Record<string, unknown> | undefined });
children.forEach(collectText);
if (type === "button") buttons.push({ label: String(children[0]), disabled: Boolean(props?.disabled), onClick: props?.onClick as (() => void) | undefined });
if (type === "input" || type === "select" || type === "textarea") inputs.push({ label: String(props?.["aria-label"] ?? ""), value: props?.value ?? props?.checked, onChange: props?.onChange as ((event: unknown) => void) | undefined });
return { type, props, children };
},
useEffect: () => undefined,
useState: <T,>(initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] => {
stateCall += 1;
if (stateCall === 1) return [{ status: "ready", data: options.data ?? surfaceData } as T, () => undefined];
if (stateCall === 3 && options.playerSearch !== undefined) return [options.playerSearch as T, () => undefined];
const value = typeof initial === "function" ? (initial as () => T)() : initial;
if (options.giftTab && value === "definitions") return [options.giftTab as T, () => undefined];
return [value, () => undefined];
}
};
const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), scum: scumActions(), dispatch: async () => ({ status: "queued", result: { jobId: "job-1" } }) };
renderPluginPage(react, {
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] },
availability: { available: true, features: [{ key: "player.intelligence", available: true }] },
workspaceActions: actions
});
return { nodes, texts, buttons, inputs, elements, actions };
}