Add SCUM file management workbench

This commit is contained in:
npc0-hue
2026-08-04 11:33:34 +08:00
parent 2921edb401
commit f028a343d7
36 changed files with 1384 additions and 158 deletions
+80 -26
View File
@@ -1,10 +1,15 @@
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");
describe("SCUM plugin feature module", () => {
it("owns runtime allowlists without a version gate", () => {
expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message");
@@ -46,35 +51,84 @@ describe("SCUM plugin feature module", () => {
expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
});
it("renders plugin-owned configuration, player, reward, state, and trajectory panels with scoped permissions", () => {
const nodes: string[] = []; const buttons = new Map<string, boolean>();
const react = { createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => { if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`); if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } };
renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true, features: [{ key: "config.manage", available: true }, { key: "player.intelligence", available: false, reason: "no event producer" }, { key: "reward.delivery", available: false, reason: "no delivery handler" }, { key: "state.patch", available: false, reason: "no state handler" }, { key: "vehicle.spawn", available: false, reason: "no vehicle handler" }, { key: "trajectory.collect", available: false, reason: "no position producer" }] }, workspace: {} });
expect(nodes).toContain("section:SCUM 配置工作台");
expect(nodes).toContain("section:SCUM 玩家档案");
expect(nodes).toContain("section:SCUM 礼物与通知");
expect(nodes).toContain("section:SCUM 受控状态修改");
expect(nodes).toContain("section:SCUM 受限载具生成");
expect(nodes).toContain("section:SCUM 地图轨迹");
expect(buttons.get("读取配置")).toBe(false);
expect(buttons.get("查询玩家")).toBe(true);
expect(buttons.get("申请投递")).toBe(true);
expect(buttons.get("创建修改申请")).toBe(true);
expect(buttons.get("生成载具")).toBe(true);
expect(buttons.get("读取轨迹")).toBe(true);
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/Saved/Config/WindowsServer");
expect(view.texts.join("\n")).toContain("/SCUM/Saved/SaveFiles/Logs");
expect(view.texts).toContain("ServerSettings.ini");
expect(view.texts).toContain("Game.ini");
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);
for (const legacyText of ["玩家档案", "礼物", "受控状态", "载具", "地图轨迹", "查询玩家"]) expect(view.texts.join("\n")).not.toContain(legacyText);
});
it("fails closed when a generally online Companion omits feature availability", () => {
const buttons = new Map<string, boolean>();
const react = { createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => { if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } };
renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] }, availability: { available: true }, workspace: {} });
for (const label of ["读取配置", "查询玩家", "申请投递", "创建修改申请", "生成载具", "读取轨迹"]) expect(buttons.get(label)).toBe(true);
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("opens vehicle spawning only for the declared Companion handler", () => {
const buttons = new Map<string, boolean>();
const react = { createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => { if (type === "button") buttons.set(String(children[0]), Boolean(props?.disabled)); return { type, props, children }; } };
renderPluginPage(react, { context: { serverInstanceId: "server-1", permissions: ["server.game-client.command"] }, availability: { available: true, features: [{ key: "vehicle.spawn", available: true }] }, workspace: {} });
expect(buttons.get("生成载具")).toBe(false);
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 without interval polling", () => {
expect(pageSource).toContain("getFileSnapshot(selectedFile.key)");
expect(pageSource).toContain("刷新结果");
expect(pageSource).not.toContain("setInterval");
expect(pageSource).not.toContain("setTimeout");
});
});
function renderAndCollect(options: { snapshot?: Record<string, unknown>; permissions?: string[]; directoryKey?: string; fileKey?: string } = {}) {
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<string, unknown> | 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: <T,>(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 === 7 && 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: {},
workspaceActions: {
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 };
}