服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
374 lines
15 KiB
TypeScript
374 lines
15 KiB
TypeScript
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
|
import { createPinia, disposePinia, setActivePinia, type Pinia } from "pinia";
|
|
import { nextTick } from "vue";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
Prepare: vi.fn(),
|
|
Release: vi.fn(),
|
|
ExecuteSelectionDialog: vi.fn(),
|
|
Formats: vi.fn(),
|
|
SelectMergeDialog: vi.fn(),
|
|
events: new Map<string, () => void>(),
|
|
}));
|
|
vi.mock("../bindings/pvfine/services", () => ({ ExportService: mocks }));
|
|
vi.mock("@wailsio/runtime", () => ({ Events: { On: (name: string, handler: () => void) => mocks.events.set(name, handler) } }));
|
|
|
|
import { modIDError, modNameError, modVersionError, useExportStore } from "../src/stores/export";
|
|
import { buildExportTree, exportFileKey } from "../src/utils/export-tree";
|
|
|
|
const files = [
|
|
{ path: "stackable/a.stk", required: false, dependencyCount: 0 },
|
|
{ path: "stackable/b.stk", required: false, dependencyCount: 0 },
|
|
{ path: "string/demo.str", required: false, dependencyCount: 0 },
|
|
];
|
|
|
|
let pinia: Pinia;
|
|
beforeEach(() => {
|
|
pinia = createPinia();
|
|
setActivePinia(pinia);
|
|
vi.clearAllMocks();
|
|
mocks.events.clear();
|
|
mocks.Prepare.mockResolvedValue({ id: "1", fileCount: 3, dependencyCount: 0, skippedDeletes: 0, warnings: [], files });
|
|
mocks.Release.mockResolvedValue(undefined);
|
|
mocks.Formats.mockResolvedValue([{ id: "110USextend", name: "110USextend", canRead: false, canWrite: true, operations: [] }]);
|
|
mocks.ExecuteSelectionDialog.mockResolvedValue("");
|
|
});
|
|
afterEach(() => {
|
|
useExportStore().close();
|
|
disposePinia(pinia);
|
|
});
|
|
|
|
const source = { source: "working", scopes: [], commitId: "" };
|
|
async function settle() {
|
|
await nextTick();
|
|
await useExportStore().refresh();
|
|
await nextTick();
|
|
}
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((yes) => { resolve = yes; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
test("所有来源默认直接导出,取消不执行或写盘", async () => {
|
|
const store = useExportStore();
|
|
for (const kind of ["working", "commit", "selection", "memory"]) {
|
|
const result = store.open({ ...source, source: kind }, "导出");
|
|
await settle();
|
|
expect(store.mode).toBe("direct");
|
|
expect(store.includeDependencies).toBe(false);
|
|
expect(store.version).toBe("1.0");
|
|
expect(store.canExport).toBe(true);
|
|
expect(mocks.Prepare.mock.lastCall?.[0].source).toBe(kind);
|
|
store.close();
|
|
expect(await result).toBe("");
|
|
}
|
|
expect(mocks.ExecuteSelectionDialog).not.toHaveBeenCalled();
|
|
expect(mocks.Release).toHaveBeenCalled();
|
|
});
|
|
|
|
test("mod 模式校验名称、格式和依赖开关,并显示删除警告", async () => {
|
|
const store = useExportStore();
|
|
void store.open(source, "导出当前改动");
|
|
await settle();
|
|
store.mode = "mod";
|
|
await settle();
|
|
expect(store.nameError).not.toBe("");
|
|
expect(store.canExport).toBe(false);
|
|
store.name = "中文 MOD";
|
|
store.includeDependencies = true;
|
|
mocks.Prepare.mockResolvedValue({ id: "mod", fileCount: 4, dependencyCount: 3, skippedDeletes: 2, warnings: ["跳过 2 个删除"], files });
|
|
await settle();
|
|
expect(mocks.Prepare.mock.lastCall?.[0]).toMatchObject({
|
|
mode: "mod", format: "110USextend", name: "中文 MOD", includeDependencies: true,
|
|
});
|
|
expect(store.preview?.warnings).toEqual(["跳过 2 个删除"]);
|
|
expect(store.preview?.dependencyCount).toBe(3);
|
|
store.mode = "direct";
|
|
await settle();
|
|
expect(mocks.Prepare.mock.lastCall?.[0].includeDependencies).toBe(false);
|
|
});
|
|
|
|
test("名称验证覆盖路径和 Windows 保留名", () => {
|
|
for (const name of ["", ".hidden", "_hidden", "CON", "nul.txt", "COM1", "a/b", "a\\b", "tail.", " tail", "tail ", "a:b"]) {
|
|
expect(modNameError(name), name).not.toBe("");
|
|
}
|
|
expect(modNameError("合法名称")).toBe("");
|
|
});
|
|
|
|
test("115CN 导出保持唯一 ID,允许更新时指定原 ID,非法 ID 阻止导出", async () => {
|
|
const store = useExportStore();
|
|
void store.open(source, "导出");
|
|
store.mode = "mod";
|
|
store.format = "115CN-mod";
|
|
store.name = "中文 MOD";
|
|
await settle();
|
|
const generated = store.modId;
|
|
expect(modIDError(generated)).toBe("");
|
|
expect(mocks.Prepare.mock.lastCall?.[0].modId).toBe(generated);
|
|
store.name = "改名";
|
|
await settle();
|
|
expect(store.modId).toBe(generated);
|
|
store.modId = "existing-mod-id";
|
|
await settle();
|
|
expect(mocks.Prepare.mock.lastCall?.[0].modId).toBe("existing-mod-id");
|
|
expect(store.canExport).toBe(true);
|
|
store.modId = "../invalid";
|
|
await settle();
|
|
expect(store.idError).not.toBe("");
|
|
expect(store.canExport).toBe(false);
|
|
for (const id of ["", "Upper", "a".repeat(65), "中文", "a.b"]) expect(modIDError(id)).not.toBe("");
|
|
store.format = "110USextend";
|
|
await settle();
|
|
expect(store.idError).toBe("");
|
|
store.close();
|
|
void store.open(source, "导出");
|
|
expect(store.modId).not.toBe(generated);
|
|
});
|
|
|
|
test("自定义版本传入元数据,无需释放旧快照,关闭后重置", async () => {
|
|
const store = useExportStore();
|
|
void store.open(source, "导出");
|
|
store.mode = "mod";
|
|
store.name = "测试";
|
|
await settle();
|
|
mocks.Release.mockClear();
|
|
store.version = "2.1-beta";
|
|
await settle();
|
|
expect(mocks.Prepare.mock.lastCall?.[0].version).toBe("2.1-beta");
|
|
expect(mocks.Release).not.toHaveBeenCalled();
|
|
expect(store.canExport).toBe(true);
|
|
store.version = "bad\nversion";
|
|
await settle();
|
|
expect(store.versionError).not.toBe("");
|
|
expect(store.canExport).toBe(false);
|
|
store.close();
|
|
expect(mocks.Release).toHaveBeenCalledWith("1");
|
|
void store.open(source, "导出");
|
|
expect(store.version).toBe("1.0");
|
|
});
|
|
|
|
test("快速改名时保留已完成的旧内容供下一次预检复用", async () => {
|
|
const store = useExportStore();
|
|
void store.open(source, "导出");
|
|
store.mode = "mod";
|
|
store.name = "初始名称";
|
|
await settle();
|
|
mocks.Release.mockClear();
|
|
const pending = deferred<{ id: string; fileCount: number; dependencyCount: number; skippedDeletes: number; warnings: string[]; files: typeof files }>();
|
|
mocks.Prepare.mockReturnValueOnce(pending.promise);
|
|
store.name = "中间名称";
|
|
await nextTick();
|
|
await Promise.resolve();
|
|
store.name = "最终名称";
|
|
await nextTick();
|
|
pending.resolve({ id: "cache", fileCount: 3, dependencyCount: 0, skippedDeletes: 0, warnings: [], files });
|
|
await settle();
|
|
expect(mocks.Release).not.toHaveBeenCalledWith("cache");
|
|
expect(mocks.Prepare.mock.lastCall?.[0].name).toBe("最终名称");
|
|
expect(store.canExport).toBe(true);
|
|
});
|
|
|
|
test("版本不强制 semver,空值使用默认值,非法控制字符和长度阻止导出", () => {
|
|
for (const value of ["", "1.0", "v2.3-beta", "中文测试版"]) expect(modVersionError(value)).toBe("");
|
|
for (const value of [" 1.0", "1.0 ", "x\nx", "x".repeat(129), "中".repeat(43)]) expect(modVersionError(value)).not.toBe("");
|
|
});
|
|
|
|
test("重复确认仅执行一次,执行期间不能关闭,成功返回目录", async () => {
|
|
const store = useExportStore();
|
|
const result = store.open(source, "导出");
|
|
await settle();
|
|
const pending = deferred<string>();
|
|
mocks.ExecuteSelectionDialog.mockReturnValue(pending.promise);
|
|
const first = store.execute();
|
|
await store.execute();
|
|
store.close();
|
|
expect(store.visible).toBe(true);
|
|
expect(mocks.ExecuteSelectionDialog).toHaveBeenCalledTimes(1);
|
|
pending.resolve("D:/mods/测试");
|
|
await first;
|
|
expect(await result).toBe("D:/mods/测试");
|
|
expect(store.visible).toBe(false);
|
|
});
|
|
|
|
test("目录选择取消无错误,可重试;过期预览禁用导出", async () => {
|
|
const store = useExportStore();
|
|
void store.open(source, "导出");
|
|
await settle();
|
|
await store.execute();
|
|
expect(store.visible).toBe(true);
|
|
expect(store.error).toBe("");
|
|
mocks.ExecuteSelectionDialog.mockRejectedValue(new Error("导出预览已过期"));
|
|
await store.execute();
|
|
expect(store.canExport).toBe(false);
|
|
expect(store.error).toContain("重新准备");
|
|
mocks.ExecuteSelectionDialog.mockResolvedValue("");
|
|
await settle();
|
|
expect(store.canExport).toBe(true);
|
|
});
|
|
|
|
test("版本变化使预览失效,归档关闭取消流程", async () => {
|
|
const store = useExportStore();
|
|
const result = store.open(source, "导出");
|
|
await settle();
|
|
mocks.events.get("version:changed")?.();
|
|
expect(store.preview).toBeNull();
|
|
expect(store.canExport).toBe(false);
|
|
expect(store.error).toContain("重新准备");
|
|
mocks.events.get("archive:closed")?.();
|
|
expect(await result).toBe("");
|
|
expect(store.visible).toBe(false);
|
|
});
|
|
|
|
test("取消慢预检释放返回任务,旧结果不能覆盖新弹窗", async () => {
|
|
const store = useExportStore();
|
|
const pending = deferred<{ id: string; fileCount: number; dependencyCount: number; skippedDeletes: number; warnings: string[] }>();
|
|
mocks.Prepare.mockReturnValueOnce(pending.promise);
|
|
const oldResult = store.open(source, "旧导出");
|
|
await nextTick();
|
|
await Promise.resolve();
|
|
store.close();
|
|
expect(await oldResult).toBe("");
|
|
void store.open({ ...source, source: "selection" }, "新导出");
|
|
pending.resolve({ id: "old", fileCount: 999, dependencyCount: 0, skippedDeletes: 0, warnings: [] });
|
|
await settle();
|
|
expect(mocks.Release).toHaveBeenCalledWith("old");
|
|
expect(store.preview?.fileCount).toBe(3);
|
|
expect(store.title).toBe("新导出");
|
|
});
|
|
|
|
test("树显示完整输出路径、目录优先、必需元数据不可移除", () => {
|
|
const tree = buildExportTree([
|
|
{ path: "pack.json", required: true, dependencyCount: 0 },
|
|
{ path: "pvf/stackable/中文.stk", required: false, dependencyCount: 0 },
|
|
{ path: "merge/string/demo.str", required: false, dependencyCount: 2 },
|
|
]);
|
|
expect(tree.map((node) => node.label)).toEqual(["merge", "pvf", "pack.json"]);
|
|
expect(tree[2]).toMatchObject({ key: exportFileKey("pack.json"), checkboxDisabled: true });
|
|
expect(tree[1]?.children?.[0]?.children?.[0]).toMatchObject({
|
|
key: exportFileKey("pvf/stackable/中文.stk"), label: "中文.stk",
|
|
});
|
|
});
|
|
|
|
test("移除文件传给后端,全不选禁用导出,刷新保留排除,新会话重置", async () => {
|
|
const store = useExportStore();
|
|
void store.open({ ...source, source: "memory" }, "内存改动");
|
|
await settle();
|
|
store.selectFiles([exportFileKey(files[0]!.path)]);
|
|
expect(store.selectedCount).toBe(1);
|
|
await store.execute();
|
|
expect(mocks.ExecuteSelectionDialog).toHaveBeenCalledWith("1", [files[1]!.path, files[2]!.path]);
|
|
await settle();
|
|
expect(store.selectedCount).toBe(1);
|
|
store.selectFiles([]);
|
|
expect(store.canExport).toBe(false);
|
|
await store.execute();
|
|
expect(mocks.ExecuteSelectionDialog).toHaveBeenCalledTimes(1);
|
|
store.close();
|
|
void store.open(source, "新导出");
|
|
await settle();
|
|
expect(store.selectedCount).toBe(3);
|
|
});
|
|
|
|
test("移除补齐依赖更新统计,必需文件始终勾选", async () => {
|
|
const store = useExportStore();
|
|
const modFiles = [
|
|
{ path: "pack.json", required: true, dependencyCount: 0 },
|
|
{ path: "pvf/a.stk", required: false, dependencyCount: 0 },
|
|
{ path: "merge/a.str", required: false, dependencyCount: 2 },
|
|
];
|
|
mocks.Prepare.mockResolvedValue({ id: "mod", fileCount: 2, dependencyCount: 2, skippedDeletes: 0, warnings: [], files: modFiles });
|
|
void store.open(source, "导出");
|
|
store.mode = "mod";
|
|
store.name = "测试";
|
|
store.includeDependencies = true;
|
|
await settle();
|
|
store.selectFiles([exportFileKey("pvf/a.stk")]);
|
|
expect(store.checkedKeys).toContain(exportFileKey("pack.json"));
|
|
expect(store.selectedDependencyCount).toBe(0);
|
|
expect(store.selectedCount).toBe(1);
|
|
await store.execute();
|
|
expect(mocks.ExecuteSelectionDialog).toHaveBeenCalledWith("mod", ["merge/a.str"]);
|
|
});
|
|
|
|
test("归档差异传递快照和方向,普通编辑不使固定快照失效,重载使其失效", async () => {
|
|
const store = useExportStore();
|
|
void store.open({ ...source, source: "archive-diff", diffId: "comparison-1", reverse: true }, "导出差异");
|
|
await settle();
|
|
expect(mocks.Prepare.mock.lastCall?.[0]).toMatchObject({ source: "archive-diff", diffId: "comparison-1", reverse: true });
|
|
mocks.events.get("archive:changed")?.();
|
|
mocks.events.get("version:changed")?.();
|
|
expect(store.canExport).toBe(true);
|
|
mocks.events.get("archive:reloaded")?.();
|
|
expect(store.canExport).toBe(false);
|
|
});
|
|
|
|
|
|
test("合并来源只用于 MOD,切换格式或重新打开时清除", async () => {
|
|
const store = useExportStore();
|
|
void store.open(source, "导出");
|
|
store.mode = "mod";
|
|
store.name = "合并MOD";
|
|
await settle();
|
|
mocks.SelectMergeDialog.mockResolvedValue({ path: "/mods/existing", name: "已有MOD", version: "1.2.4", versionNotice: "" });
|
|
await store.selectMerge();
|
|
await settle();
|
|
expect(mocks.SelectMergeDialog).toHaveBeenCalledWith("110USextend");
|
|
expect(store.name).toBe("已有MOD");
|
|
expect(store.version).toBe("1.2.4");
|
|
expect(store.packageName).toBe("已有MOD-1.2.4/");
|
|
expect(mocks.Prepare.mock.lastCall?.[0].mergePath).toBe("/mods/existing");
|
|
store.mode = "direct";
|
|
await settle();
|
|
expect(mocks.Prepare.mock.lastCall?.[0].mergePath).toBe("");
|
|
store.mode = "mod";
|
|
store.format = "115CN-mod";
|
|
await settle();
|
|
expect(store.mergePath).toBe("");
|
|
store.mergePath = "/mods/another";
|
|
store.close();
|
|
void store.open(source, "导出");
|
|
await settle();
|
|
expect(store.mergePath).toBe("");
|
|
});
|
|
|
|
test("取消选择保留原有合并来源,关闭后忽略迟到的选择结果", async () => {
|
|
const store = useExportStore();
|
|
void store.open(source, "导出");
|
|
store.mode = "mod";
|
|
store.name = "合并MOD";
|
|
store.mergePath = "/mods/original";
|
|
await settle();
|
|
mocks.SelectMergeDialog.mockResolvedValue(null);
|
|
await store.selectMerge();
|
|
expect(store.mergePath).toBe("/mods/original");
|
|
const pending = deferred<{ path: string; name: string; version: string; versionNotice: string }>();
|
|
mocks.SelectMergeDialog.mockReturnValue(pending.promise);
|
|
const selecting = store.selectMerge();
|
|
expect(store.canExport).toBe(false);
|
|
store.close();
|
|
void store.open(source, "新导出");
|
|
pending.resolve({ path: "/mods/late", name: "迟到", version: "9.9", versionNotice: "" });
|
|
await selecting;
|
|
await settle();
|
|
expect(store.mergePath).toBe("");
|
|
});
|
|
|
|
|
|
test("特殊版本保留原值并显示手动修改提示,包名使用格式默认版本", async () => {
|
|
const store = useExportStore();
|
|
void store.open(source, "导出");
|
|
store.mode = "mod";
|
|
mocks.SelectMergeDialog.mockResolvedValue({ path: "/mods/special", name: "特殊MOD", version: "beta", versionNotice: "请手动修改版本号" });
|
|
await store.selectMerge();
|
|
await settle();
|
|
expect(store.name).toBe("特殊MOD");
|
|
expect(store.version).toBe("beta");
|
|
expect(store.versionNotice).toBe("请手动修改版本号");
|
|
expect(mocks.Prepare.mock.lastCall?.[0]).toMatchObject({ name: "特殊MOD", version: "beta", mergePath: "/mods/special" });
|
|
store.version = "";
|
|
expect(store.packageName).toBe("特殊MOD-1.0/");
|
|
store.format = "115CN-mod";
|
|
expect(store.packageName).toBe("特殊MOD-1.0.0.zip");
|
|
});
|