import { describe, expect, it } 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 { 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 declaredWorkspace = { defaultDirectoryKey: "scum-config", directories: [ { key: "scum-config", label: "服务器配置", scope: "config" }, { key: "scum-logs", label: "日志文件", scope: "logs" } ], files: [ { key: "scum-server-settings", directoryKey: "scum-config", label: "ServerSettings.ini", kind: "config", editable: true }, { key: "scum-game-config", directoryKey: "scum-config", label: "Game.ini", kind: "config" }, { key: "scum-admin-log", directoryKey: "scum-logs", label: "Admin.log", kind: "log", streamKey: "scum.admin" } ], configFields: configurationCatalog }; describe("SCUM plugin feature module", () => { it("owns runtime allowlists 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 }])).toContain("白名单"); 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 allowlisted 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 controlled 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("renders the compact two-level file management workbench without legacy stacked panels", () => { const view = renderAndCollect(); expect(view.nodes).toContain("section:SCUM 文件管理"); expect(view.nodes).toContain("aside:SCUM 文件两级菜单"); expect(view.nodes).toContain("article:文件 ServerSettings.ini"); expect(view.texts.join("\n")).toContain("scum-config · 配置声明"); expect(view.texts).toContain("刷新文件列表"); expect(view.texts).toContain("ServerSettings.ini"); expect(view.texts).toContain("Game.ini"); expect(view.texts).toContain("配置表单"); expect(view.texts).toContain("键值视图"); expect(view.texts).toContain("原文模式"); expect(view.buttons.find((button) => button.label === "刷新文件列表")?.disabled).toBe(false); expect(view.buttons.find((button) => button.label === "读取文件")?.disabled).toBe(false); expect(view.buttons.find((button) => button.label === "刷新结果")?.disabled).toBe(false); for (const legacyText of ["玩家档案", "礼物", "受控状态", "载具", "地图轨迹", "查询玩家"]) expect(view.texts.join("\n")).not.toContain(legacyText); }); it("does not invent a hardcoded SCUM file list when the platform workspace is missing", () => { const view = renderAndCollect({ workspace: {} }); expect(view.texts.join("\n")).toContain("当前插件没有可展示的声明文件。"); expect(view.texts).not.toContain("ServerSettings.ini"); expect(view.texts).not.toContain("Game.ini"); expect(pageSource).not.toContain("fallbackFiles"); }); it("keeps declared log files in read-only raw view with encoding controls", () => { const view = renderAndCollect({ directoryKey: "scum-logs", fileKey: "scum-admin-log" }); expect(view.nodes).toContain("article:文件 Admin.log"); expect(view.texts).toContain("UTF-8"); expect(view.texts).toContain("UTF-16 LE"); expect(view.texts.join("\n")).toContain("尚未读取此日志文件的受控内容。"); expect(view.nodes.some((node) => node.startsWith("textarea:"))).toBe(false); expect(view.texts).not.toContain("配置表单"); expect(view.texts).not.toContain("提交写入"); }); it("renders current config values, unknown fields, encoding switch, and guarded write actions after a read", () => { const view = renderAndCollect({ snapshot: { serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "ready", content: "ServerName=Qinghuo\nMaxPlayers=96\nCustomKey=keep\n", version: 3, checksum: "sha256:cfg", sizeBytes: 48 } }); expect(view.texts).toContain("UTF-8"); expect(view.texts).toContain("UTF-16 LE"); expect(view.texts).toContain("未建模配置项"); expect(view.texts).toContain("CustomKey"); expect(pageSource).toContain('e("option", { value: "true" }, "是")'); expect(pageSource).toContain('type: "range"'); expect(view.buttons.find((button) => button.label === "预览改动")?.disabled).toBe(true); expect(view.buttons.find((button) => button.label === "提交写入")?.disabled).toBe(true); }); it("loads snapshots on selection or manual refresh and bounded post-request refresh", () => { expect(pageSource).toContain("getFileSnapshot(selectedFile.key)"); expect(pageSource).toContain("刷新结果"); expect(pageSource).toContain("loadFileSnapshot(selectedFile.key, true)"); expect(pageSource).not.toContain("setInterval"); }); }); function renderAndCollect(options: { snapshot?: Record; permissions?: string[]; directoryKey?: string; fileKey?: string; workspace?: Record } = {}) { const nodes: string[] = []; const texts: string[] = []; const buttons: Array<{ label: string; disabled: boolean }> = []; 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 | null, ...children: unknown[]) => { if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); children.forEach(collectText); if (type === "button") buttons.push({ label: String(children[0]), disabled: Boolean(props?.disabled) }); return { type, props, children }; }, useEffect: () => undefined, useState: (initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] => { stateCall += 1; if (stateCall === 1 && options.directoryKey) return [options.directoryKey as T, () => undefined]; if (stateCall === 2 && options.fileKey) return [options.fileKey as T, () => undefined]; if (stateCall === 8 && options.snapshot) return [options.snapshot as T, () => undefined]; return [typeof initial === "function" ? (initial as () => T)() : initial, () => undefined]; } }; renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.files.read", "server.files.write", "server.logs.read"] }, availability: { available: true, features: [{ key: "config.manage", available: true }] }, workspace: options.workspace ?? declaredWorkspace, workspaceActions: { refreshWorkspace: async () => declaredWorkspace, requestFile: async (fileKey: string) => ({ status: "queued", message: fileKey }), getFileSnapshot: async (fileKey: string) => ({ serverInstanceId: "server-1", pluginId: "game.scum", key: fileKey, state: "not-read" }), writeFile: async (fileKey: string) => ({ status: "queued", message: fileKey }) } }); return { nodes, texts, buttons }; }