159 lines
12 KiB
TypeScript
159 lines
12 KiB
TypeScript
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<string, unknown>; permissions?: string[]; directoryKey?: string; fileKey?: string; workspace?: Record<string, unknown> } = {}) {
|
|
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 === 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 };
|
|
}
|