服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { createPinia, setActivePinia } from "pinia";
|
||
import { nextTick, ref } from "vue";
|
||
|
||
const api = vi.hoisted(() => ({
|
||
GetFile: vi.fn(),
|
||
GetFileBasic: vi.fn(),
|
||
ResolveFiles: vi.fn(),
|
||
ApplyWorldDropEdit: vi.fn(),
|
||
showArchiveEditor: vi.fn(),
|
||
refreshInfo: vi.fn(),
|
||
SetText: vi.fn(),
|
||
GetAnnotations: vi.fn(),
|
||
Save: vi.fn(),
|
||
SaveAsDialog: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("@wailsio/runtime", () => ({ Events: { On: vi.fn() } }));
|
||
vi.mock("../bindings/pvfine/services", () => ({
|
||
EditorService: {
|
||
GetFile: api.GetFile,
|
||
GetFileBasic: api.GetFileBasic,
|
||
SetText: api.SetText,
|
||
GetAnnotations: api.GetAnnotations,
|
||
Save: api.Save,
|
||
SaveAsDialog: api.SaveAsDialog,
|
||
},
|
||
ArchiveService: { ResolveFiles: api.ResolveFiles },
|
||
FileGUIService: { ApplyWorldDropEdit: api.ApplyWorldDropEdit },
|
||
}));
|
||
vi.mock("../src/stores/archive", () => ({
|
||
useArchiveStore: () => ({ refreshInfo: api.refreshInfo, open: true }),
|
||
}));
|
||
vi.mock("../src/stores/script", () => ({ useScriptStore: () => api }));
|
||
vi.mock("../src/stores/explorer", () => ({
|
||
useExplorerStore: () => ({
|
||
getFilePath: (index: number) => (index === 10 ? "etc/worlddrop.etc" : `file/${index}.txt`),
|
||
selectedKey: null,
|
||
refreshTreeTags: vi.fn(),
|
||
}),
|
||
}));
|
||
|
||
import { useEditorStore } from "../src/stores/editor";
|
||
|
||
describe("world drop entry & editor store integration", () => {
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia());
|
||
vi.resetAllMocks();
|
||
api.GetFileBasic.mockImplementation((index) => api.GetFile(index));
|
||
});
|
||
|
||
it.each([false, true])("openUpgrade: 工具入口打开对应概率文件并复用已有草稿(增幅=%s)", async (amplify) => {
|
||
const path = amplify ? "etc/amplifyupgrade.etc" : "etc/upgrade.etc";
|
||
api.ResolveFiles.mockResolvedValue([{ name: path.split('/').at(-1), path, isDir: false, fileIndex: 30 }]);
|
||
api.GetFile.mockResolvedValue({ index: 30, path, text: "original", editable: true, dataType: 1, size: 8, tags: [], annotations: [], modified: false });
|
||
const store = useEditorStore();
|
||
await store.openFile(30);
|
||
store.updateContent(30, "概率编辑草稿");
|
||
expect(await store.openUpgrade(amplify)).toBe(true);
|
||
expect(api.ResolveFiles).toHaveBeenCalledWith([path]);
|
||
expect(store.tabs.filter(tab => tab.index === 30)).toHaveLength(1);
|
||
expect(store.activeTab?.text).toBe("概率编辑草稿");
|
||
expect(store.getGUIModes(store.activePaneId).entries.get(30)?.mode).toBe("gui");
|
||
});
|
||
|
||
it("openForge: 工具入口打开锻造 GUI 并保留已有草稿", async () => {
|
||
api.ResolveFiles.mockResolvedValue([{ fileIndex: 10, path: "etc/upgrade_separate.etc", isDir: false }]);
|
||
api.GetFile.mockResolvedValue({ index: 10, path: "etc/upgrade_separate.etc", text: "original", editable: true, dataType: 1, size: 10, tags: [], annotations: [], modified: false });
|
||
const store = useEditorStore();
|
||
await store.openFile(10);
|
||
const tab = store.tabs.find(t => t.index === 10)!;
|
||
tab.text = "forge draft";
|
||
expect(await store.openForge()).toBe(true);
|
||
expect(api.ResolveFiles).toHaveBeenCalledWith(["etc/upgrade_separate.etc"]);
|
||
expect(tab.text).toBe("forge draft");
|
||
expect(store.getGUIModes(store.activePaneId).entries.get(10)?.mode).toBe("gui");
|
||
expect(store.tabs).toHaveLength(1);
|
||
api.ResolveFiles.mockResolvedValue([]);
|
||
await expect(store.openForge()).rejects.toThrow("未找到锻造文件");
|
||
});
|
||
|
||
it("openUpgrade: 缺失文件时清晰报错", async () => {
|
||
api.ResolveFiles.mockResolvedValue([]);
|
||
const store = useEditorStore();
|
||
await expect(store.openUpgrade()).rejects.toThrow("未找到强化文件 etc/upgrade.etc");
|
||
await expect(store.openUpgrade(true)).rejects.toThrow("未找到增幅文件 etc/amplifyupgrade.etc");
|
||
});
|
||
|
||
it("saveFileTab: 保存指定技能,不受当前活动标签影响", async () => {
|
||
api.GetFile.mockImplementation((index: number) => Promise.resolve({
|
||
index, path: `skill/test/${index}.skl`, text: "original", editable: true,
|
||
dataType: 1, size: 8, tags: [], annotations: [], modified: false,
|
||
}));
|
||
api.SetText.mockResolvedValue(undefined);
|
||
api.GetAnnotations.mockResolvedValue([]);
|
||
const store = useEditorStore();
|
||
await store.openFile(20);
|
||
await store.openFile(21);
|
||
store.updateContent(20, "技能参数修改");
|
||
store.updateContent(21, "其他草稿");
|
||
expect(store.activeTab?.index).toBe(21);
|
||
expect(await store.saveFileTab(20)).toBe(true);
|
||
expect(api.SetText).toHaveBeenCalledExactlyOnceWith(20, "技能参数修改");
|
||
expect(store.tabs.find((tab) => tab.index === 20)?.original).toBe("技能参数修改");
|
||
expect(store.tabs.find((tab) => tab.index === 21)?.original).toBe("original");
|
||
expect(api.refreshInfo).toHaveBeenCalledOnce();
|
||
expect(await store.saveFileTab(20)).toBe(false);
|
||
expect(store.saving).toBe(false);
|
||
});
|
||
|
||
it("openWorldDrop: 归档中缺少 etc/worlddrop.etc 时明确抛错", async () => {
|
||
api.ResolveFiles.mockResolvedValue([]);
|
||
const store = useEditorStore();
|
||
|
||
await expect(store.openWorldDrop()).rejects.toThrow("未找到全局掉率文件 etc/worlddrop.etc");
|
||
});
|
||
|
||
it("openWorldDrop: 成功解析后打开标签并切换到 GUI 模式", async () => {
|
||
api.ResolveFiles.mockResolvedValue([
|
||
{ name: "worlddrop.etc", path: "etc/worlddrop.etc", isDir: false, fileIndex: 10 },
|
||
]);
|
||
api.GetFile.mockResolvedValue({
|
||
index: 10,
|
||
path: "etc/worlddrop.etc",
|
||
text: "[world drop]\n\t1\t0\n\t-1\n",
|
||
editable: true,
|
||
dataType: 1,
|
||
size: 30,
|
||
tags: [],
|
||
annotations: [],
|
||
modified: false,
|
||
});
|
||
|
||
const store = useEditorStore();
|
||
const result = await store.openWorldDrop();
|
||
expect(result).toBe(true);
|
||
|
||
// 标签应已打开且活动
|
||
expect(store.activeTab?.index).toBe(10);
|
||
expect(store.activeTab?.path).toBe("etc/worlddrop.etc");
|
||
|
||
// 目标窗格中的模式应为 gui
|
||
const modes = store.getGUIModes(store.activePaneId);
|
||
expect(modes.entries.get(10)?.mode).toBe("gui");
|
||
});
|
||
|
||
it("openWorldDrop: 已打开的标签会被复用并切换到 GUI 模式", async () => {
|
||
api.ResolveFiles.mockResolvedValue([
|
||
{ name: "worlddrop.etc", path: "etc/worlddrop.etc", isDir: false, fileIndex: 10 },
|
||
]);
|
||
api.GetFile.mockResolvedValue({
|
||
index: 10,
|
||
path: "etc/worlddrop.etc",
|
||
text: "[world drop]\n\t1\t0\n\t-1\n",
|
||
editable: true,
|
||
dataType: 1,
|
||
size: 30,
|
||
tags: [],
|
||
annotations: [],
|
||
modified: false,
|
||
});
|
||
|
||
const store = useEditorStore();
|
||
// 先以常规文本方式打开
|
||
await store.openFile(10);
|
||
const modes = store.getGUIModes(store.activePaneId);
|
||
expect(modes.entries.get(10)?.mode ?? "text").toBe("text");
|
||
|
||
// 点击全局掉率入口复用
|
||
await store.openWorldDrop();
|
||
expect(store.tabs.filter((t) => t.index === 10)).toHaveLength(1);
|
||
expect(modes.entries.get(10)?.mode).toBe("gui");
|
||
});
|
||
|
||
it("applyWorldDropEdit: 草稿文本不匹配时拒绝提交;成功后更新标签内容与 modified", async () => {
|
||
api.GetFile.mockResolvedValue({
|
||
index: 10,
|
||
path: "etc/worlddrop.etc",
|
||
text: "original text",
|
||
editable: true,
|
||
dataType: 1,
|
||
size: 30,
|
||
tags: [],
|
||
annotations: [],
|
||
modified: false,
|
||
});
|
||
|
||
const store = useEditorStore();
|
||
await store.openFile(10);
|
||
const tab = store.tabs.find((t) => t.index === 10)!;
|
||
|
||
// 草稿已被修改而请求带着旧文本
|
||
tab.text = "modified draft";
|
||
await expect(
|
||
store.applyWorldDropEdit({
|
||
fileIndex: 10,
|
||
path: "etc/worlddrop.etc",
|
||
text: "stale text",
|
||
revision: 1,
|
||
levels: [],
|
||
}),
|
||
).rejects.toThrow("全局掉率草稿已变化");
|
||
|
||
// 正常匹配提交
|
||
const newText = "[world drop]\n\t5\t0\n\t100\t50\n\t-1\n";
|
||
api.ApplyWorldDropEdit.mockResolvedValue({
|
||
revision: 2,
|
||
modifiedCount: 1,
|
||
files: [
|
||
{
|
||
fileIndex: 10,
|
||
path: "etc/worlddrop.etc",
|
||
beforeText: "modified draft",
|
||
text: newText,
|
||
},
|
||
],
|
||
});
|
||
api.GetFile.mockResolvedValue({
|
||
index: 10,
|
||
path: "etc/worlddrop.etc",
|
||
text: newText,
|
||
editable: true,
|
||
dataType: 1,
|
||
size: newText.length,
|
||
tags: [],
|
||
annotations: [],
|
||
modified: true,
|
||
});
|
||
|
||
const result = await store.applyWorldDropEdit({
|
||
fileIndex: 10,
|
||
path: "etc/worlddrop.etc",
|
||
text: "modified draft",
|
||
revision: 1,
|
||
levels: [],
|
||
});
|
||
|
||
expect(result.revision).toBe(2);
|
||
expect(tab.text).toBe(newText);
|
||
expect(tab.original).toBe(newText);
|
||
expect(tab.modified).toBe(true);
|
||
});
|
||
|
||
it("registerGUIDirtyChecker: GUI 未保存修改能够被 isDirty 与 dirtyCount 感知", async () => {
|
||
api.GetFile.mockResolvedValue({
|
||
index: 10,
|
||
path: "etc/worlddrop.etc",
|
||
text: "original text",
|
||
editable: true,
|
||
dataType: 1,
|
||
size: 30,
|
||
tags: [],
|
||
annotations: [],
|
||
modified: false,
|
||
});
|
||
|
||
const store = useEditorStore();
|
||
await store.openFile(10);
|
||
const tab = store.tabs.find((t) => t.index === 10)!;
|
||
|
||
expect(store.dirtyCount).toBe(0);
|
||
|
||
const guiDirty = ref(true);
|
||
const unregister = store.registerGUIDirtyChecker(10, () => guiDirty.value);
|
||
|
||
expect(store.dirtyCount).toBe(1);
|
||
|
||
guiDirty.value = false;
|
||
expect(store.dirtyCount).toBe(0);
|
||
|
||
unregister();
|
||
guiDirty.value = true;
|
||
expect(store.dirtyCount).toBe(0);
|
||
});
|
||
|
||
it("registerGUIDirtyChecker: 支持多分屏独立注册,任意窗格有修改即判定为 dirty,注销一个不影响其他", async () => {
|
||
api.GetFile.mockResolvedValue({
|
||
index: 10,
|
||
path: "etc/worlddrop.etc",
|
||
text: "original text",
|
||
editable: true,
|
||
dataType: 1,
|
||
size: 30,
|
||
tags: [],
|
||
annotations: [],
|
||
modified: false,
|
||
});
|
||
|
||
const store = useEditorStore();
|
||
await store.openFile(10);
|
||
expect(store.dirtyCount).toBe(0);
|
||
|
||
const pane1Dirty = ref(false);
|
||
const pane2Dirty = ref(false);
|
||
|
||
const unreg1 = store.registerGUIDirtyChecker(10, () => pane1Dirty.value);
|
||
const unreg2 = store.registerGUIDirtyChecker(10, () => pane2Dirty.value);
|
||
|
||
// 此时两个都为 false
|
||
expect(store.hasGUIDirty(10)).toBe(false);
|
||
expect(store.dirtyCount).toBe(0);
|
||
|
||
// 窗格 1 修改
|
||
pane1Dirty.value = true;
|
||
expect(store.hasGUIDirty(10)).toBe(true);
|
||
expect(store.dirtyCount).toBe(1);
|
||
|
||
// 窗格 2 也修改
|
||
pane2Dirty.value = true;
|
||
expect(store.hasGUIDirty(10)).toBe(true);
|
||
|
||
// 窗格 1 恢复,但窗格 2 仍然 dirty
|
||
pane1Dirty.value = false;
|
||
expect(store.hasGUIDirty(10)).toBe(true);
|
||
expect(store.dirtyCount).toBe(1);
|
||
|
||
// 窗格 2 恢复
|
||
pane2Dirty.value = false;
|
||
expect(store.hasGUIDirty(10)).toBe(false);
|
||
expect(store.dirtyCount).toBe(0);
|
||
|
||
// 窗格 1 再次 dirty,然后注销窗格 2,窗格 1 仍生效
|
||
pane1Dirty.value = true;
|
||
unreg2();
|
||
expect(store.hasGUIDirty(10)).toBe(true);
|
||
|
||
// 注销窗格 1
|
||
unreg1();
|
||
expect(store.hasGUIDirty(10)).toBe(false);
|
||
});
|
||
|
||
it("save / saveAs / saveActiveTab: 阻止存在 GUI 未应用修改时的普通保存,并给出明确提示;草稿保存保持正常", async () => {
|
||
api.GetFile.mockResolvedValue({
|
||
index: 10,
|
||
path: "etc/worlddrop.etc",
|
||
text: "original text",
|
||
editable: true,
|
||
dataType: 1,
|
||
size: 30,
|
||
tags: [],
|
||
annotations: [],
|
||
modified: false,
|
||
});
|
||
api.SetText.mockResolvedValue(undefined);
|
||
api.GetAnnotations.mockResolvedValue([]);
|
||
api.Save.mockResolvedValue({ files: [] });
|
||
api.SaveAsDialog.mockResolvedValue("/path/to/new.pvf");
|
||
|
||
const store = useEditorStore();
|
||
await store.openFile(10);
|
||
const tab = store.tabs.find((t) => t.index === 10)!;
|
||
|
||
// 仅在 GUI 中有未应用修改(tab.text === tab.original)
|
||
const guiDirty = ref(true);
|
||
const unreg = store.registerGUIDirtyChecker(10, () => guiDirty.value);
|
||
|
||
expect(store.hasGUIOnlyDirty(tab)).toBe(true);
|
||
|
||
// 尝试 saveActiveTab
|
||
await expect(store.saveActiveTab()).rejects.toThrow(
|
||
"当前文件存在未应用的界面修改,请先在界面中点击【应用修改】后再保存",
|
||
);
|
||
expect(api.SetText).not.toHaveBeenCalled();
|
||
|
||
// 尝试 save
|
||
await expect(store.save()).rejects.toThrow(
|
||
"存在未应用的界面修改,请先在界面中点击【应用修改】后再保存",
|
||
);
|
||
expect(api.Save).not.toHaveBeenCalled();
|
||
|
||
// 尝试 saveAs
|
||
await expect(store.saveAs()).rejects.toThrow(
|
||
"存在未应用的界面修改,请先在界面中点击【应用修改】后再保存",
|
||
);
|
||
expect(api.SaveAsDialog).not.toHaveBeenCalled();
|
||
|
||
// 取消 GUI dirty 后,如果仅有普通文本草稿修改,正常保存成功
|
||
unreg();
|
||
expect(store.hasGUIOnlyDirty(tab)).toBe(false);
|
||
|
||
tab.text = "edited text draft";
|
||
expect(store.isTextDirty(tab)).toBe(true);
|
||
|
||
const activeSaved = await store.saveActiveTab();
|
||
expect(activeSaved).toBe(true);
|
||
expect(api.SetText).toHaveBeenCalledWith(10, "edited text draft");
|
||
|
||
tab.text = "another draft";
|
||
const saveResult = await store.save();
|
||
expect(saveResult).toBeDefined();
|
||
expect(api.Save).toHaveBeenCalled();
|
||
});
|
||
|
||
it("save / saveAs / saveActiveTab: 文本草稿与 GUI 表单同时 dirty 时,同样必须阻止保存并禁止静默遗留 GUI 修改", async () => {
|
||
api.GetFile.mockResolvedValue({
|
||
index: 10,
|
||
path: "etc/worlddrop.etc",
|
||
text: "original text",
|
||
editable: true,
|
||
dataType: 1,
|
||
size: 30,
|
||
tags: [],
|
||
annotations: [],
|
||
modified: false,
|
||
});
|
||
api.SetText.mockResolvedValue(undefined);
|
||
api.GetAnnotations.mockResolvedValue([]);
|
||
api.Save.mockResolvedValue({ files: [] });
|
||
api.SaveAsDialog.mockResolvedValue("/path/to/new.pvf");
|
||
|
||
const store = useEditorStore();
|
||
await store.openFile(10);
|
||
const tab = store.tabs.find((t) => t.index === 10)!;
|
||
|
||
// 模拟同时修改了 DSL 文本与 GUI 表单
|
||
tab.text = "modified text in dsl";
|
||
expect(store.isTextDirty(tab)).toBe(true);
|
||
|
||
const guiDirty = ref(true);
|
||
const unreg = store.registerGUIDirtyChecker(10, () => guiDirty.value);
|
||
expect(store.hasGUIDirty(tab.index)).toBe(true);
|
||
// hasGUIOnlyDirty 为 false(因为文本也是脏的),但仍有 GUI 脏数据待应用
|
||
expect(store.hasGUIOnlyDirty(tab)).toBe(false);
|
||
|
||
// 1. saveActiveTab 必须拦截,禁止只保存 DSL 文本而静默遗留 GUI 修改
|
||
await expect(store.saveActiveTab()).rejects.toThrow(
|
||
"当前文件存在未应用的界面修改,请先在界面中点击【应用修改】后再保存",
|
||
);
|
||
expect(api.SetText).not.toHaveBeenCalled();
|
||
|
||
// 2. save 必须拦截
|
||
await expect(store.save()).rejects.toThrow(
|
||
"存在未应用的界面修改,请先在界面中点击【应用修改】后再保存",
|
||
);
|
||
expect(api.Save).not.toHaveBeenCalled();
|
||
|
||
// 3. saveAs 必须拦截
|
||
await expect(store.saveAs()).rejects.toThrow(
|
||
"存在未应用的界面修改,请先在界面中点击【应用修改】后再保存",
|
||
);
|
||
expect(api.SaveAsDialog).not.toHaveBeenCalled();
|
||
|
||
// 4. 用户应用或放弃 GUI 修改后(GUI 不再 dirty),普通的文本草稿保存才能顺利执行
|
||
guiDirty.value = false;
|
||
expect(store.hasGUIDirty(tab.index)).toBe(false);
|
||
expect(store.isTextDirty(tab)).toBe(true);
|
||
|
||
const saved = await store.saveActiveTab();
|
||
expect(saved).toBe(true);
|
||
expect(api.SetText).toHaveBeenCalledWith(10, "modified text in dsl");
|
||
|
||
unreg();
|
||
});
|
||
});
|