feat(scum): rebuild plugin-owned management data

This commit is contained in:
npc0-hue
2026-08-15 12:43:09 +08:00
parent 92b1159cc8
commit 65353cf269
113 changed files with 3116 additions and 8058 deletions
+225 -79
View File
@@ -1,24 +1,39 @@
import { describe, expect, it } from "vitest";
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, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, queueGiftDelivery, requestSCUMPageQueries, 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 pageSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features/page.ts"), "utf8");
const projectionData = {
players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, position: { x: 10, y: 20, z: 3, hasCoordinates: true }, freshness: { status: "fresh" }, unknownFields: { "855": 100 } }],
squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 3, leaderProfileId: "profile-1", freshness: { status: "fresh" } }],
members: [{ gamePlayerId: "steam-1", displayName: "Mira", squadId: "squad-1", rank: "Leader", freshness: { status: "fresh" } }],
vehicles: [{ vehicleId: "veh-1", label: "Laika", position: { subjectType: "vehicle", subjectId: "veh-1", x: 400, y: 200, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }],
flags: [{ flagId: "flag-1", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { subjectType: "flag", subjectId: "flag-1", x: 100, y: 80, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }],
positions: [{ subjectType: "player", subjectId: "steam-1", gamePlayerId: "steam-1", x: 10, y: 20, z: 3, hasCoordinates: true, freshness: { status: "fresh" } }],
operations: [{ id: "op-1", templateKey: "player.fame.set", status: "waiting", safeSummary: { message: "awaiting approval" } }],
workflows: [{ id: "wf-1", templateKey: "scum.world-refresh", status: "queued", currentStepKey: "read-positions", createdAt: "2026-08-10T00:00:00Z", safeSummary: { message: "world refresh queued" } }],
steps: [{ stepKey: "read-positions", status: "queued", capability: "remote.run.db.sqlite.query", safeSummary: { message: "queued safely" } }]
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, 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", 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" }],
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: [{ vehicleId: "veh-1", label: "Laika", position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }],
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", () => {
@@ -62,114 +77,245 @@ describe("SCUM plugin feature module", () => {
expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
});
it("renders projection-backed user management without raw file/config panels", () => {
it("loads page data only through scoped plugin collections", async () => {
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
const data = await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "gifts");
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.gifts, scumCollections.giftClaims, scumCollections.pendingGifts, scumCollections.giftDeliveries, scumCollections.timedGiftEvents, scumCollections.players]);
expect(data.gifts[0]).toMatchObject({ collection: scumCollections.gifts, _recordKey: `${scumCollections.gifts}-1` });
});
it("merges the latest typed player and online-session snapshots into database users", async () => {
const pluginData = pluginDataActions({ list: async (collection) => collection === scumCollections.players ? { items: [{ key: "steam-1", value: { gamePlayerId: "steam-1", displayName: "Mira", online: false } }] } : { items: [] } });
const gameClient = gameClientActions();
gameClient.snapshots.mockImplementation(async (query) => query?.type === "players" ? { items: [{ sequence: 2, observedAt: "2026-08-10T00:00:00Z", payload: { players: [{ playerId: "steam-1", playerName: "Mira", status: "online", pingMs: 32 }] } }] } : { items: [{ sequence: 3, observedAt: "2026-08-10T00:01:00Z", payload: { sessions: [{ sessionId: "session-1", playerName: "Mira" }] } }] });
const data = await loadSCUMSurface({ pluginData, gameClient }, "players");
expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players", "online.sessions"]);
expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", status: "online", online: true, pingMs: 32, onlineObservedAt: "2026-08-10T00:01:00Z" });
expect(mergePlayerSnapshots([{ gamePlayerId: "steam-2", displayName: "Noah" }], { items: [] }, { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { sessions: [] } }] })[0]).toMatchObject({ online: false });
});
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 }) }, "workflows");
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents]);
const dispatch = dispatchAction();
await requestSCUMPageQueries({ dispatch }, "workflows");
expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.tasks", "scum.events"]);
dispatch.mockClear();
await requestSCUMPageQueries({ dispatch }, "activity");
expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.tasks", "scum.events"]);
});
it("dispatches only declared SQLite query envelopes for machine refresh", async () => {
const dispatch = dispatchAction();
await requestSCUMPageQueries({ dispatch }, "squads");
expect(dispatch).toHaveBeenCalledTimes(3);
expect(dispatch.mock.calls.map(([envelope]) => envelope.payload["input.templateKey"])).toEqual(["scum.squads", "scum.squad-members", "scum.flags"]);
for (const [envelope] of dispatch.mock.calls) expect(envelope).toMatchObject({ action: "remote.access.request", payload: { capability: "remote.run.db.sqlite.query", declarationKey: "scum-database", targetKey: "scum-database" } });
});
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:1,b:1,c:1,d:1,e:1,f:1,g:1,h:1,i:1")).toThrow("最多包含 8 项");
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 gameClient = gameClientActions();
const actions: SCUMWorkspaceActions = { pluginData, gameClient };
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], surfaceData.eventProduces);
expect(gameClient.queue).toHaveBeenLastCalledWith(expect.objectContaining({ commandType: "event.start", payload: expect.objectContaining({
eventType: "range", class: 1, placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2,
produces: [{ tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }]
}) }));
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 the host-compatible generic gameClient bridge", async () => {
const pluginData = pluginDataActions();
const gameClient = gameClientActions();
const actions: SCUMWorkspaceActions = { pluginData, gameClient };
await queueGiftDelivery(actions, { ...surfaceData.gifts[0], operations: ["#announce Starter pack", "#SetFamePoints 250"] }, surfaceData.players[0]);
expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ profileKey: "scum-client-manager", commandType: "reward.deliver", payload: expect.objectContaining({ playerId: "steam-1", items: [{ catalogCode: "BP_Cash_01", quantity: 2 }], operations: ["#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], surfaceData.eventProduces);
expect(gameClient.queue).toHaveBeenLastCalledWith(expect.objectContaining({ profileKey: "scum-client-manager", commandType: "event.start", payload: expect.objectContaining({ eventId: "event-1", eventType: "range", class: 1, placard: "Event starting", percent: 75, produces: [{ tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }] }) }));
expect(Object.keys(gameClient).sort()).toEqual(["get", "list", "queue", "snapshots"]);
});
it("defaults activity class to range and strips collection metadata from queued produces", async () => {
const pluginData = pluginDataActions();
const gameClient = gameClientActions();
await startEvent({ pluginData, gameClient }, { id: "event-default", name: "Default Event" }, [{
_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(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ commandType: "event.start", payload: expect.objectContaining({
eventType: "range", class: 1, npc: 0, item: 0, zombie: 0, animal: 0,
produces: [{ tradeGoodsId: "cargo-drop", percent: 80, value: 2, r: 500, x: 1000, y: 2000, z: 300 }]
}) }));
});
it("renders searchable user management from real collection values", () => {
const view = renderAndCollect();
expect(view.nodes).toContain("section:用户管理");
expect(view.texts.join("\n")).toContain("登录日志和 SCUM.db typed observations");
expect(view.texts).toContain("投影/Companion 可用");
expect(view.texts).toContain("刷新投影");
expect(view.texts).toContain("刷新真实数据");
expect(view.texts.join("\n")).toContain("插件声明的 SCUM.db 查询与日志同步");
expect(view.texts).toContain("通用数据/机器动作可用");
expect(view.buttons.find((button) => button.label === "同步 SCUM.db")?.disabled).toBe(false);
expect(view.inputs.map((input) => input.label)).toContain("搜索用户");
expect(view.texts).toContain("Mira");
expect(view.texts.join("\n")).toContain("Steam 76561198000000001");
expect(view.texts.join("\n")).toContain("Profile profile-1");
expect(view.texts.join("\n")).toContain("Fame 42");
expect(view.buttons.find((button) => button.label === "Fame +100")?.disabled).toBe(false);
expect(view.buttons.find((button) => button.label === "现金 +1000")?.disabled).toBe(false);
expect(view.buttons.find((button) => button.label === "855 审批")?.disabled).toBe(false);
for (const removedText of ["ServerSettings.ini", "Game.ini", "配置表单", "键值视图", "原文模式", "读取文件", "提交写入"]) expect(view.texts.join("\n")).not.toContain(removedText);
});
it("does not invent fake players when projections are empty", () => {
const view = renderAndCollect({ data: { ...projectionData, players: [], positions: [] } });
expect(view.texts.join("\n")).toContain("暂无玩家投影");
expect(view.texts.join("\n")).toContain("不会显示假玩家");
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("fallbackFiles");
expect(pageSource).not.toContain("samplePlayers");
expect(pageSource).not.toContain("fallbackFiles");
});
it("renders squad and flag governance from projections", () => {
it("renders squad filtering, roster, and flag details", () => {
const view = renderAndCollect({ pageKey: "squads", pageTitle: "队伍管理" });
expect(view.nodes).toContain("section:队伍管理");
expect(view.texts).toContain("队伍");
expect(view.texts).toContain("成员 / 旗帜");
expect(view.inputs.map((input) => input.label)).toContain("搜索队伍");
expect(view.texts).toContain("Wolves");
expect(view.texts.join("\n")).toContain("成员 3");
expect(view.texts).toContain("队伍成员");
expect(view.texts).toContain("Mira");
expect(view.texts.join("\n")).toContain("verified");
});
it("renders realtime map overlays without sample coordinates", () => {
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(["生成类型", "活动公告", "活动概率", "生成物品编号", "生成半径", "生成 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("活动生成项");
});
it("renders gift definitions, claims, and delivery records", () => {
const definitions = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
expect(definitions.texts).toContain("礼包定义");
expect(definitions.texts).toContain("Starter Pack");
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("section:实时地图");
expect(view.texts).toContain("地图覆盖物");
expect(view.texts.join("\n")).toContain("坐标点");
expect(view.texts.join("\n")).toContain("X 10 / Y 20 / Z 3");
expect(pageSource).toContain("map-projection-board");
expect(pageSource).not.toContain("sampleCoordinates");
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", "地图宽度公里", "地图高度公里"]));
});
it("renders gift and workflow typed status surfaces", () => {
const gifts = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
expect(gifts.nodes).toContain("section:礼包管理");
expect(gifts.texts.join("\n")).toContain("typed delivery workflow");
expect(gifts.buttons.find((button) => button.label === "创建礼包发放")?.disabled).toBe(false);
expect(gifts.buttons.find((button) => button.label === "发送通知")?.disabled).toBe(false);
const workflows = renderAndCollect({ pageKey: "workflows", pageTitle: "Workflow 状态" });
expect(workflows.nodes).toContain("section:Workflow 状态");
expect(workflows.texts.join("\n")).toContain("scum.world-refresh");
expect(workflows.texts.join("\n")).toContain("read-positions");
it("deduplicates map entities, keeps every point, and computes custom map bounds", async () => {
const duplicateData: SCUMSurfaceData = { ...surfaceData, mapPoints: [{ id: "direct-player", subjectType: "player", subjectId: "steam-1", 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);
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).not.toContain("visible.slice(0, 240)");
});
it("loads plugin-owned projections through generic platform collections instead of file snapshots", () => {
expect(pageSource).toContain("pluginData");
expect(pageSource).toContain('"scum_users"');
expect(pageSource).toContain('"scum_squads"');
expect(pageSource).toContain('"scum_map_points"');
expect(pageSource).not.toContain("getFileSnapshot");
expect(pageSource).not.toContain("requestFile");
expect(pageSource).not.toContain("writeFile");
expect(pageSource).not.toContain("setInterval");
it("contains no specialized host callbacks, raw SQL, machine paths, or fake-data branches", () => {
const source = `${pageSource}\n${dataClientSource}`;
for (const forbidden of ["listSCUM", "gameGift", "createSCUMOperation", "createSCUMWorkflow", "SELECT ", "C:/", "/Users/", "hostPath", "sampleCoordinates", "samplePlayers"]) expect(source).not.toContain(forbidden);
expect(source).toContain("pluginData");
expect(source).toContain("remote.access.request");
expect(source).toContain("input.templateKey");
});
});
function renderAndCollect(options: { data?: typeof projectionData; permissions?: string[]; pageKey?: string; pageTitle?: string } = {}) {
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 dispatchAction() {
return vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ requestId: envelope.requestId, action: envelope.action, status: "queued" }));
}
function gameClientActions() {
const queue = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["queue"]>(async () => ({ id: "command-1", state: "pending" }));
const get = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["get"]>(async () => ({ id: "command-1", state: "pending" }));
const list = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["list"]>(async () => ({ items: [], count: 0 }));
const snapshots = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["snapshots"]>(async () => ({ items: [], count: 0 }));
return {
queue, get, list, snapshots
};
}
function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed" } = {}) {
const nodes: string[] = [];
const texts: string[] = [];
const buttons: Array<{ label: string; disabled: boolean }> = [];
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) });
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 ?? projectionData } as T, () => undefined];
return [typeof initial === "function" ? (initial as () => T)() : initial, () => undefined];
if (stateCall === 1) return [{ status: "ready", data: options.data ?? surfaceData } 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(), gameClient: gameClientActions(), dispatch: dispatchAction() };
renderPluginPage(react, {
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] },
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] },
availability: { available: true, features: [{ key: "player.intelligence", available: true }] },
workspaceActions: {
listSCUMPlayers: async () => ({ items: projectionData.players, count: projectionData.players.length }),
listSCUMSquads: async () => ({ items: projectionData.squads, count: projectionData.squads.length }),
listSCUMSquadMembers: async () => ({ items: projectionData.members, count: projectionData.members.length }),
listSCUMVehicles: async () => ({ items: projectionData.vehicles, count: projectionData.vehicles.length }),
listSCUMFlags: async () => ({ items: projectionData.flags, count: projectionData.flags.length }),
listSCUMPositions: async () => ({ items: projectionData.positions, count: projectionData.positions.length }),
listSCUMOperations: async () => ({ items: projectionData.operations, count: projectionData.operations.length }),
listSCUMWorkflows: async () => ({ items: projectionData.workflows, count: projectionData.workflows.length }),
listSCUMWorkflowSteps: async () => ({ items: projectionData.steps, count: projectionData.steps.length }),
createSCUMOperation: async () => ({ id: "op-new", status: "waiting" }),
createSCUMWorkflow: async () => ({ id: "wf-new", status: "queued" })
}
workspaceActions: actions
});
return { nodes, texts, buttons };
return { nodes, texts, buttons, inputs, elements, actions };
}