DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,175 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import {
installActiveUndoBridge,
registerUndoHandler,
triggerActiveUndo,
triggerActiveRedo,
} from "../src/activeUndoBridge";
describe("activeUndoBridge", () => {
const listeners: Record<string, Function[]> = {};
beforeEach(() => {
// 确保 mock document/window 环境
if (typeof document === "undefined") {
(global as any).document = {
execCommand: vi.fn(),
addEventListener: vi.fn((type: string, fn: any) => {
listeners[type] = listeners[type] || [];
listeners[type].push(fn);
}),
removeEventListener: vi.fn(),
};
(global as any).window = {
addEventListener: vi.fn((type: string, fn: any) => {
listeners[type] = listeners[type] || [];
listeners[type].push(fn);
}),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn((evt: any) => {
const fns = listeners[evt.type] || [];
for (const fn of fns) fn(evt);
return !evt.defaultPrevented;
}),
};
} else {
if (!(window as any).dispatchEvent) {
(window as any).dispatchEvent = vi.fn((evt: any) => {
const fns = listeners[evt.type] || [];
for (const fn of fns) fn(evt);
return !evt.defaultPrevented;
});
}
}
});
it("应按优先级顺序调用活跃处理器的 undo 和 redo", () => {
const handlerLowUndo = vi.fn().mockReturnValue(false);
const handlerHighUndo = vi.fn().mockReturnValue(true);
const handlerHighRedo = vi.fn().mockReturnValue(true);
const unreg1 = registerUndoHandler({
undo: handlerLowUndo,
redo: () => false,
priority: 10,
});
const unreg2 = registerUndoHandler({
undo: handlerHighUndo,
redo: handlerHighRedo,
priority: 100,
});
// 触发 undo:应该优先调用 priority 100 的 handler
const res = triggerActiveUndo();
expect(res).toBe(true);
expect(handlerHighUndo).toHaveBeenCalledTimes(1);
expect(handlerLowUndo).not.toHaveBeenCalled();
// 触发 redo
const resRedo = triggerActiveRedo();
expect(resRedo).toBe(true);
expect(handlerHighRedo).toHaveBeenCalledTimes(1);
// 注销高优先级 handler
unreg2();
handlerLowUndo.mockReturnValue(true);
const res2 = triggerActiveUndo();
expect(res2).toBe(true);
expect(handlerLowUndo).toHaveBeenCalledTimes(1);
unreg1();
});
it("hook document.execCommand 能够成功拦截并调用 triggerActiveUndo", () => {
installActiveUndoBridge();
const undoFn = vi.fn().mockReturnValue(true);
const unreg = registerUndoHandler({
undo: undoFn,
redo: () => false,
priority: 50,
});
const execRes = document.execCommand("undo");
expect(execRes).toBe(true);
expect(undoFn).toHaveBeenCalledTimes(1);
unreg();
});
it("当高优先级处理器 isActive 为 false 时,应跳过并顺延至下一个活跃处理器", () => {
const inactiveUndo = vi.fn().mockReturnValue(true);
const activeUndo = vi.fn().mockReturnValue(true);
const unreg1 = registerUndoHandler({
undo: inactiveUndo,
redo: () => false,
priority: 100,
isActive: () => false,
});
const unreg2 = registerUndoHandler({
undo: activeUndo,
redo: () => false,
priority: 50,
isActive: () => true,
});
const res = triggerActiveUndo();
expect(res).toBe(true);
expect(inactiveUndo).not.toHaveBeenCalled();
expect(activeUndo).toHaveBeenCalledTimes(1);
unreg1();
unreg2();
});
it("当处理器执行抛出异常时,应安全捕获并继续尝试后续处理器", () => {
const errorUndo = vi.fn().mockImplementation(() => {
throw new Error("test crash");
});
const fallbackUndo = vi.fn().mockReturnValue(true);
const unreg1 = registerUndoHandler({
undo: errorUndo,
redo: () => false,
priority: 100,
});
const unreg2 = registerUndoHandler({
undo: fallbackUndo,
redo: () => false,
priority: 50,
});
const res = triggerActiveUndo();
expect(res).toBe(true);
expect(errorUndo).toHaveBeenCalledTimes(1);
expect(fallbackUndo).toHaveBeenCalledTimes(1);
unreg1();
unreg2();
});
it("beforeinput 事件应能触发撤销并阻止默认事件行为", () => {
installActiveUndoBridge();
const undoFn = vi.fn().mockReturnValue(true);
const unreg = registerUndoHandler({
undo: undoFn,
redo: () => false,
priority: 80,
});
const event = new Event("beforeinput", { cancelable: true });
Object.defineProperty(event, "inputType", { value: "historyUndo" });
window.dispatchEvent(event);
expect(undoFn).toHaveBeenCalledTimes(1);
expect(event.defaultPrevented).toBe(true);
unreg();
});
});
+725
View File
@@ -0,0 +1,725 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { createPinia, disposePinia, setActivePinia, type Pinia } from "pinia";
import type { ArchiveDiffFile, ArchiveDiffNode } from "../bindings/pvfine/services/models";
import { diffOperation, orientArchiveDiff, isExternalInputElement, isArchiveDiffShortcutActive } from "../src/archiveDiff";
const mocks = vi.hoisted(() => ({
Start: vi.fn(), Release: vi.fn(), Status: vi.fn(), Children: vi.fn(), FilteredChildren: vi.fn(), FileDiff: vi.fn(), Cancel: vi.fn(), SelectArchive: vi.fn(),
FlatFiles: vi.fn(), MergeText: vi.fn(), MergeFiles: vi.fn(), archiveDiffDrafts: vi.fn(), events: new Map<string, () => void>(),
}));
vi.mock("../bindings/pvfine/services", () => ({ ArchiveDiffService: mocks }));
vi.mock("../src/stores/editor", () => ({
useEditorStore: () => ({
archiveDiffDrafts: mocks.archiveDiffDrafts,
refreshMergedFiles: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock("@wailsio/runtime", () => ({ Events: { On: (name: string, callback: () => void) => mocks.events.set(name, callback) } }));
import { useArchiveDiffStore, preserveTreeExpansion, collectLeavesFromTree, type DiffTreeNode } from "../src/stores/archiveDiff";
const ready = { id: "1", state: "ready", currentPath: "current.pvf", otherPath: "other.pvf", processed: 10, total: 10, added: 2, modified: 1, deleted: 3, error: "" };
const file: ArchiveDiffFile = { path: "a.equ", operation: "add", beforeHash: "", afterHash: "hash", beforeType: 0, afterType: 1, beforeSize: 0, afterSize: 10, beforeExists: false, afterExists: true, beforeText: "", afterText: "new", textAvailable: true, message: "" };
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(yes => { resolve = yes; }); return { promise, resolve }; }
let pinia: Pinia;
beforeEach(() => {
pinia = createPinia(); setActivePinia(pinia); vi.resetAllMocks(); mocks.events.clear();
mocks.Start.mockResolvedValue(ready); mocks.Status.mockResolvedValue(ready);
mocks.Children.mockResolvedValue([{ path: "equipment", name: "equipment", isDir: true, count: 6, operation: "" }]);
mocks.FilteredChildren.mockImplementation((id: string, parent: string) => mocks.Children(id, parent));
mocks.Release.mockResolvedValue(undefined); mocks.Cancel.mockResolvedValue(undefined);
mocks.FileDiff.mockResolvedValue(file); mocks.archiveDiffDrafts.mockReturnValue([{ fileIndex: 0, path: "a.equ", text: "draft" }]);
});
afterEach(() => { useArchiveDiffStore().close(); disposePinia(pinia); });
test("默认另一份归档为基准,反向交换新增删除和所有详情侧且不重新比较", async () => {
const store = useArchiveDiffStore(); store.open(); store.otherPath = "other.pvf"; await store.refresh();
expect(store.reverse).toBe(false); expect(store.counts).toEqual({ added: 2, modified: 1, deleted: 3 });
await store.openFile({ path: "a.equ", name: "a.equ", isDir: false, operation: "add", count: 1 });
store.reverse = true;
expect(store.counts).toEqual({ added: 3, modified: 1, deleted: 2 });
expect(store.displayedFile).toMatchObject({ operation: "delete", beforeText: "new", afterText: "", beforeExists: true, afterExists: false });
expect(mocks.Start).toHaveBeenCalledTimes(1);
expect(diffOperation("modify", true)).toBe("modify");
expect(orientArchiveDiff(orientArchiveDiff(file, true), true)).toEqual(file);
// 反向对比下重新打开文件,可编辑草稿仍取本地工作区内容(afterText),不被对比侧覆盖
await store.openFile({ path: "a.equ", name: "a.equ", isDir: false, operation: "add", count: 1 });
expect(store.draftText).toBe("new");
});
test("选择归档后生成差异树,草稿传递且选择状态结束", async () => {
const store = useArchiveDiffStore(); store.open(); mocks.SelectArchive.mockResolvedValue("other.pvf");
await store.selectArchive();
expect(store.selecting).toBe(false);
expect(mocks.Start).toHaveBeenCalledWith({ path: "other.pvf", drafts: [{ fileIndex: 0, path: "a.equ", text: "draft" }] });
expect(store.tree[0]).toMatchObject({ key: "equipment", isLeaf: false, node: { count: 6 } });
mocks.Children.mockResolvedValue([{ path: "equipment/a.equ", name: "a.equ", isDir: false, operation: "add", count: 1 }]);
await store.loadChildren(store.tree[0]!);
expect(store.tree[0]?.children?.[0]).toMatchObject({ key: "equipment/a.equ", isLeaf: true });
});
test("取消选档不启动比较,未应用 GUI 修改保留原结果", async () => {
const store = useArchiveDiffStore(); store.open(); mocks.SelectArchive.mockResolvedValue(""); await store.selectArchive();
expect(mocks.Start).not.toHaveBeenCalled();
store.otherPath = "other.pvf"; await store.refresh();
mocks.archiveDiffDrafts.mockImplementation(() => { throw new Error("请先应用修改"); });
await store.refresh();
expect(store.error).toBe("请先应用修改"); expect(store.status?.id).toBe("1"); expect(mocks.Start).toHaveBeenCalledTimes(1);
});
test("关闭期间返回的 Start 被取消且不恢复已关闭弹窗", async () => {
const store = useArchiveDiffStore(); store.open(); store.otherPath = "other.pvf";
const pending = deferred<typeof ready>(); mocks.Start.mockReturnValue(pending.promise);
const promise = store.refresh(); store.close(); pending.resolve(ready); await promise;
expect(store.status).toBeNull(); expect(store.visible).toBe(false); expect(mocks.Cancel).toHaveBeenCalledWith("1");
});
test("切换工作区释放会话,过期详情不覆盖新选择", async () => {
const store = useArchiveDiffStore(); store.open(); store.otherPath = "other.pvf"; await store.refresh();
const pending = deferred<ArchiveDiffFile>(); mocks.FileDiff.mockReturnValueOnce(pending.promise);
const first = store.openFile({ path: "a.equ", name: "a", isDir: false, operation: "add", count: 1 });
mocks.FileDiff.mockResolvedValue({ ...file, path: "b.equ" });
await store.openFile({ path: "b.equ", name: "b", isDir: false, operation: "add", count: 1 });
pending.resolve(file); await first; expect(store.file?.path).toBe("b.equ");
mocks.events.get("archive:opened")?.(); expect(store.visible).toBe(false); expect(store.tree).toEqual([]); expect(mocks.Release).toHaveBeenCalled();
});
test("文本复核和建树期间仍处于忙碌状态", () => {
const store = useArchiveDiffStore();
for (const state of ["classifying", "verifying", "sorting", "building", "sorting-tree"]) {
store.status = { ...ready, state };
expect(store.busy).toBe(true);
}
store.status = ready;
expect(store.busy).toBe(false);
});
test("根节点返回前保持加载状态,取消后忽略旧树", async () => {
const store = useArchiveDiffStore(); store.open(); store.otherPath = "other.pvf";
const pending = deferred<[]>(); mocks.Children.mockReturnValue(pending.promise);
const refresh = store.refresh();
await vi.waitFor(() => expect(store.rootLoading).toBe(true));
expect(store.busy).toBe(true);
await store.cancel();
expect(store.busy).toBe(false);
pending.resolve([]); await refresh;
expect(store.tree).toEqual([]);
expect(store.status?.state).toBe("cancelled");
});
test("筛选后勾选目录仅选择匹配项,不包含隐藏文件", async () => {
const store = useArchiveDiffStore(); store.open(); store.otherPath = "other.pvf"; await store.refresh();
mocks.FlatFiles.mockResolvedValue([
{ path: "equipment/weapon/sword.equ", name: "sword.equ", isDir: false, operation: "add", count: 1 },
{ path: "equipment/weapon/axe.equ", name: "axe.equ", isDir: false, operation: "add", count: 1 },
]);
store.filterOperation = "add";
await store.toggleSelectDir("equipment/weapon", 2);
expect(store.selectedPaths).toEqual(new Set(["equipment/weapon/sword.equ", "equipment/weapon/axe.equ"]));
expect(store.isDirSelected("equipment/weapon", 2)).toBe(true);
expect(store.isDirIndeterminate("equipment", 5)).toBe(true);
});
test("归档重载后若已配置目标则自动刷新对比,未配置时保留页面选档能力", async () => {
const store = useArchiveDiffStore();
store.open();
store.otherPath = "other.pvf";
await store.refresh();
expect(mocks.Start).toHaveBeenCalledTimes(1);
// 触发归档重载事件:若已配置对比目标,自动刷新比较
mocks.events.get("archive:reloaded")?.();
await vi.waitFor(() => expect(mocks.Start).toHaveBeenCalledTimes(2));
expect(store.otherPath).toBe("other.pvf");
expect(store.status?.state).toBe("ready");
// 若关闭后在残留页面重新选档,选档不受中断影响且正常恢复比较
store.close();
expect(store.visible).toBe(false);
mocks.SelectArchive.mockResolvedValue("new_target.pvf");
await store.selectArchive();
expect(store.visible).toBe(true);
expect(store.otherPath).toBe("new_target.pvf");
expect(mocks.Start).toHaveBeenCalledTimes(3);
});
test("Start 未返回时重载可重新启动,旧会话不会覆盖新结果", async () => {
const store = useArchiveDiffStore();
store.otherPath = "other.pvf";
const pending = deferred<typeof ready>();
mocks.Start.mockReturnValueOnce(pending.promise).mockResolvedValue({ ...ready, id: "2" });
mocks.Status.mockResolvedValue({ ...ready, id: "2" });
const first = store.refresh();
expect(store.starting).toBe(true);
mocks.events.get("archive:reloaded")?.();
pending.resolve(ready);
await first;
await vi.waitFor(() => expect(store.status?.id).toBe("2"));
await vi.waitFor(() => expect(store.busy).toBe(false));
expect(mocks.Cancel).toHaveBeenCalledWith("1");
expect(mocks.Start).toHaveBeenCalledTimes(2);
});
test("重载等待取消时关闭工作台不会重新启动", async () => {
const store = useArchiveDiffStore();
store.otherPath = "other.pvf";
await store.refresh();
const pending = deferred<void>();
mocks.Cancel.mockReturnValueOnce(pending.promise);
mocks.events.get("archive:reloaded")?.();
store.close();
pending.resolve();
await Promise.resolve();
expect(mocks.Start).toHaveBeenCalledTimes(1);
expect(store.status).toBeNull();
});
test("筛选重读已加载目录,移除旧操作及已合并文件", async () => {
const store = useArchiveDiffStore();
store.otherPath = "other.pvf";
await store.refresh();
const added = { path: "equipment/a.equ", name: "a.equ", isDir: false, operation: "add", count: 1 };
const modified = { ...added, path: "equipment/b.equ", name: "b.equ", operation: "modify" };
mocks.Children.mockResolvedValueOnce([added, modified]);
await store.loadChildren(store.tree[0]!);
mocks.FilteredChildren.mockImplementation(async (_id, parent, op) =>
parent ? (op === "modify" ? [modified] : [added, modified]) :
[{ path: "equipment", name: "equipment", isDir: true, count: op === "modify" ? 1 : 2, operation: "" }]);
await store.setFilterOperation("modify");
expect(collectLeavesFromTree(store.tree).map(node => node.path)).toEqual(["equipment/b.equ"]);
mocks.FilteredChildren.mockResolvedValue([]);
await store.refreshCurrentView("equipment/b.equ");
expect(store.tree).toEqual([]);
});
test("后返回的旧筛选与旧平铺请求不能恢复过期数据", async () => {
const store = useArchiveDiffStore();
store.otherPath = "other.pvf";
await store.refresh();
const staleNode = { path: "stale.equ", name: "stale.equ", isDir: false, operation: "modify", count: 1 };
const pending = deferred<ArchiveDiffNode[]>();
mocks.FilteredChildren.mockReturnValueOnce(pending.promise).mockResolvedValue([]);
const oldFilter = store.setFilterOperation("modify");
await store.setFilterOperation("delete");
pending.resolve([staleNode]);
await oldFilter;
expect(store.filterOperation).toBe("delete");
expect(store.tree).toEqual([]);
const flat = deferred<ArchiveDiffNode[]>();
mocks.FlatFiles.mockReturnValueOnce(flat.promise);
const loading = store.loadFlatFiles();
store.close();
flat.resolve([staleNode]);
await loading;
expect(store.flatFiles).toEqual([]);
expect(store.flatLoading).toBe(false);
});
test("清空搜索后旧请求不能恢复搜索树,平铺保存同步更新目录树", async () => {
const store = useArchiveDiffStore();
store.otherPath = "other.pvf";
await store.refresh();
const pending = deferred<ArchiveDiffNode[]>();
mocks.FlatFiles.mockReturnValueOnce(pending.promise);
store.searchQuery = "stale";
const searching = store.searchTree("stale");
store.clearSearch();
pending.resolve([{ path: "stale.equ", name: "stale.equ", isDir: false, operation: "add", count: 1 }]);
await searching;
store.searchQuery = "another";
expect(store.displayedTree).toEqual([]);
store.clearSearch();
store.listMode = "flat";
mocks.FilteredChildren.mockResolvedValue([]);
await store.refreshCurrentView("equipment/a.equ");
expect(store.tree).toEqual([]);
});
test("保存期间继续输入不会丢草稿,保存后刷新统计", async () => {
const store = useArchiveDiffStore();
store.otherPath = "other.pvf";
await store.refresh();
store.setDraft("a.equ", "saved", "original");
const pending = deferred<void>();
mocks.MergeText.mockReturnValueOnce(pending.promise);
mocks.Status.mockResolvedValue({ ...ready, modified: 4 });
const saving = store.mergeText("a.equ", "saved");
store.updateDraftText("keep typing");
pending.resolve();
expect(await saving).toBe(true);
expect(store.draftText).toBe("keep typing");
expect(store.originalDraftText).toBe("saved");
expect(store.isDraftDirty).toBe(true);
expect(store.counts.modified).toBe(4);
});
test("根据关键字过滤后勾选目录仅选择匹配文件,绝不包含被隐藏的文件", async () => {
const store = useArchiveDiffStore();
store.open();
store.otherPath = "other.pvf";
await store.refresh();
// 模拟搜索 sword:后端仅返回匹配项,被关键字隐藏的 axe.equ 不应被选中
mocks.FlatFiles.mockResolvedValue([
{ path: "equipment/weapon/sword.equ", name: "sword.equ", isDir: false, operation: "modify", count: 1 },
]);
store.searchQuery = "sword";
await store.searchTree("sword");
// 确认搜索树已构建
expect(store.displayedTree.length).toBe(1);
expect(store.displayedTree[0].node.path).toBe("equipment");
// 勾选顶层 equipment 目录
await store.toggleSelectDir("equipment");
// 断言:只有匹配的 sword.equ 被选中,绝不包含被隐藏的文件
expect(store.selectedPaths).toEqual(new Set(["equipment/weapon/sword.equ"]));
expect(store.isDirSelected("equipment", 1)).toBe(true);
expect(store.isDirSelected("equipment/weapon", 1)).toBe(true);
// 再次勾选目录执行反选,完全清空已选项
await store.toggleSelectDir("equipment");
expect(store.selectedPaths.size).toBe(0);
expect(store.isDirSelected("equipment", 1)).toBe(false);
});
test("方案 B(物理侧锚定):反向视角下 store.file 保持原始物理归属,可编辑草稿严格锚定本地当前内容", async () => {
const store = useArchiveDiffStore();
store.open();
store.otherPath = "other.pvf";
await store.refresh();
// 原始差异数据:本地(after)为 local_content,外部对比目标(before)为 target_content
const diffFile: ArchiveDiffFile = {
path: "test.equ",
operation: "modify",
beforeHash: "h1",
afterHash: "h2",
beforeType: 1,
afterType: 1,
beforeSize: 20,
afterSize: 30,
beforeExists: true,
afterExists: true,
beforeText: "target_content",
afterText: "local_content",
textAvailable: true,
message: "",
};
mocks.FileDiff.mockResolvedValue(diffFile);
await store.openFile({ path: "test.equ", name: "test.equ", isDir: false, operation: "modify", count: 1 });
// 1. 正向视角下:
expect(store.reverse).toBe(false);
expect(store.file?.afterText).toBe("local_content");
expect(store.file?.beforeText).toBe("target_content");
expect(store.draftText).toBe("local_content");
// 用户修改了草稿
store.updateDraftText("local_content_modified");
expect(store.isDraftDirty).toBe(true);
// 2. 切换为反向视角(方案 B):
store.toggleReverse();
expect(store.reverse).toBe(true);
// 物理侧原始数据 store.file 绝对不被原地翻转修改(双栏编辑器消费此对象)
expect(store.file?.afterText).toBe("local_content");
expect(store.file?.beforeText).toBe("target_content");
// 草稿依然保留用户修改,绝不被目标归档覆盖
expect(store.draftText).toBe("local_content_modified");
expect(store.isDraftDirty).toBe(true);
// 仅供单栏差异和树形列表消费的 displayedFile 执行逻辑翻转
expect(store.displayedFile?.beforeText).toBe("local_content");
expect(store.displayedFile?.afterText).toBe("target_content");
});
test("openFileDirect 在静默模式下不置空 file 且不设置 fileLoading 为 true", async () => {
const store = useArchiveDiffStore();
store.open();
store.otherPath = "other.pvf";
await store.refresh();
// 第一次正常打开文件(非静默)
await store.openFile({ path: "a.equ", name: "a.equ", isDir: false, operation: "add", count: 1 });
expect(store.file?.path).toBe("a.equ");
expect(store.fileLoading).toBe(false);
// 构造异步挂起模拟静默拉取过程
const pending = deferred<ArchiveDiffFile>();
mocks.FileDiff.mockReturnValueOnce(pending.promise);
const silentPromise = store.openFileDirect({ path: "a.equ", name: "a.equ", isDir: false, operation: "add", count: 1 }, true);
// 静默模式下:fileLoading 应保持 false,file 应继续保留原先的引用(不被置为 null 导致外部组件卸载闪烁)
expect(store.fileLoading).toBe(false);
expect(store.file).not.toBeNull();
expect(store.file?.path).toBe("a.equ");
pending.resolve({ ...file, afterText: "updated_after_save" });
await silentPromise;
expect(store.file?.afterText).toBe("updated_after_save");
expect(store.fileLoading).toBe(false);
});
test("preserveTreeExpansion 保留加载层级但从后端刷新内容", async () => {
const oldTree: DiffTreeNode[] = [
{
key: "equipment",
label: "equipment",
isLeaf: false,
node: { path: "equipment", name: "equipment", isDir: true, count: 2, operation: "" },
children: [
{
key: "equipment/weapon",
label: "weapon",
isLeaf: false,
node: { path: "equipment/weapon", name: "weapon", isDir: true, count: 1, operation: "" },
children: [
{
key: "equipment/weapon/sword.equ",
label: "sword.equ",
isLeaf: true,
node: { path: "equipment/weapon/sword.equ", name: "sword.equ", isDir: false, count: 1, operation: "add" },
},
],
},
],
},
];
// 新拉取的树只有 1 级节点(未展开,children 为 undefined)
const newTree: DiffTreeNode[] = [
{
key: "equipment",
label: "equipment",
isLeaf: false,
node: { path: "equipment", name: "equipment", isDir: true, count: 2, operation: "" },
},
];
const load = vi.fn(async (path: string) =>
path === "equipment" ? oldTree[0].children! : []);
await preserveTreeExpansion(newTree, oldTree, load);
// 断言:newTree 的 equipment 节点成功保留了已展开的 children
expect(newTree[0].children).toBeDefined();
expect(newTree[0].children?.length).toBe(1);
expect(newTree[0].children?.[0].key).toBe("equipment/weapon");
expect(newTree[0].children?.[0].children).toEqual([]);
expect(load.mock.calls.map(([path]) => path)).toEqual(["equipment", "equipment/weapon"]);
});
test("refreshTreeForSavedFile 针对已展开的父目录进行局部刷新,不破坏整树状态", async () => {
const store = useArchiveDiffStore();
store.open();
store.otherPath = "other.pvf";
await store.refresh();
// 展开 equipment 目录
const equipmentNode = store.tree[0]!;
mocks.FilteredChildren.mockResolvedValueOnce([
{ path: "equipment/a.equ", name: "a.equ", isDir: false, operation: "add", count: 1 },
]);
await store.loadChildren(equipmentNode);
expect(equipmentNode.children?.length).toBe(1);
// 模拟保存 equipment/a.equ 后的局部刷新
mocks.FilteredChildren.mockImplementation(async (_id: string, parent: string) => {
if (parent === "equipment") {
// 局部刷新 equipment 目录下仅返回最新数据
return [{ path: "equipment/a.equ", name: "a.equ", isDir: false, operation: "modify", count: 1 }];
}
if (parent === "") {
// 根节点最新计数值
return [{ path: "equipment", name: "equipment", isDir: true, count: 1, operation: "" }];
}
return [];
});
await store.refreshCurrentView("equipment/a.equ");
// 断言:equipment 节点的 children 得到局部精准更新,且整树结构未被坍塌
expect(store.tree.length).toBe(1);
expect(store.tree[0].node.path).toBe("equipment");
expect(store.tree[0].children?.length).toBe(1);
expect(store.tree[0].children?.[0].node.operation).toBe("modify");
});
test("上一个差异文件与下一个差异文件导航能够按序切换并自动展开目录", async () => {
const store = useArchiveDiffStore();
store.open();
store.otherPath = "other.pvf";
await store.refresh();
// 模拟 flatFiles 列表(3 个按路径升序的文件)
store.flatFiles = [
{ path: "character/swordman.ani", name: "swordman.ani", isDir: false, operation: "modify", count: 1 },
{ path: "equipment/weapon/katana.equ", name: "katana.equ", isDir: false, operation: "add", count: 1 },
{ path: "equipment/weapon/sword.equ", name: "sword.equ", isDir: false, operation: "modify", count: 1 },
];
// 初始状态下未选中任何文件,冷启动就绪(允许导航到第 1 个文件)
expect(store.currentFileIndex).toBe(-1);
expect(store.hasPreviousFile).toBe(false);
expect(store.hasNextFile).toBe(true);
expect(store.fileNavIndicator).toBe("0 / 3");
mocks.FileDiff.mockImplementation(async (_id: string, p: string) => ({
...file,
path: p,
}));
// 打开第 1 个文件
await store.openFile(store.flatFiles[0]!);
expect(store.currentFileIndex).toBe(0);
expect(store.hasPreviousFile).toBe(false);
expect(store.hasNextFile).toBe(true);
expect(store.fileNavIndicator).toBe("1 / 3");
// 点击“下一个差异文件” -> 切换到第 2 个文件 katana.equ
mocks.FileDiff.mockResolvedValueOnce({
...file,
path: "equipment/weapon/katana.equ",
afterText: "katana_content",
});
const nextRes = await store.openNextFile();
expect(nextRes).toBe(true);
expect(store.currentFileIndex).toBe(1);
expect(store.file?.path).toBe("equipment/weapon/katana.equ");
expect(store.hasPreviousFile).toBe(true);
expect(store.hasNextFile).toBe(true);
expect(store.fileNavIndicator).toBe("2 / 3");
// 确认祖先目录已自动加入 expandedKeys
expect(store.expandedKeys).toEqual(expect.arrayContaining(["equipment", "equipment/weapon"]));
// 再次点击“下一个差异文件” -> 切换到第 3 个文件(最后一个)
mocks.FileDiff.mockResolvedValueOnce({
...file,
path: "equipment/weapon/sword.equ",
afterText: "sword_content",
});
await store.openNextFile();
expect(store.currentFileIndex).toBe(2);
expect(store.file?.path).toBe("equipment/weapon/sword.equ");
expect(store.hasPreviousFile).toBe(true);
expect(store.hasNextFile).toBe(false); // 最后一个文件,不能再点下一个
expect(store.fileNavIndicator).toBe("3 / 3");
// 点击“上一个差异文件” -> 回退到第 2 个文件 katana.equ
mocks.FileDiff.mockResolvedValueOnce({
...file,
path: "equipment/weapon/katana.equ",
afterText: "katana_content",
});
const prevRes = await store.openPreviousFile();
expect(prevRes).toBe(true);
expect(store.currentFileIndex).toBe(1);
expect(store.file?.path).toBe("equipment/weapon/katana.equ");
expect(store.fileNavIndicator).toBe("2 / 3");
});
test("有未保存草稿时点击下一个文件触发确认弹窗保护,取消后高亮不漂移且不产生展开副作用", async () => {
const store = useArchiveDiffStore();
store.open();
store.otherPath = "other.pvf";
await store.refresh();
store.flatFiles = [
{ path: "a.equ", name: "a.equ", isDir: false, operation: "modify", count: 1 },
{ path: "equipment/weapon/b.equ", name: "b.equ", isDir: false, operation: "modify", count: 1 },
];
await store.openFile(store.flatFiles[0]!);
expect(store.currentFileIndex).toBe(0);
expect(store.selectedKeys).toEqual(["a.equ"]);
expect(store.expandedKeys).toEqual([]);
// 用户编辑了当前文件,草稿变脏
store.updateDraftText("unsaved_dirty_text");
expect(store.isDraftDirty).toBe(true);
// 点击下一个文件 -> 应当被拦截并弹出保存确认弹窗
const switched = await store.openNextFile();
expect(switched).toBe(false);
expect(store.showDraftConfirmModal).toBe(true);
expect(store.pendingSwitchNode?.path).toBe("equipment/weapon/b.equ");
// 核心断言:当前文件仍停留在 a.equ,高亮与祖先目录绝不提前被展开修改(事务零副作用)
expect(store.currentFileIndex).toBe(0);
expect(store.selectedKeys).toEqual(["a.equ"]);
expect(store.expandedKeys).toEqual([]);
// 用户取消弹窗切换
store.cancelDraftSwitch();
expect(store.showDraftConfirmModal).toBe(false);
expect(store.pendingSwitchNode).toBeNull();
expect(store.selectedKeys).toEqual(["a.equ"]);
expect(store.expandedKeys).toEqual([]);
expect(store.file?.path).toBe("a.equ");
});
test("初始未选中文件时支持冷启动一键打开第 1 个差异文件", async () => {
const store = useArchiveDiffStore();
store.open();
store.otherPath = "other.pvf";
await store.refresh();
store.flatFiles = [
{ path: "first.equ", name: "first.equ", isDir: false, operation: "add", count: 1 },
{ path: "second.equ", name: "second.equ", isDir: false, operation: "modify", count: 1 },
];
// 初始未选中文件
expect(store.currentFileIndex).toBe(-1);
expect(store.hasPreviousFile).toBe(false);
expect(store.hasNextFile).toBe(true); // 冷启动就绪
expect(store.fileNavIndicator).toBe("0 / 2");
mocks.FileDiff.mockResolvedValueOnce({
...file,
path: "first.equ",
afterText: "first_content",
});
// 点击下一个文件 -> 触发冷启动,成功打开第 1 个文件
const ok = await store.openNextFile();
expect(ok).toBe(true);
expect(store.currentFileIndex).toBe(0);
expect(store.file?.path).toBe("first.equ");
expect(store.selectedKeys).toEqual(["first.equ"]);
expect(store.fileNavIndicator).toBe("1 / 2");
expect(store.hasPreviousFile).toBe(false);
expect(store.hasNextFile).toBe(true);
});
test("差异文件列表为空时的边界处理", async () => {
const store = useArchiveDiffStore();
store.open();
store.otherPath = "other.pvf";
await store.refresh();
store.flatFiles = [];
expect(store.navigableFiles.length).toBe(0);
expect(store.currentFileIndex).toBe(-1);
expect(store.hasPreviousFile).toBe(false);
expect(store.hasNextFile).toBe(false);
expect(store.fileNavIndicator).toBe("");
const prevRes = await store.openPreviousFile();
const nextRes = await store.openNextFile();
expect(prevRes).toBe(false);
expect(nextRes).toBe(false);
});
test("反向对比模式下正确映射操作类型并联动过滤", async () => {
const store = useArchiveDiffStore();
store.open();
store.otherPath = "other.pvf";
await store.refresh();
// 正向视角下为 add 的文件,在反向对比下应显示为 delete
store.flatFiles = [
{ path: "add_item.equ", name: "add_item.equ", isDir: false, operation: "add", count: 1 },
];
// 正向模式筛选 delete -> 无结果
store.filterOperation = "delete";
store.reverse = false;
expect(store.navigableFiles.length).toBe(0);
// 开启反向对比后,原本的 add 被映射为 delete -> 可被检索到
store.reverse = true;
expect(store.navigableFiles.length).toBe(1);
expect(store.navigableFiles[0]!.path).toBe("add_item.equ");
});
test("collectLeavesFromTree 能够正确递归收集多级目录下的叶子文件并过滤", () => {
const treeData: DiffTreeNode[] = [
{
key: "equipment",
label: "equipment",
isLeaf: false,
node: { path: "equipment", name: "equipment", isDir: true, operation: "", count: 2 },
children: [
{
key: "equipment/weapon",
label: "weapon",
isLeaf: false,
node: { path: "equipment/weapon", name: "weapon", isDir: true, operation: "", count: 1 },
children: [
{
key: "equipment/weapon/katana.equ",
label: "katana.equ",
isLeaf: true,
node: { path: "equipment/weapon/katana.equ", name: "katana.equ", isDir: false, operation: "add", count: 1 },
},
],
},
{
key: "equipment/armor.equ",
label: "armor.equ",
isLeaf: true,
node: { path: "equipment/armor.equ", name: "armor.equ", isDir: false, operation: "modify", count: 1 },
},
],
},
];
// 全量收集
const allLeaves = collectLeavesFromTree(treeData, "all", false, "");
expect(allLeaves.map((n) => n.path)).toEqual([
"equipment/weapon/katana.equ",
"equipment/armor.equ",
]);
// 按操作过滤 modify
const modifyLeaves = collectLeavesFromTree(treeData, "modify", false, "");
expect(modifyLeaves.map((n) => n.path)).toEqual(["equipment/armor.equ"]);
// 按关键字过滤 "katana"
const searchedLeaves = collectLeavesFromTree(treeData, "all", false, "katana");
expect(searchedLeaves.map((n) => n.path)).toEqual(["equipment/weapon/katana.equ"]);
});
test("isExternalInputElement 能够精准识别外部输入控件与编辑器内焦点", () => {
expect(isExternalInputElement(null)).toBe(false);
// 普通外部单行输入框与多行文本域
const inputEl = { tagName: "INPUT", isContentEditable: false, closest: () => null } as unknown as Element;
expect(isExternalInputElement(inputEl)).toBe(true);
const textareaEl = { tagName: "TEXTAREA", isContentEditable: false, closest: () => null } as unknown as Element;
expect(isExternalInputElement(textareaEl)).toBe(true);
// 普通无输入能力的 div 与按钮
const divEl = { tagName: "DIV", isContentEditable: false, closest: () => null } as unknown as Element;
expect(isExternalInputElement(divEl)).toBe(false);
// 处于 CodeMirror 编辑器内部的 contenteditable 容器
const cmContent = {
tagName: "DIV",
isContentEditable: true,
closest: (selector: string) => (selector === ".cm-editor" ? {} : null),
} as unknown as Element;
expect(isExternalInputElement(cmContent)).toBe(false);
// 包含在 editorContainer 内部的元素
const editorContainer = {
contains: (target: any) => target === cmContent,
} as unknown as Element;
expect(isExternalInputElement(cmContent, editorContainer)).toBe(false);
const otherEditor = { contains: () => false } as unknown as Element;
expect(isExternalInputElement(cmContent, otherEditor)).toBe(true);
// 独立的外部富文本可编辑元素
const richTextEl = {
tagName: "DIV",
isContentEditable: true,
closest: () => null,
} as unknown as Element;
expect(isExternalInputElement(richTextEl)).toBe(true);
});
test("对比快捷键仅在活动视图响应,主编辑器与模态框焦点不被劫持", () => {
const inside = { tagName: "DIV", isContentEditable: true, closest: (s: string) => s === ".cm-editor" ? {} : null } as unknown as Element;
const foreign = { ...inside } as unknown as Element;
const container = { contains: (el: Element) => el === inside } as unknown as Element;
expect(isArchiveDiffShortcutActive(false, inside, container)).toBe(false);
expect(isArchiveDiffShortcutActive(true, inside, container)).toBe(true);
expect(isArchiveDiffShortcutActive(true, foreign, container)).toBe(false);
const dialog = { tagName: "BUTTON", closest: () => ({}) } as unknown as Element;
expect(isArchiveDiffShortcutActive(true, dialog, container)).toBe(false);
expect(isArchiveDiffShortcutActive(true, inside, null)).toBe(false);
});
@@ -0,0 +1,211 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
const api = vi.hoisted(() => ({
Open: vi.fn(),
OpenDialog: vi.fn(),
Close: vi.fn(),
IndexStatus: vi.fn(),
RebuildSearchIndex: vi.fn(),
SetTextEncoding: vi.fn(),
}));
const events = vi.hoisted(() => new Map<string, (event: unknown) => void>());
vi.mock("@wailsio/runtime", () => ({
Events: { On: (name: string, callback: (event: unknown) => void) => events.set(name, callback) },
}));
vi.mock("../bindings/pvfine/services", () => ({ ArchiveService: api, EditorService: {} }));
import { useArchiveStore } from "../src/stores/archive";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function emit(name: string, data?: unknown) {
events.get(name)?.({ data });
}
async function flush() {
await Promise.resolve();
await Promise.resolve();
}
beforeEach(() => {
vi.useFakeTimers();
vi.resetAllMocks();
events.clear();
setActivePinia(createPinia());
vi.stubGlobal("window", {
setInterval: (...args: Parameters<typeof setInterval>) => globalThis.setInterval(...args),
clearInterval: (handle: number) => globalThis.clearInterval(handle),
localStorage: { getItem: () => null, setItem: vi.fn() },
});
api.IndexStatus.mockResolvedValue({ state: "building", stage: "sqlite" });
api.Close.mockResolvedValue(undefined);
});
test("string table events retain timings without restarting semantic indexing", () => {
const store = useArchiveStore();
store.info = { path: "/game/Script.pvf" } as NonNullable<typeof store.info>;
emit("archive:string-table-index", {
archivePath: "/game/Script.pvf",
stats: { revision: 4, state: "ready", tableBuilds: 1, entries: 100, mappingDurationMs: 2, tableDurationMs: 12, buildDurationMs: 14 },
});
expect(store.stringTableIndex.buildDurationMs).toBe(14);
expect(store.stringTableIndex.tableBuilds).toBe(1);
expect(store.stringTableIndex.entries).toBe(100);
expect(vi.getTimerCount()).toBe(0);
emit("archive:string-table-index", { archivePath: "/game/Script.pvf", stats: { revision: 3, buildDurationMs: 10 } });
expect(store.stringTableIndex.buildDurationMs).toBe(14);
emit("archive:string-table-index", { archivePath: "/game/old.pvf", stats: { revision: 10, buildDurationMs: 200 } });
expect(store.stringTableIndex.buildDurationMs).toBe(14);
emit("archive:string-table-index", {
archivePath: "/game/Script.pvf", kind: "snapshot", stats: { revision: 0, state: "idle" },
});
expect(store.stringTableIndex.state).toBe("idle");
expect(store.stringTableIndex.buildDurationMs).toBe(0);
emit("archive:closed");
expect(store.stringTableIndex.state).toBe("idle");
expect(store.stringTableIndex.buildDurationMs).toBe(0);
});
test("index polling restores missing string table statistics", async () => {
api.IndexStatus.mockResolvedValue({
state: "ready", stringTableIndex: { revision: 4, state: "ready", tableBuilds: 2, buildDurationMs: 30 },
});
const store = useArchiveStore();
emit("archive:opened", { path: "/game/Script.pvf" });
await flush();
expect(store.stringTableIndex.buildDurationMs).toBe(30);
expect(store.stringTableIndex.tableBuilds).toBe(2);
expect(store.indexReady).toBe(true);
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
});
test("a stale poll cannot overwrite the ready event or restart indexing", async () => {
const pending = deferred<{ state: string }>();
api.IndexStatus.mockReturnValueOnce(pending.promise);
const store = useArchiveStore();
emit("archive:opened", { path: "/game/Script.pvf" });
emit("archive:index-ready", { state: "ready", stage: "ready-sqlite", done: 10, total: 10 });
pending.resolve({ state: "building" });
await flush();
expect(store.indexReady).toBe(true);
expect(store.indexing).toBe(false);
expect(store.indexStatus.stage).toBe("ready-sqlite");
expect(vi.getTimerCount()).toBe(0);
});
test("a stale poll cannot overwrite an error event", async () => {
const pending = deferred<{ state: string }>();
api.IndexStatus.mockReturnValueOnce(pending.promise);
const store = useArchiveStore();
emit("archive:opened", { path: "/game/Script.pvf" });
emit("archive:index-error", { state: "error", error: "build failed" });
pending.resolve({ state: "building" });
await flush();
expect(store.indexStatus.state).toBe("error");
expect(store.indexStatus.error).toBe("build failed");
expect(vi.getTimerCount()).toBe(0);
});
test("a stale poll cannot overwrite newer progress", async () => {
const pending = deferred<{ state: string; done: number }>();
api.IndexStatus.mockReturnValueOnce(pending.promise);
const store = useArchiveStore();
emit("archive:opened", { path: "/game/Script.pvf" });
emit("archive:index-progress", { state: "building", done: 20, total: 100 });
pending.resolve({ state: "building", done: 5 });
await flush();
expect(store.indexStatus.done).toBe(20);
expect(vi.getTimerCount()).toBe(1);
});
test("a poll from a closed archive cannot change the next archive's state", async () => {
const pending = deferred<{ state: string }>();
api.IndexStatus.mockReturnValueOnce(pending.promise);
const store = useArchiveStore();
emit("archive:opened", { path: "/game/first.pvf" });
emit("archive:closed");
emit("archive:opened", { path: "/game/second.pvf" });
pending.resolve({ state: "ready" });
await flush();
expect(store.info?.path).toBe("/game/second.pvf");
expect(store.indexing).toBe(true);
await vi.advanceTimersByTimeAsync(250);
expect(api.IndexStatus).toHaveBeenCalledTimes(2);
});
test("polling retries transient errors and recovers a missed ready event", async () => {
api.IndexStatus.mockRejectedValueOnce(new Error("temporary error"))
.mockResolvedValueOnce({ state: "ready", stage: "ready-sqlite" });
const store = useArchiveStore();
emit("archive:opened", { path: "/game/Script.pvf" });
await flush();
expect(store.indexing).toBe(true);
await vi.advanceTimersByTimeAsync(250);
expect(store.indexReady).toBe(true);
expect(vi.getTimerCount()).toBe(0);
});
test("background refresh keeps polling until refreshing is false", async () => {
const store = useArchiveStore();
emit("archive:opened", { path: "/game/Script.pvf" });
await flush();
emit("archive:index-ready", { state: "ready", refreshing: true });
api.IndexStatus.mockResolvedValueOnce({ state: "ready", refreshing: false });
expect(vi.getTimerCount()).toBe(1);
await vi.advanceTimersByTimeAsync(250);
expect(store.refreshingIndex).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
test("rebuild starts fallback polling when progress and ready events are missed", async () => {
const store = useArchiveStore();
store.info = { path: "/game/Script.pvf" } as NonNullable<typeof store.info>;
api.RebuildSearchIndex.mockResolvedValue({ state: "building" });
api.IndexStatus.mockResolvedValueOnce({ state: "ready" });
await store.rebuildSearchIndex();
await flush();
expect(store.indexReady).toBe(true);
expect(api.IndexStatus).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
test("a stale rebuild response cannot overwrite the ready event", async () => {
const pending = deferred<{ state: string }>();
const store = useArchiveStore();
store.info = { path: "/game/Script.pvf" } as NonNullable<typeof store.info>;
api.RebuildSearchIndex.mockReturnValueOnce(pending.promise);
const rebuilding = store.rebuildSearchIndex();
emit("archive:index-ready", { state: "ready", stage: "ready-sqlite" });
pending.resolve({ state: "building" });
await rebuilding;
expect(store.indexReady).toBe(true);
expect(vi.getTimerCount()).toBe(0);
});
test("encoding switch updates archive info and reload resets string table stats", async () => {
const store = useArchiveStore();
const info = { path: "/game/Script.pvf", textEncoding: "GBK", textEncodingLabel: "GBK" };
store.info = { ...info, textEncoding: "auto" } as NonNullable<typeof store.info>;
api.SetTextEncoding.mockResolvedValue(info);
await store.setTextEncoding("GBK");
expect(api.SetTextEncoding).toHaveBeenCalledWith("GBK");
expect(store.info?.textEncoding).toBe("GBK");
store.stringTableIndex.revision = 20;
emit("archive:reloaded", info);
expect(store.stringTableIndex.revision).toBe(0);
api.SetTextEncoding.mockRejectedValue(new Error("请先保存"));
await expect(store.setTextEncoding("auto")).rejects.toThrow("请先保存");
expect(store.info?.textEncoding).toBe("GBK");
});
+94
View File
@@ -0,0 +1,94 @@
import { beforeEach, expect, test, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
const api = vi.hoisted(() => ({ Save: vi.fn(), SaveAsDialog: vi.fn(), SetText: vi.fn(), GetAnnotations: vi.fn(), refreshInfo: vi.fn(), refreshTreeTags: vi.fn(), handlers: new Map<string, (event: any) => void>() }));
vi.mock("@wailsio/runtime", () => ({ Events: { On: (name: string, handler: (event: any) => void) => { api.handlers.set(name, handler); } } }));
vi.mock("../bindings/pvfine/services", () => ({ EditorService: api, ArchiveService: {}, FileGUIService: {} }));
vi.mock("../src/stores/archive", () => ({ useArchiveStore: () => ({ refreshInfo: api.refreshInfo }) }));
vi.mock("../src/stores/script", () => ({ useScriptStore: () => ({}) }));
vi.mock("../src/stores/explorer", () => ({ useExplorerStore: () => api }));
import { useEditorStore } from "../src/stores/editor";
beforeEach(() => {
setActivePinia(createPinia());
vi.resetAllMocks();
api.handlers.clear();
});
test("保存从准备到写盘持续反馈,拦截重复保存,完成后释放状态", async () => {
let finish!: (value: any) => void;
api.Save.mockReturnValue(new Promise(resolve => { finish = resolve; }));
const store = useEditorStore();
const pending = store.save();
expect(store.archiveSaveStage).toBe("preparing");
expect(store.saving).toBe(true);
await vi.waitFor(() => expect(store.archiveSaveStage).toBe("saving"));
await expect(store.save()).rejects.toThrow("正在保存");
await expect(store.saveAs()).rejects.toThrow("正在保存");
expect(api.Save).toHaveBeenCalledTimes(1);
expect(api.SaveAsDialog).not.toHaveBeenCalled();
api.handlers.get("archive:save-progress")!({ data: { phase: "packing", done: 5, total: 10, unit: "chunks" } });
expect(store.archiveSaveProgress).toEqual({ phase: "packing", done: 5, total: 10, unit: "chunks" });
api.handlers.get("archive:save-progress")!({ data: { phase: "syncing", done: 0, total: 0, unit: "" } });
expect(store.archiveSaveProgress?.phase).toBe("syncing");
finish({ fileCount: 1 });
await pending;
expect(store.archiveSaveStage).toBeNull();
expect(store.saving).toBe(false);
expect(store.archiveSaveProgress).toBeNull();
api.handlers.get("archive:save-progress")!({ data: { phase: "done", done: 1, total: 1, unit: "" } });
expect(store.archiveSaveProgress).toBeNull();
});
test("写盘失败后收起反馈并允许重试", async () => {
api.Save.mockRejectedValueOnce(new Error("磁盘写入失败")).mockResolvedValueOnce({});
const store = useEditorStore();
await expect(store.save()).rejects.toThrow("磁盘写入失败");
expect(store.archiveSaveStage).toBeNull();
expect(store.saving).toBe(false);
await store.save();
expect(api.Save).toHaveBeenCalledTimes(2);
});
test("另存为取消或失败后清理反馈;成功时等待工作区刷新", async () => {
const store = useEditorStore();
api.SaveAsDialog.mockResolvedValueOnce("").mockRejectedValueOnce(new Error("保存失败"));
expect(await store.saveAs()).toBeNull();
expect(store.archiveSaveStage).toBeNull();
expect(api.refreshInfo).not.toHaveBeenCalled();
await expect(store.saveAs()).rejects.toThrow("保存失败");
expect(store.archiveSaveStage).toBeNull();
expect(store.saving).toBe(false);
let finish!: () => void;
api.SaveAsDialog.mockImplementation(async () => {
expect(store.archiveSaveStage).toBe("saveAs");
return "saved.pvf";
});
api.refreshInfo.mockReturnValue(new Promise<void>(resolve => { finish = resolve; }));
const pending = store.saveAs();
await vi.waitFor(() => expect(store.archiveSaveStage).toBe("finishing"));
expect(store.saving).toBe(true);
finish();
expect(await pending).toBe("saved.pvf");
expect(store.archiveSaveStage).toBeNull();
expect(store.saving).toBe(false);
});
test("单文件保存不触发归档遮罩", async () => {
const store = useEditorStore();
store.tabs.push({ index: 1, path: "test.stk", title: "test.stk", dataType: 1,
size: 1, tags: [], editable: true, original: "before", text: "after", modified: false,
annotations: [], annotationsHidden: false, lineWrapping: true, icon: null,
fieldImage: null, loading: false, loadError: null });
let finish!: () => void;
api.SetText.mockReturnValue(new Promise<void>(resolve => { finish = resolve; }));
const pending = store.saveFileTab(1);
expect(store.saving).toBe(true);
expect(store.archiveSaveStage).toBeNull();
finish();
expect(await pending).toBe(true);
expect(api.SetText).toHaveBeenCalledWith(1, "after");
expect(store.saving).toBe(false);
expect(store.archiveSaveStage).toBeNull();
});
+251
View File
@@ -0,0 +1,251 @@
import { beforeEach, expect, test, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
const api = vi.hoisted(() => ({
CreateSnapshot: vi.fn(),
Status: vi.fn(),
PendingRecovery: vi.fn(),
Restore: vi.fn(),
Discard: vi.fn(),
DropForWorkspace: vi.fn(),
ChooseCachePath: vi.fn(),
saveAllDirty: vi.fn(),
}));
const state = vi.hoisted(() => ({
archive: { open: true, loading: false, modifiedCount: 0, info: null as unknown },
editor: { dirtyCount: 0, saving: false },
version: { status: { loading: false, changedFiles: 0, needsSave: false } },
settings: { autosaveEnabled: true, autosaveIntervalSeconds: 300, autosavePath: "" },
fileSets: { dirty: false },
script: { workspaceDetached: false, dirty: false },
}));
vi.mock("@wailsio/runtime", () => ({ Events: { On: vi.fn() } }));
vi.mock("../bindings/pvfine/services", () => ({ AutosaveService: api }));
vi.mock("../src/stores/archive", () => ({ useArchiveStore: () => state.archive }));
vi.mock("../src/stores/editor", () => ({ useEditorStore: () => Object.assign(state.editor, { saveAllDirty: api.saveAllDirty }) }));
vi.mock("../src/stores/version", () => ({ useVersionStore: () => state.version }));
vi.mock("../src/stores/fileSets", () => ({ useFileSetStore: () => state.fileSets }));
vi.mock("../src/stores/script", () => ({ useScriptStore: () => state.script }));
vi.mock("../src/stores/settings", () => ({
useSettingsStore: () => state.settings,
minAutosaveIntervalMinutes: 1,
maxAutosaveIntervalMinutes: 120,
defaultAutosaveIntervalSeconds: 300,
}));
import { useAutosaveStore } from "../src/stores/autosave";
import { setDialogBusy } from "../src/dialogBusy";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
const statusPayload = {
enabled: true,
path: "/tmp/autosave.pvf",
defaultPath: "/cache/pvfine/autosave.pvf",
exists: false,
cachedAt: 0,
sourcePath: "",
sizeBytes: 0,
running: false,
lastError: "",
};
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
// 惰性转发:假定时器替换 globalThis 后 window 上的实现同步生效。
vi.stubGlobal("window", {
setInterval: (...args: Parameters<typeof setInterval>) => globalThis.setInterval(...args),
clearInterval: (handle: number) => globalThis.clearInterval(handle),
});
state.archive.open = true;
state.archive.loading = false;
state.archive.modifiedCount = 0;
state.archive.info = null;
state.editor.dirtyCount = 0;
state.editor.saving = false;
state.version.status.loading = false;
state.settings.autosaveEnabled = true;
state.settings.autosaveIntervalSeconds = 300;
api.Status.mockResolvedValue({ ...statusPayload });
api.PendingRecovery.mockResolvedValue(null);
api.CreateSnapshot.mockResolvedValue(undefined);
api.Discard.mockResolvedValue(true);
api.DropForWorkspace.mockResolvedValue(undefined);
api.saveAllDirty.mockResolvedValue(1);
});
test("干净工作区与关闭开关都不写缓存", async () => {
const store = useAutosaveStore();
await store.tick();
expect(api.CreateSnapshot).not.toHaveBeenCalled();
state.settings.autosaveEnabled = false;
state.archive.modifiedCount = 3;
await store.tick();
expect(api.CreateSnapshot).not.toHaveBeenCalled();
});
test("有改动时先提交编辑器草稿再快照", async () => {
const store = useAutosaveStore();
state.editor.dirtyCount = 2;
await store.tick();
expect(api.saveAllDirty).toHaveBeenCalledTimes(1);
expect(api.CreateSnapshot).toHaveBeenCalledTimes(1);
expect(store.lastSnapshotAt).toBeGreaterThan(0);
expect(store.lastError).toBe("");
});
test("保存中、版本加载中或没有归档时跳过", async () => {
const store = useAutosaveStore();
state.archive.modifiedCount = 1;
state.editor.saving = true;
await store.tick();
state.editor.saving = false;
state.version.status.loading = true;
await store.tick();
state.version.status.loading = false;
state.archive.open = false;
await store.tick();
expect(api.CreateSnapshot).not.toHaveBeenCalled();
});
test("快照失败只记录错误,不抛出", async () => {
const store = useAutosaveStore();
state.archive.modifiedCount = 1;
api.CreateSnapshot.mockRejectedValue(new Error("磁盘已满"));
await store.tick();
expect(store.lastError).toBe("磁盘已满");
expect(store.running).toBe(false);
});
test("定时器按设置的间隔触发快照", async () => {
vi.useFakeTimers();
try {
const store = useAutosaveStore();
state.settings.autosaveIntervalSeconds = 60;
state.archive.modifiedCount = 1;
store.restartTimer();
await vi.advanceTimersByTimeAsync(59_000);
expect(api.CreateSnapshot).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1_000);
expect(api.CreateSnapshot).toHaveBeenCalledTimes(1);
store.stopTimer();
} finally {
vi.useRealTimers();
}
});
test("启动检查待恢复备份并可用恢复/丢弃处理", async () => {
const store = useAutosaveStore();
const pending = {
sourcePath: "/game/Script.pvf",
sourceName: "Script.pvf",
cachedAt: 100,
sizeBytes: 2048,
pendingFiles: 2,
sourceExists: true,
sourceChanged: false,
};
api.PendingRecovery.mockResolvedValue(pending);
expect(await store.checkRecovery()).toEqual(pending);
const restored = { path: "/game/Script.pvf", modifiedCount: 2 };
api.Restore.mockResolvedValue(restored);
expect(await store.restore()).toBe(true);
expect(store.pendingRecovery).toBeNull();
expect(state.archive.info).toEqual(restored);
expect(await store.discard()).toBe(true);
await store.discardForWorkspace();
expect(api.DropForWorkspace).toHaveBeenCalled();
});
test("恢复失败时错误向上抛出并保留待恢复信息", async () => {
const store = useAutosaveStore();
store.pendingRecovery = {
sourcePath: "/game/Script.pvf",
sourceName: "Script.pvf",
cachedAt: 100,
sizeBytes: 2048,
pendingFiles: 1,
sourceExists: false,
sourceChanged: false,
};
api.Restore.mockRejectedValue(new Error("原文件不存在,请先找回文件再恢复备份: /game/Script.pvf"));
await expect(store.restore()).rejects.toThrow("原文件不存在");
expect(store.pendingRecovery).not.toBeNull();
expect(store.lastError).toContain("原文件不存在");
});
test("恢复期间暴露 restoring 状态,重复调用直接忽略", async () => {
const store = useAutosaveStore();
const pending = deferred<unknown>();
api.Restore.mockReturnValue(pending.promise);
const first = store.restore();
expect(store.restoring).toBe(true);
expect(await store.restore()).toBe(false);
expect(api.Restore).toHaveBeenCalledTimes(1);
pending.resolve({ path: "/game/Script.pvf", modifiedCount: 1 });
expect(await first).toBe(true);
expect(store.restoring).toBe(false);
});
test("恢复弹窗忙碌态锁定关闭入口并切换按钮文案", () => {
const instance: any = {
loading: false,
positiveText: "恢复",
negativeButtonProps: {},
closable: true,
maskClosable: true,
closeOnEsc: true,
};
setDialogBusy(instance, true, "恢复", "正在恢复…");
expect(instance).toMatchObject({
loading: true,
positiveText: "正在恢复…",
negativeButtonProps: { disabled: true },
closable: false,
maskClosable: false,
closeOnEsc: false,
});
setDialogBusy(instance, false, "恢复");
expect(instance).toMatchObject({
loading: false,
positiveText: "恢复",
negativeButtonProps: { disabled: false },
closable: true,
maskClosable: true,
closeOnEsc: true,
});
});
test("initialize 只检查一次恢复并启动定时器", async () => {
vi.useFakeTimers();
try {
const store = useAutosaveStore();
state.archive.modifiedCount = 1;
api.PendingRecovery.mockResolvedValue(null);
await store.initialize();
await store.initialize();
expect(api.PendingRecovery).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(300_000);
expect(api.CreateSnapshot).toHaveBeenCalled();
store.stopTimer();
} finally {
vi.useRealTimers();
}
});
+264
View File
@@ -0,0 +1,264 @@
import { describe, expect, it } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { ceraDate, ceraSchemas, ceraTabProducts, changeCeraProduct, newCeraValues, nextCeraID, readCeraShop } from '../src/gui/ceraShop';
import { getGUIProvider } from '../src/gui/registry';
const item = '3000109 1 1 0 0 15 0 0 `Life Token` 0 0 -1 `` -1';
const avatar = '3107733 515540507 4 0 0 120 0 0 0 0 -1 -1 -1 ``';
const pkg = '3400245 590713421 0 0 599 0 0 `Summer Box` 90 0 -1 -1 `20241105`';
function fixture(eol = '\n') {
return ['# 😀 注释保留', '[item]', item, '[/item]', '[dont trade avatar]', avatar, '[/dont trade avatar]', '[package]', pkg, '[/package]',
'[shop button list and sequence]', '`event` `mileage` `hotandnew` `package` `avatar` `item` `exit`', '[/shop button list and sequence]',
'[purchasing limit]', '3000109 1 0 2 0 0 0 0', '[/purchasing limit]', '[specific product mileage]', '3000109 5', '[/specific product mileage]',
'[hotnew sell ipg]', '3000109 6', '[/hotnew sell ipg]', '[storage]', '[cargo 1]', '3000109 1', '[cargo 1]', '3400245 590713421', '[/storage]',
'[visible storage]', '[cargo 1]', '1 3000109', '[/visible storage]', '[unknown]', '`3000109` 3000109', '[/unknown]', ''].join(eol);
}
describe('商城结构和编辑', () => {
it('识别三种布局、导航顺序及聚合页签', () => {
const doc = readCeraShop(fixture());
expect(doc.issues).toEqual([]); expect(doc.products).toHaveLength(3);
expect(doc.tabs).toEqual(['event','mileage','hotandnew','package','avatar','item','exit']);
expect(doc.products.find(p => p.section === 'package')).toMatchObject({ name: 'Summer Box', cash: '599', date: '20241105' });
expect(doc.products.find(p => p.section === 'dont trade avatar')).toMatchObject({ name: '', cash: '120' });
expect(ceraTabProducts(doc, 'hotandnew').map(p => p.id)).toEqual(['3000109']);
expect(ceraTabProducts(doc, 'mileage').map(p => p.id)).toEqual(['3000109']);
expect(ceraDate('20241105')).toBe('2024-11-05');
});
for (const eol of ['\n', '\r\n', '\r']) {
it(`逐字段编辑保留未知字段、注释和换行 ${JSON.stringify(eol)}`, () => {
const source = fixture(eol).replace('1 1 0 0 15', '1 1 # 价格注释'+eol+' 0 0 15');
const row = readCeraShop(source).products.find(p => p.section === 'item')!, values = row.fields.map(t => t.value);
values[5] = '30'; values[8] = '中文 `名称`';
const result = changeCeraProduct(source, 'edit', 'item', values, row);
expect(result).toBe(source.replace('0 0 15', '0 0 30').replace('`Life Token`', '`中文 ``名称```'));
expect(readCeraShop(result).products.find(p => p.section === 'item')?.name).toBe('中文 `名称`');
});
it(`改号和删除同步商品引用,但保留物品编号 ${JSON.stringify(eol)}`, () => {
const source = fixture(eol), row = readCeraShop(source).products.find(p => p.section === 'item')!, values = row.fields.map(t => t.value);
values[0] = '3000999';
const result = changeCeraProduct(source, 'edit', 'item', values, row), doc = readCeraShop(result);
expect(doc.products.find(p => p.id === '3000999')?.itemId).toBe('1');
expect(ceraTabProducts(doc, 'hotandnew')[0]?.id).toBe('3000999');
expect(result).toContain('[unknown]'+eol+'`3000109` 3000109');
expect(result).toContain('3000999 1'); expect(result).toContain('1 3000999');
const removed = changeCeraProduct(source, 'delete', 'item', [], row), after = readCeraShop(removed);
expect(after.products).toHaveLength(2); expect(after.sections.find(s => s.name === 'purchasing limit')?.tokens).toEqual([]);
expect(after.sections.find(s => s.name === 'storage')?.children).toHaveLength(1);
expect(after.sections.find(s => s.name === 'visible storage')?.children).toHaveLength(0);
expect(removed).toContain('3400245 590713421'); expect(removed).toContain('[unknown]'+eol+'`3000109` 3000109');
});
}
it('新增自动递增、允许手动编号,缺少分类时追加分类', () => {
const source = fixture(), doc = readCeraShop(source);
expect(nextCeraID(doc)).toBe('3400246');
const values = newCeraValues(doc, 'item event'); values[1] = '42';
const result = changeCeraProduct(source, 'add', 'item event', values);
expect(result.startsWith(source)).toBe(true);
expect(readCeraShop(result).products.find(p => p.section === 'item event')).toMatchObject({ id: '3400246', itemId: '42', cash: '0' });
values[0] = '3500000'; expect(readCeraShop(changeCeraProduct(source, 'add', 'item', values)).products).toHaveLength(4);
});
it('保留特殊类型字符串标记', () => {
const source = fixture().replace('`Life Token`', '{7=`Life Token`}');
const target = readCeraShop(source).products.find(p => p.section === 'item')!, next = target.fields.map(t => t.value); next[8] = '新名称';
expect(changeCeraProduct(source, 'edit', 'item', next, target)).toContain('{7=`新名称`}');
});
it('拒绝重复编号、无效数值、失效目标和不完整记录', () => {
const source = fixture(), doc = readCeraShop(source), row = doc.products.find(p => p.section === 'item')!, values = row.fields.map(t => t.value);
for (const value of ['3400245', '0', '-1', '2147483648', '1.5', '']) { values[0] = value; expect(() => changeCeraProduct(source,'edit','item',values,row)).toThrow(); }
values[0] = row.id; values[5] = '-1'; expect(() => changeCeraProduct(source,'edit','item',values,row)).toThrow();
expect(() => changeCeraProduct('# shift\n'+source,'delete','item',[],row)).toThrow('已变化');
const broken = source.replace(item, item + ' 9'), parsed = readCeraShop(broken);
expect(parsed.blocked.has('item')).toBe(true); expect(() => newCeraValues(parsed,'item')).toThrow(); expect(() => nextCeraID(parsed)).toThrow();
expect(() => changeCeraProduct(source.replace('3000109 1 0 2 0 0 0 0', '3000109 1'), 'delete', 'item', [], row)).toThrow();
});
it('可交易时装独立使用 13 字段布局', () => {
const source = '[avatar]\n4000114 39000 1 0 0 700 0 0 90 0 -1 -1 ``\n[/avatar]';
const doc = readCeraShop(source); expect(doc.issues).toEqual([]);
expect(doc.products[0]).toMatchObject({ id: '4000114', cash: '700', name: '' });
const values = newCeraValues(doc, 'avatar'); values[1] = '39003';
expect(values).toHaveLength(13); expect(values[12]).toBe('');
expect(readCeraShop(changeCeraProduct(source, 'add', 'avatar', values)).products).toHaveLength(2);
});
it('入口准确识别商城文件', () => {
const file = (path: string) => ({ index: 1, path, text: '', editable: true });
expect(getGUIProvider(file('etc/(r)cerashop.etc'))?.id).toBe('cera-shop');
expect(getGUIProvider(file('ETC\\(R)CERASHOP.ETC'))?.id).toBe('cera-shop');
expect(getGUIProvider(file('etc/cerashopoption.cos'))).toBeNull();
});
it('兼容 90CN 按钮顺序的历史结束标签,编辑保留原始拼写', () => {
const source = fixture().replace('[/shop button list and sequence]', '[/shop button and sequence]');
const doc = readCeraShop(source);
expect(doc.tabs).toEqual(['event','mileage','hotandnew','package','avatar','item','exit']);
expect(doc.issues).toEqual([]);
const row = doc.products.find(p => p.section === 'item')!, values = row.fields.map(t => t.value); values[5] = '42';
const edited = changeCeraProduct(source, 'edit', 'item', values, row);
expect(edited).toBe(source.replace('0 0 15', '0 0 42'));
expect(edited).toContain('[/shop button and sequence]');
expect(() => readCeraShop(fixture().replace('[/item]', '[/wrong item]'))).toThrow('闭合标签不匹配');
});
function legacyFixture(eol = '\n') {
return ['# 旧版商城 😀', '[avatar]', '120000 39032 1 1 0 -1', '[/avatar]',
'[coin]', '100000 1 10 0 0 880 `10个` 0 0', '[/coin]',
'[item]', '100534 2681921 1 0 0 2000 `账号金库升级工具` 0 0', '[/item]',
'[selectable character premium]', '-1 2681927 -1 7 0 2000 `魔王之契约` 0 -1', '[/selectable character premium]',
'[visual]', '100615 2681313 -1 7 0 300 `名称装饰卡 (7天)` 0', '[/visual]',
'[package]', '104763 490702435 0 0 19800 `夏日礼包` 1 0 -1 -1 15', '[/package]',
'[community item]', '102401 2683401 1 0 0 14888 `1个` 101 0', '[/community item]',
'[charac premium package]', '100625 2682006 3880 `60天` 0 0 1 60 100614', '[/charac premium package]',
'[derivative]', '`https://example.invalid/item` `周边` 338 -1 -1 5 -1', '[/derivative]',
'[shop button list and sequence]', '`mileage` `hotandnew` `package` `avatar` `item` `community` `derivative` `exit`', '[/shop button list and sequence]',
'[buy restrict item]', '1 100534 7 1', '[/buy restrict item]',
'[btn control]', '100534 0 1 0', '[/btn control]',
'[default promotion page]', '100534 29 -1 7', '[/default promotion page]',
'[specific product mileage]', '100534 3', '[/specific product mileage]',
'[custom]', '100534 `保留`', '[/custom]', ''].join(eol);
}
it('按结构识别普通版商城,保留旧版页签及各分类', () => {
const doc = readCeraShop(legacyFixture());
expect(doc.family).toBe('legacy'); expect(doc.issues).toEqual([]); expect(doc.products).toHaveLength(9);
expect(doc.tabs).toEqual(['mileage','hotandnew','package','avatar','item','community','derivative','exit']);
expect(doc.schemas.item).toMatchObject({ width: 9, name: 6, date: -1 });
expect(doc.products.find(p => p.section === 'avatar')).toMatchObject({ gold: '', cash: '', quantity: '' });
expect(doc.products.find(p => p.section === 'selectable character premium')).toMatchObject({ gold: '', quantity: '', cash: '2000' });
expect(doc.products.find(p => p.section === 'visual')).toMatchObject({ gold: '', quantity: '', cash: '300' });
expect(doc.products.find(p => p.section === 'charac premium package')).toMatchObject({ cash: '3880', name: '60天' });
expect(ceraTabProducts(doc, 'community')).toHaveLength(1);
expect(ceraTabProducts(doc, 'derivative')[0]).toMatchObject({ id: '', itemId: '-1', name: '周边', cash: '338' });
expect(() => newCeraValues(doc, 'derivative')).toThrow();
expect(() => changeCeraProduct(legacyFixture(), 'delete', 'derivative', [], ceraTabProducts(doc, 'derivative')[0])).toThrow();
});
for (const eol of ['\n','\r\n','\r']) {
it(`普通版增删改保留字段宽度、期限及引用 ${JSON.stringify(eol)}`, () => {
const source = legacyFixture(eol), doc = readCeraShop(source), row = doc.products.find(p => p.section === 'item')!;
const values = row.fields.map(t => t.value); values[0] = '100535'; values[5] = '1234';
const changed = changeCeraProduct(source,'edit','item',values,row), after = readCeraShop(changed);
expect(after.products.find(p => p.section === 'item')).toMatchObject({ id: '100535', cash: '1234' });
expect(changed).toContain('1 100535 7 1'); expect(changed).toContain('100535 29 -1 7');
expect(changed).toContain('[custom]'+eol+'100534 `保留`');
expect(changed).toContain('100615 2681313 -1 7 0 300');
const removed = readCeraShop(changeCeraProduct(source,'delete','item',[],row));
expect(removed.sections.find(s => s.name === 'buy restrict item')?.tokens).toHaveLength(0);
expect(removed.sections.find(s => s.name === 'btn control')?.tokens).toHaveLength(0);
expect(removed.sections.find(s => s.name === 'default promotion page')?.tokens.map(t => t.value)).toEqual(['-1','7']);
for (const name of ['avatar','item','package','visual','charac premium package']) {
const fields = newCeraValues(doc,name); fields[1] = '42';
const added = readCeraShop(changeCeraProduct(source,'add',name,fields));
expect(added.products).toHaveLength(10); expect(fields).toHaveLength(doc.schemas[name]!.width);
}
expect(nextCeraID(doc)).toBe('120001');
});
}
const legacyPath = process.env.PVF_CERA90_LEGACY_TEXT;
it.skipIf(!legacyPath || !existsSync(legacyPath!))('90CN etc/cerashop.etc 真实样本显示所有商品并使用原布局增删改', () => {
const source = readFileSync(legacyPath!, 'utf8'), doc = readCeraShop(source);
expect(doc.family).toBe('legacy'); expect(doc.issues).toEqual([]); expect(doc.blocked.size).toBe(0);
expect(doc.products).toHaveLength(37127);
expect(ceraTabProducts(doc,'avatar')).toHaveLength(36394);
expect(ceraTabProducts(doc,'item')).toHaveLength(239);
expect(ceraTabProducts(doc,'package')).toHaveLength(453);
expect(ceraTabProducts(doc,'community')).toHaveLength(34);
expect(ceraTabProducts(doc,'derivative')).toHaveLength(7);
for (const name of ['avatar','item','coin','material','recoveryitem','premium','selectable character premium','visual','creature','community package','community item','regular package','package','charac premium package']) {
const row = doc.products.find(p => p.section === name && Number(p.id) > 0)!, schema = doc.schemas[name]!, values = row.fields.map(t => t.value);
const column = schema.cash >= 0 ? schema.cash : 2; values[column] = schema.cash >= 0 ? '123' : '2';
const changed = changeCeraProduct(source,'edit',name,values,row);
const token = row.fields[column]!;
expect(changed).toBe(source.slice(0,token.start)+values[column]+source.slice(token.end));
const fields = newCeraValues(doc,name); fields[1] = row.itemId;
expect(readCeraShop(changeCeraProduct(source,'add',name,fields)).products).toHaveLength(37128);
}
const target = doc.products.find(p => p.section === 'item')!, values = target.fields.map(t => t.value); values[0] = nextCeraID(doc);
expect(readCeraShop(changeCeraProduct(source,'edit','item',values,target)).products.find(p => p.id === values[0])).toBeDefined();
expect(readCeraShop(changeCeraProduct(source,'delete','item',[],target)).products).toHaveLength(37126);
});
function twFixture() {
return ['# TW 商城 😀', '[npc info]', '`Map/Npc/NpcFace01.img` 12', '[/npc info]',
'[avatar]', '[/avatar]', '[dont trade avatar]', '[/dont trade avatar]', '[avatar dye]', '[/avatar dye]',
'[coin]', '9000 1 1 0 0 5 `复活币` 0 0 -1', '[/coin]',
'[item]', '10000 50 1 0 0 10 `金库升级` 0 0 -1', '[/item]',
'[premium]', '30001 31 1 0 0 50 `契约` 0 0 -1', '[/premium]',
'[creature]', '20000 24 100 0 0 30 `宠物饲料` 0 0 -1', '[/creature]',
'[package]', '100031 2660041 0 0 1080 `礼包` 2 0 -1 -1', '[/package]',
'[gift disallowance]', '10000', '[/gift disallowance]',
'[shop button list and sequence]', '`hotandnew` `avatar` `item` `creature` `package` `exit`', '[/shop button list and sequence]', ''].join('\n');
}
it('TW 使用十字段道具和礼包,宠物按配置显示独立页签', () => {
const doc = readCeraShop(twFixture());
expect(doc.family).toBe('tw'); expect(doc.issues).toEqual([]); expect(doc.products).toHaveLength(5);
expect(doc.schemas.item).toMatchObject({ width:10, name:6, date:-1 });
expect(doc.schemas.package).toMatchObject({ width:10, name:5, date:-1 });
expect(doc.schemas.avatar.width).toBe(7); expect(doc.schemas['dont trade avatar'].width).toBe(8);
expect(doc.schemas['avatar dye']).toBeUndefined();
expect(doc.tabs).toEqual(['hotandnew','avatar','item','creature','package','exit']);
expect(ceraTabProducts(doc,'item')).toHaveLength(3); expect(ceraTabProducts(doc,'creature')).toHaveLength(1);
expect(doc.products.find(p => p.section === 'creature')).toMatchObject({ cash:'30', quantity:'100' });
expect(nextCeraID(doc)).toBe('100032');
const row = doc.products.find(p => p.section === 'item')!, values=row.fields.map(t=>t.value); values[0]='100032'; values[5]='20';
const changed=changeCeraProduct(twFixture(),'edit','item',values,row);
expect(changed).toContain('[gift disallowance]\n100032');
expect(changed).toContain('`Map/Npc/NpcFace01.img` 12');
expect(readCeraShop(changed).products.find(p=>p.id==='100032')).toMatchObject({cash:'20',name:'金库升级'});
for(const name of ['avatar','dont trade avatar','coin','item','premium','creature','package']) {
const fields=newCeraValues(doc,name); fields[1]='42';
expect(readCeraShop(changeCeraProduct(twFixture(),'add',name,fields)).products).toHaveLength(6);
expect(fields).toHaveLength(doc.schemas[name]!.width);
}
});
it('TW 七/八字段时装保留原始期限与附加参数,不误作价格或数量', () => {
const source='[avatar]\n144327 39000 1 90 0 -1 -1\n[/avatar]\n[dont trade avatar]\n55309429 101500454 4 0 0 -1 120164 -1\n[/dont trade avatar]';
const doc=readCeraShop(source); expect(doc.family).toBe('tw'); expect(doc.issues).toEqual([]);
expect(doc.products).toHaveLength(2);
for(const row of doc.products) {
expect(row).toMatchObject({gold:'',cash:'',quantity:''});
const values=row.fields.map(t=>t.value); values[2]='2';
const token=row.fields[2]!;
expect(changeCeraProduct(source,'edit',row.section,values,row)).toBe(source.slice(0,token.start)+'2'+source.slice(token.end));
}
});
for(const [variable,count] of [['PVF_CERA_TW_TEXT',133],['PVF_CERA_TW_FIRST_TEXT',54476]] as const) {
const path=process.env[variable];
it.skipIf(!path || !existsSync(path!))(`TW 真实商城回归 ${variable}`,()=>{
const source=readFileSync(path!,'utf8'),doc=readCeraShop(source);
expect(doc.family).toBe('tw'); expect(doc.blocked.size).toBe(0); expect(doc.products).toHaveLength(count);
expect(doc.issues).toEqual([]);
expect(doc.tabs).toContain('creature');
expect(ceraTabProducts(doc,'creature')).toHaveLength(count===133?5:17);
if(count===133)expect(ceraTabProducts(doc,'item')).toHaveLength(96);
for(const name of ['coin','item','premium','creature','package']) {
const row=doc.products.find(p=>p.section===name && Number(p.id)>0)!,schema=doc.schemas[name]!,values=row.fields.map(t=>t.value);
values[schema.cash]='123'; const token=row.fields[schema.cash]!;
expect(changeCeraProduct(source,'edit',name,values,row)).toBe(source.slice(0,token.start)+'123'+source.slice(token.end));
const added=newCeraValues(doc,name);added[1]=row.itemId;
expect(readCeraShop(changeCeraProduct(source,'add',name,added)).products).toHaveLength(count+1);
expect(readCeraShop(changeCeraProduct(source,'delete',name,[],row)).products).toHaveLength(count-1);
}
});
}
const real90Path = process.env.PVF_CERA90_TEXT;
it.skipIf(!real90Path || !existsSync(real90Path!))('90CN 真实商城可以解析并修改四种记录的价格', () => {
const source = readFileSync(real90Path!, 'utf8'), doc = readCeraShop(source);
expect(doc.blocked.size).toBe(0);
expect(doc.tabs).toEqual(['event','mileage','hotandnew','package','avatar','item','exit']);
expect(doc.products.length).toBeGreaterThan(60000);
for (const section of ['item','avatar','dont trade avatar','package']) {
const row = doc.products.find(p => p.section === section && Number(p.id) > 0)!, values = row.fields.map(t => t.value); values[ceraSchemas[section]!.cash] = '123';
const changed = changeCeraProduct(source, 'edit', section, values, row);
expect(changed).toContain('[/shop button and sequence]');
expect(readCeraShop(changed).products.find(p => p.section === section && p.row === row.row)?.cash).toBe('123');
}
});
const realPath = process.env.PVF_CERA_TEXT;
it.skipIf(!realPath || !existsSync(realPath!))('110US 真实样本分组、改价、增删与引用更新', () => {
const source = readFileSync(realPath!, 'utf8'), doc = readCeraShop(source);
expect(doc.products).toHaveLength(17091); expect(doc.issues).toEqual([]);
expect(doc.products.filter(p => p.section === 'dont trade avatar')).toHaveLength(10755);
expect(doc.products.filter(p => p.section === 'package')).toHaveLength(5120);
for (const section of ['item','dont trade avatar','package']) {
const row = doc.products.find(p => p.section === section)!, values = row.fields.map(t => t.value); values[ceraSchemas[section]!.cash] = '123';
expect(readCeraShop(changeCeraProduct(source,'edit',section,values,row)).products.find(p => p.id === row.id)?.cash).toBe('123');
expect(readCeraShop(changeCeraProduct(source,'delete',section,[],row)).products).toHaveLength(17090);
}
const values = newCeraValues(doc,'item'); values[1]='1';
expect(readCeraShop(changeCeraProduct(source,'add','item',values)).products).toHaveLength(17092);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { addCraneItem, editCraneItem, editCraneMaterial, parseCraneMinigame, removeCraneItem } from '../src/gui/craneMinigame';
import { getGUIProvider } from '../src/gui/registry';
const values = { id: 10000006, count: 2, viewRatio: 43.96, pickRatio: 90 };
const block = '[item]\n\t10000006 // 物品 ID 😀\n\t[cnt]\n\t\t2 // 数量\n\t[viewRatio]\n\t\t43.96\n\t[pickRatio]\n\t\t90\n[/item]';
const tail = '[material]\n\t2660547\t1 // 启动材料\n[send mail]\n[inven]1\n[detail]0 `消息`[/detail]\n[/send mail]\n[need material]3332 5[/need material]\n';
const fixture = '# 注释\n[viewCnt]6\n' + block + '\n' + block + '\n' + tail;
describe('娃娃机 GUI', () => {
for (const eol of ['\n', '\r\n', '\r']) {
it(`按记录编辑并保留其他 section、注释和 ${JSON.stringify(eol)}`, () => {
const text = fixture.replaceAll('\n', eol), doc = parseCraneMinigame(text);
expect(doc.items).toHaveLength(2);
expect(editCraneItem(text, 0, values)).toBe(text);
const expected = block.replace('43.96', '12.5').replaceAll('\n', eol);
expect(editCraneItem(text, 1, { ...values, viewRatio: 12.5 })).toBe(text.slice(0, doc.items[1]!.section.start) + expected + text.slice(doc.items[1]!.section.end));
expect(editCraneMaterial(text, 42, 3)).toBe(text.replace('2660547\t1', '42\t3'));
const added = addCraneItem(text, { ...values, id: 42 });
expect(parseCraneMinigame(added).items.map(i => i.fields[0].value)).toEqual(['10000006', '10000006', '42']);
expect(added).toContain(tail.replaceAll('\n', eol));
expect(removeCraneItem(text, 1)).toBe(text.slice(0, doc.items[1]!.section.start) + text.slice(doc.items[1]!.section.end));
});
}
it('整数值保留原浮点类型,浮点几率支持小数', () => {
const edited = editCraneItem(fixture, 0, { ...values, viewRatio: 50, pickRatio: 50.5 });
expect(parseCraneMinigame(edited).items[0]!.fields.map(t => t.value)).toEqual(['10000006', '2', '50.0', '50.5']);
});
it('空文件可新增奖品和材料', () => {
expect(parseCraneMinigame(addCraneItem('', values)).items).toHaveLength(1);
expect(parseCraneMinigame(editCraneMaterial('', 42, 1)).material.map(t => t.value)).toEqual(['42', '1']);
});
it('未知奖品字段编辑时保留,删除时要求 DSL 确认', () => {
const text = fixture.replace('[/item]', '[extra]`保留`[/item]');
expect(editCraneItem(text, 0, { ...values, count: 3 })).toContain('[extra]`保留`');
expect(() => removeCraneItem(text, 0)).toThrow('其他字段');
});
it('拒绝损坏结构、特殊类型和越界值', () => {
for (const text of [fixture.replace('[/item]', ''), fixture.replace('[cnt]', '[pickRatio]'), fixture.replace('43.96', '`43.96`'), fixture + '[material]1 2', fixture.replace('43.96', 'NaN'), fixture.replace('2 // 数量', '2.5')]) expect(() => parseCraneMinigame(text)).toThrow();
for (const invalid of [{ count: 1.5 }, { id: -1 }, { id: 2147483648 }, { pickRatio: 101 }, { viewRatio: -1 }, { viewRatio: Infinity }]) expect(() => editCraneItem(fixture, 0, { ...values, ...invalid })).toThrow();
expect(() => removeCraneItem(fixture, 9)).toThrow();
});
it('只匹配目标路径,入口追加在现有工具后', () => {
const file = { index: 0, path: '\\ETC\\CRANEMINIGAMEITEM.ETC', text: '', editable: true };
expect(getGUIProvider(file)?.id).toBe('crane-minigame');
expect(getGUIProvider({ ...file, path: 'etc/other.etc' })).toBeNull();
});
it.skipIf(!process.env.PVF_GUI_CRANE_FIXTURE)('真实归档全部奖品可无损编辑、增删,保留尾部配置', () => {
const text = readFileSync(process.env.PVF_GUI_CRANE_FIXTURE!, 'utf8'), doc = parseCraneMinigame(text);
expect(doc.items.length).toBeGreaterThan(100);
expect(doc.material.map(t => t.value)).toEqual(['2660547', '1']);
for (const [index, item] of doc.items.entries()) {
const [id, count, viewRatio, pickRatio] = item.fields.map(t => Number(t.value));
expect(editCraneItem(text, index, { id: id!, count: count!, viewRatio: viewRatio!, pickRatio: pickRatio! })).toBe(text);
const field = item.fields[1];
expect(editCraneItem(text, index, { id: id!, count: count! + 1, viewRatio: viewRatio!, pickRatio: pickRatio! })).toBe(text.slice(0, field.start) + String(count! + 1) + text.slice(field.end));
}
const added = addCraneItem(text, values);
expect(parseCraneMinigame(added).items).toHaveLength(doc.items.length + 1);
expect(added.slice(added.indexOf('[material]'))).toBe(text.slice(text.indexOf('[material]')));
expect(parseCraneMinigame(removeCraneItem(text, 0)).items).toHaveLength(doc.items.length - 1);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { startDebugTiming } from "../src/stores/debugTiming";
import { useLogStore } from "../src/stores/logs";
describe("debug timing", () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.restoreAllMocks();
});
afterEach(() => vi.unstubAllEnvs());
it("records request start and elapsed time at DEBUG level", () => {
vi.spyOn(performance, "now").mockReturnValueOnce(100).mockReturnValueOnce(123.5);
const finish = startDebugTiming("editor.metadata-request", "file=42");
finish("failed");
const logs = useLogStore();
expect(logs.entries.map((entry) => entry.level)).toEqual(["DEBUG", "DEBUG"]);
expect(logs.entries[0].message).toBe("editor.metadata-request started file=42");
expect(logs.entries[1].message).toBe("editor.metadata-request failed file=42 elapsed=23.50ms");
expect(logs.filtered).toHaveLength(0);
logs.levels.push("DEBUG");
expect(logs.filtered).toHaveLength(2);
});
it("生产构建不启动计时、不记录性能日志", () => {
vi.stubEnv("DEV", false);
vi.stubEnv("MODE", "production");
const clock = vi.spyOn(performance, "now");
startDebugTiming("editor.metadata-request", "file=42")();
expect(clock).not.toHaveBeenCalled();
expect(useLogStore().entries).toHaveLength(0);
});
it("build:dev 的 development mode 仍记录性能日志", () => {
vi.stubEnv("DEV", false);
vi.stubEnv("MODE", "development");
startDebugTiming("editor.metadata-request", "file=42")();
expect(useLogStore().entries.map((entry) => entry.level)).toEqual(["DEBUG", "DEBUG"]);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { expect, test, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { reactive } from "vue";
const api = vi.hoisted(() => ({ archive: null as any, Read: vi.fn() }));
vi.mock("../src/stores/archive", () => ({ useArchiveStore: () => api.archive }));
vi.mock("../bindings/pvfine/services", () => ({ DropService: api }));
import {
groupTotal,
useDropRateStore,
percentToBasisPoints,
validateDropRateSections,
type DropRateSectionForm,
} from "../src/stores/dropRate";
function section(key: string, groupCount: number): DropRateSectionForm {
return {
key,
groups: Array.from({ length: groupCount }, () => ({
rates: [60, 24.49, 10.51, 5, 0],
})),
};
}
test("百分比转换只接受两位小数并使用百分之一单位", () => {
expect(percentToBasisPoints(12.34)).toBe(1234);
expect(percentToBasisPoints(0)).toBe(0);
expect(percentToBasisPoints(12.345)).toBeNull();
expect(percentToBasisPoints(100.01)).toBeNull();
});
test("每组总和以百分比显示并保持精度", () => {
expect(groupTotal({ rates: [60, 24.49, 10.51, 5, 0] })).toBe(100);
expect(groupTotal({ rates: [60, null, 10, 5, 0] })).toBeNull();
});
test("四类掉率的表单校验要求每组总和为 100.00%", () => {
const sections = [
section("hell", 2),
section("flip", 1),
section("monster", 4),
section("elite", 4),
];
expect(validateDropRateSections(sections)).toBe("");
sections[2].groups[0].rates[0] = 60.01;
expect(validateDropRateSections(sections)).toContain("小怪 普通");
});
test("已验证的六个版本可以打开基础掉率,其余格式仍禁用", async () => {
setActivePinia(createPinia());
api.archive = reactive({ open: true, info: { format: "tw" } });
const store = useDropRateStore();
const sections = [section("hell",2), section("flip",1), section("monster",4), section("elite",4)];
api.Read.mockResolvedValue({ pvfVersion: "TW", revision: 1, sections: sections.map(s => ({ key:s.key,groups:s.groups.map(g => ({ rates:g.rates.map(v => Math.round(v! * 100)) })) })) });
expect(store.supported).toBe(true);
expect(await store.load()).toBe(true);
expect(store.pvfVersion).toBe("TW");
expect(store.validationError).toBe("");
for (const format of ["standard","alternate","paged110","paged100cn","paged115cn"]) { api.archive.info.format=format; expect(store.supported).toBe(true); }
for (const format of ["paged115","paged","recovered"]) { api.archive.info.format=format; expect(store.supported).toBe(false); }
api.archive.info.format="tw"; api.archive.open=false; expect(store.supported).toBe(false);
});
@@ -0,0 +1,61 @@
import { expect, test } from "vitest";
import { ChangeSet } from "@codemirror/state";
import { annotationAt, forEachVisibleAnnotation, indexAnnotations, referenceAt } from "../src/editorAnnotations";
import type { EditorAnnotation } from "../bindings/pvfine/services/models";
function annotation(start: number, end: number, targetFileIndex = 7): EditorAnnotation {
return { start, end, targetFileIndex, title: "地图", content: "标注", type: "reference", ruleIds: [], image: null, inlineImage: false, placeholder: null };
}
test("插入后链接位置跟随文本,标注对象不被复制", () => {
const item = annotation(10, 20);
const ranges = indexAnnotations([item], 100).map(ChangeSet.of({ from: 0, insert: "abc" }, 100));
expect(referenceAt(ranges, 12)).toBeUndefined();
expect(referenceAt(ranges, 13)).toBe(item);
expect(referenceAt(ranges, 23)).toBeUndefined();
});
test("插入边界不扩张标注,删除目标后链接消失", () => {
const item = annotation(10, 20);
const ranges = indexAnnotations([item], 100);
const inserted = ranges.map(ChangeSet.of([{ from: 10, insert: "x" }, { from: 20, insert: "y" }], 100));
expect(referenceAt(inserted, 10)).toBeUndefined();
expect(referenceAt(inserted, 11)).toBe(item);
expect(referenceAt(inserted, 21)).toBeUndefined();
const deleted = ranges.map(ChangeSet.of({ from: 10, to: 20 }, 100));
expect(referenceAt(deleted, 10)).toBeUndefined();
});
test("非链接和越界范围不会误触发跳转", () => {
const ranges = indexAnnotations([annotation(-20, 2, -1), annotation(200, 300)], 100);
expect(referenceAt(ranges, 0)).toBeUndefined();
expect(referenceAt(ranges, 100)).toBeUndefined();
});
test("隐藏标注时仍能按文本范围定位非链接标注", () => {
const item = annotation(10, 20, -1);
const ranges = indexAnnotations([item], 100).map(ChangeSet.of({ from: 0, insert: "abc" }, 100));
expect(annotationAt(ranges, 12)).toBeUndefined();
expect(annotationAt(ranges, 13)).toBe(item);
expect(annotationAt(ranges, 22)).toBe(item);
expect(annotationAt(ranges, 23)).toBeUndefined();
});
test("五万条标注编辑后仍正确定位远端链接并共享对象", () => {
const items = Array.from({ length: 50000 }, (_, index) => annotation(index * 30, index * 30 + 20, index));
const ranges = indexAnnotations(items, 1500000).map(ChangeSet.of({ from: 0, insert: "abc" }, 1500000));
expect(referenceAt(ranges, 49999 * 30 + 3)).toBe(items[49999]);
expect(ranges.size).toBe(50000);
});
test("大量标注只遍历可见范围,折叠后的重复范围只处理一次", () => {
const items = Array.from({ length: 50000 }, (_, index) => annotation(index * 30, index * 30 + 20, index));
const ranges = indexAnnotations(items, 1500000);
const visible: number[] = [];
forEachVisibleAnnotation(ranges, [
{ from: 300, to: 360 },
{ from: 320, to: 350 },
{ from: 1499970, to: 1500000 },
], (_from, _to, value) => visible.push(value.annotation.targetFileIndex));
expect(visible).toEqual([10, 11, 12, 49999]);
});
+360
View File
@@ -0,0 +1,360 @@
import { beforeEach, expect, test, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { nextTick } from "vue";
const api = vi.hoisted(() => ({ GetFileBasic: vi.fn(), GetFile: vi.fn(), GetAnnotationsForText: vi.fn(), showArchiveEditor: vi.fn(), ApplyShopEdit: vi.fn(), refreshInfo: vi.fn(), Release: vi.fn(), ResolveFiles: vi.fn() }));
vi.mock("@wailsio/runtime", () => ({ Events: { On: vi.fn() } }));
vi.mock("../bindings/pvfine/services", () => ({ EditorService: api, ArchiveService: api, FileGUIService: api, ArchiveDiffService: api }));
vi.mock("../src/stores/archive", () => ({ useArchiveStore: () => ({ refreshInfo: api.refreshInfo }) }));
vi.mock("../src/stores/script", () => ({ useScriptStore: () => api }));
vi.mock("../src/stores/explorer", () => ({
useExplorerStore: () => ({ getFilePath: (index: number) => `map/${index}.lst`, selectedKey: null }),
}));
import { useEditorStore } from "../src/stores/editor";
import { longLineThreshold } from "../src/editorWrapping";
import { useArchiveDiffStore } from "../src/stores/archiveDiff";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
function meta(index: number, text = "1 `map/file.map`") {
return { index, path: `map/${index}.lst`, text, editable: true, dataType: 1, size: text.length, tags: [], annotations: [], modified: false };
}
beforeEach(() => {
setActivePinia(createPinia());
vi.resetAllMocks();
api.GetFile.mockResolvedValue(null);
api.GetAnnotationsForText.mockResolvedValue([]);
api.Release.mockResolvedValue(undefined);
api.ResolveFiles.mockResolvedValue([]);
});
test("对比草稿参与所有关闭路径的脏检查,取消保留,确认释放会话", () => {
const editor = useEditorStore();
const diff = useArchiveDiffStore();
editor.openCompareTab();
diff.setDraft("a.equ", "edited", "original");
expect(editor.dirtyCount).toBe(1);
editor.requestCloseTab(-999);
expect(editor.pendingClose).toMatchObject({ kind: "tab", index: -999 });
editor.cancelPendingClose();
expect(diff.isDraftDirty).toBe(true);
editor.requestCloseOthers(1);
expect(editor.pendingClose).toEqual({ kind: "others", keepIndex: 1 });
editor.cancelPendingClose();
editor.requestCloseAll();
expect(editor.pendingClose).toEqual({ kind: "all" });
editor.confirmPendingClose();
expect(editor.tabs).toHaveLength(0);
expect(diff.isDraftDirty).toBe(false);
expect(api.Release).toHaveBeenCalledTimes(1);
});
test("分屏关闭其中一个对比视图不释放仍在使用的草稿", () => {
const editor = useEditorStore();
const diff = useArchiveDiffStore();
editor.openCompareTab();
editor.split("columns");
diff.setDraft("a.equ", "edited", "original");
editor.closeTab(-999, "pane-2");
expect(diff.isDraftDirty).toBe(true);
expect(api.Release).not.toHaveBeenCalled();
editor.closeTab(-999, "pane-1");
expect(api.Release).toHaveBeenCalledTimes(1);
});
test("归档重载不把对比页伪索引传给 GetFile", async () => {
const editor = useEditorStore();
editor.openCompareTab();
await editor.refreshAfterArchiveChange(["__compare__"], true);
expect(api.GetFile).not.toHaveBeenCalled();
expect(editor.tabs[0]?.index).toBe(-999);
});
test("保存全部和活动标签通过对比保存接口写入草稿", async () => {
const editor = useEditorStore();
const diff = useArchiveDiffStore();
editor.openCompareTab();
diff.setDraft("a.equ", "edited", "original");
const save = vi.spyOn(diff, "mergeText").mockImplementation(async (_path, text) => {
diff.originalDraftText = text;
return true;
});
expect(await editor.saveAllDirty()).toBe(1);
expect(save).toHaveBeenCalledWith("a.equ", "edited");
expect(editor.dirtyCount).toBe(0);
diff.updateDraftText("again");
expect(await editor.saveActiveTab()).toBe(true);
expect(save).toHaveBeenLastCalledWith("a.equ", "again");
});
test("对比草稿未能保存时不能继续归档保存或备份", async () => {
const editor = useEditorStore();
const diff = useArchiveDiffStore();
editor.openCompareTab();
diff.setDraft("a.equ", "edited", "original");
vi.spyOn(diff, "mergeText").mockResolvedValue(false);
await expect(editor.saveAllDirty()).rejects.toThrow("对比草稿尚未保存");
expect(editor.dirtyCount).toBe(1);
});
test("自动换行在加载完成前按行长决定,普通文件默认开启", async () => {
api.GetFileBasic.mockImplementation(async (index) =>
meta(index, index === 1 ? "x".repeat(longLineThreshold + 1) : "short\nlines")
);
const store = useEditorStore();
await store.openFile(1);
expect(store.activeTab?.loading).toBe(false);
expect(store.activeTab?.lineWrapping).toBe(false);
await store.openFile(2);
expect(store.activeTab?.lineWrapping).toBe(true);
});
test("换行开关按文件保留,分屏共享,重新打开恢复自动判断", async () => {
api.GetFileBasic.mockImplementation(async (index) => meta(index));
const store = useEditorStore();
await store.openFile(1);
store.activeTab!.lineWrapping = false;
store.split("columns");
expect(store.activeTab?.lineWrapping).toBe(false);
await store.openFile(2);
expect(store.activeTab?.lineWrapping).toBe(true);
store.activateTab("pane-2", 1);
expect(store.activeTab?.lineWrapping).toBe(false);
store.closeTab(1, "pane-2");
store.closeTab(1, "pane-1");
await store.openFile(1);
expect(store.activeTab?.lineWrapping).toBe(true);
});
test("立即创建加载标签;重复打开共享请求", async () => {
const pending = deferred<ReturnType<typeof meta>>();
api.GetFileBasic.mockReturnValue(pending.promise);
const store = useEditorStore();
const opening = store.openFile(1);
expect(store.activeTab?.title).toBe("1.lst");
expect(store.activeTab?.loading).toBe(true);
expect(store.activeTab?.editable).toBe(false);
await store.openFile(1);
expect(api.GetFileBasic).toHaveBeenCalledTimes(1);
pending.resolve(meta(1));
await opening;
expect(store.tabs).toHaveLength(1);
expect(store.activeTab?.loading).toBe(false);
expect(store.activeTab?.text).toBe(meta(1).text);
});
test("请求完成不抢回用户后来选中的标签", async () => {
const first = deferred<ReturnType<typeof meta>>();
api.GetFileBasic.mockImplementation((index) => index === 1 ? first.promise : Promise.resolve(meta(index)));
const store = useEditorStore();
const opening = store.openFile(1);
await store.openFile(2);
first.resolve(meta(1));
await opening;
expect(store.activeKey).toBe(2);
});
test("关闭后重开同一文件,旧请求不能覆盖新标签", async () => {
const first = deferred<ReturnType<typeof meta>>();
api.GetFileBasic.mockReturnValueOnce(first.promise).mockResolvedValueOnce(meta(1, "new"));
const store = useEditorStore();
const opening = store.openFile(1);
await nextTick();
store.closeTab(1);
await store.openFile(1);
first.resolve(meta(1, "stale"));
await opening;
expect(store.tabs).toHaveLength(1);
expect(store.activeTab?.text).toBe("new");
});
test("加载失败保留标签并支持重试", async () => {
api.GetFileBasic.mockRejectedValueOnce(new Error("读取失败")).mockResolvedValueOnce(meta(1));
const store = useEditorStore();
await store.openFile(1);
expect(store.activeTab?.loadError).toBe("读取失败");
expect(store.opening).toBe(false);
await store.retryOpenFile(1);
expect(store.activeTab?.loadError).toBeNull();
expect(store.activeTab?.editable).toBe(true);
});
test("元数据未返回时正文已可编辑;延迟元数据不覆盖草稿", async () => {
const pending = deferred<ReturnType<typeof meta>>();
api.GetFileBasic.mockResolvedValue(meta(1, "original"));
api.GetFile.mockReturnValue(pending.promise);
const store = useEditorStore();
await store.openFile(1);
expect(store.activeTab?.loading).toBe(false);
expect(store.activeTab?.editable).toBe(true);
expect(store.activeTab?.text).toBe("original");
store.updateContent(1, "draft");
const enriched = { ...meta(1, "outdated"), icon: "icon.png" };
pending.resolve(enriched);
await vi.waitFor(() => expect(store.activeTab?.icon).toBe("icon.png"));
expect(store.activeTab?.text).toBe("draft");
expect(store.activeTab?.original).toBe("original");
});
test("元数据加载失败不影响正文与编辑状态", async () => {
api.GetFileBasic.mockResolvedValue(meta(1));
api.GetFile.mockRejectedValue(new Error("标注读取失败"));
const store = useEditorStore();
await store.openFile(1);
await nextTick();
expect(api.GetFile).toHaveBeenCalledTimes(1);
expect(store.activeTab?.loadError).toBeNull();
expect(store.activeTab?.text).toBe(meta(1).text);
expect(store.activeTab?.editable).toBe(true);
expect(store.activeTab?.loading).toBe(false);
});
test("分屏共享加载结果,关闭其中一处不会丢失文件", async () => {
const pending = deferred<ReturnType<typeof meta>>();
api.GetFileBasic.mockReturnValue(pending.promise);
const store = useEditorStore();
const opening = store.openFile(1);
store.split("columns");
store.closeTab(1, "pane-1");
pending.resolve(meta(1));
await opening;
expect(store.activeTab?.text).toBe(meta(1).text);
expect(store.activeTab?.loading).toBe(false);
});
test("临时隐藏标注只作用于当前文件,分屏共享,关闭后重开恢复", async () => {
api.GetFileBasic.mockImplementation((index) => Promise.resolve(meta(index)));
const store = useEditorStore();
await store.openFile(1);
await store.openFile(2);
store.toggleAnnotationsHidden(1);
expect(store.tabs.find((tab) => tab.index === 1)?.annotationsHidden).toBe(true);
expect(store.activeTab?.annotationsHidden).toBe(false);
store.activateTab("pane-1", 1);
store.split("columns");
expect(store.activeTab?.annotationsHidden).toBe(true);
store.closeTab(1, "pane-2");
expect(store.tabs.find((tab) => tab.index === 1)?.annotationsHidden).toBe(true);
store.closeTab(1, "pane-1");
await store.openFile(1);
expect(store.activeTab?.annotationsHidden).toBe(false);
});
test("加载中的标签也受数量上限约束", async () => {
api.GetFileBasic.mockImplementation(async (index) => meta(index));
const store = useEditorStore();
const openings = Array.from({ length: 20 }, (_, index) => store.openFile(index));
await expect(store.openFile(21)).rejects.toThrow("上限 20");
expect(store.tabs).toHaveLength(20);
await Promise.all(openings);
});
test("加载标签立即关闭时不发起读取,也不恢复标签", async () => {
const store = useEditorStore();
const opening = store.openFile(1);
store.closeAllTabs();
await opening;
expect(api.GetFileBasic).not.toHaveBeenCalled();
expect(api.GetFile).not.toHaveBeenCalled();
expect(store.tabs).toHaveLength(0);
expect(store.opening).toBe(false);
});
test("多个文件同时加载时各自维护状态", async () => {
const first = deferred<ReturnType<typeof meta>>();
const second = deferred<ReturnType<typeof meta>>();
api.GetFileBasic.mockImplementation((index) => index === 1 ? first.promise : second.promise);
const store = useEditorStore();
const openingFirst = store.openFile(1);
const openingSecond = store.openFile(2);
second.resolve(meta(2));
await openingSecond;
expect(store.tabs[0].loading).toBe(true);
expect(store.tabs[1].loading).toBe(false);
expect(store.opening).toBe(true);
first.resolve(meta(1));
await openingFirst;
expect(store.opening).toBe(false);
});
test("商店修改携带未保存草稿并同步所有受影响标签,不写磁盘", async () => {
api.GetFileBasic.mockImplementation(async (index) => meta(index, "original"));
const store = useEditorStore();
await store.openFile(1); await store.openFile(2);
store.updateContent(1, "shop draft"); store.updateContent(2, "item draft");
const pending = deferred<any>(); api.ApplyShopEdit.mockReturnValue(pending.promise);
const request = { fileIndex: 1, path: "map/1.lst", text: "shop draft", revision: 1, action: "edit-item" } as any;
const applying = store.applyShopEdit(request); await nextTick();
expect(store.guiApplying).toBe(true);
expect(api.ApplyShopEdit.mock.calls[0][0].drafts).toEqual([
{ fileIndex: 1, path: "map/1.lst", text: "shop draft" }, { fileIndex: 2, path: "map/2.lst", text: "item draft" },
]);
store.updateContent(1, "must not change during apply");
expect(store.tabs.find((tab) => tab.index === 1)?.text).toBe("shop draft");
api.GetFile.mockImplementation(async (index) => ({ ...meta(index, `after ${index}`), modified: true }));
pending.resolve({ revision: 2, files: [1, 2].map((index) => ({ fileIndex: index, path: `map/${index}.lst`, beforeText: index === 1 ? "shop draft" : "item draft", text: `after ${index}` })) });
await applying;
for (const tab of store.tabs) { expect(tab.text).toBe(`after ${tab.index}`); expect(tab.original).toBe(tab.text); expect(tab.modified).toBe(true); }
expect(store.guiApplying).toBe(false);
});
test("商店草稿过期或提交失败保留现有内容并释放锁", async () => {
api.GetFileBasic.mockResolvedValue(meta(1, "new draft"));
const store = useEditorStore(); await store.openFile(1);
const request = { fileIndex: 1, path: "map/1.lst", text: "old draft" } as any;
await expect(store.applyShopEdit(request)).rejects.toThrow("草稿已变化");
expect(api.ApplyShopEdit).not.toHaveBeenCalled();
api.ApplyShopEdit.mockRejectedValue(new Error("已过期"));
await expect(store.applyShopEdit({ ...request, text: "new draft" })).rejects.toThrow("已过期");
expect(store.tabs[0].text).toBe("new draft");
expect(store.guiApplying).toBe(false); expect(store.saving).toBe(false);
});
test("规则刷新按未保存文本重算标注并保留草稿", async () => {
api.GetFileBasic.mockResolvedValue(meta(1, "original"));
const store = useEditorStore();
await store.openFile(1);
store.updateContent(1, "draft\n[name]\n123");
api.GetFile.mockResolvedValue(meta(1, "rendered"));
api.GetAnnotationsForText.mockResolvedValue([{start:6,end:12,title:"new",type:"text"}]);
await store.refreshRenderedText();
expect(store.activeTab?.text).toBe("draft\n[name]\n123");
expect(api.GetAnnotationsForText).toHaveBeenLastCalledWith(1, "draft\n[name]\n123");
expect(store.activeTab?.annotations[0]?.start).toBe(6);
await store.refreshAnnotations();
expect(api.GetAnnotationsForText).toHaveBeenLastCalledWith(1, "draft\n[name]\n123");
});
test("对比 Tab 开启时不调用 GetFile(-999)", async () => {
const store = useEditorStore();
store.openCompareTab();
expect(store.tabs.some((t) => t.index === -999)).toBe(true);
await store.refreshRenderedText();
await store.refreshAnnotations();
expect(api.GetFile).not.toHaveBeenCalledWith(-999);
expect(api.GetAnnotationsForText).not.toHaveBeenCalledWith(-999, expect.anything());
});
test("AI 写入后同步文本基线,保留后端未保存标记,后续保存不被误当作本地草稿", async () => {
api.GetFileBasic.mockResolvedValue(meta(1, "old"));
const store = useEditorStore();
await store.openFile(1);
api.ResolveFiles.mockResolvedValue([{ fileIndex: 1, path: "map/1.lst", size: 3, dataType: 1 }]);
api.GetFile.mockResolvedValue({ ...meta(1, "new"), modified: true });
await store.syncMCPWorkspace();
expect(store.activeTab?.text).toBe("new");
expect(store.activeTab?.original).toBe("new");
expect(store.activeTab?.modified).toBe(true);
expect(store.dirtyCount).toBe(0);
expect(store.archiveDiffDrafts()).toEqual([]);
api.GetFile.mockResolvedValue({ ...meta(1, "new"), modified: false });
await store.syncMCPWorkspace();
expect(store.activeTab?.modified).toBe(false);
});
@@ -0,0 +1,23 @@
import { expect, test } from "vitest";
import { hasLongLine, longLineThreshold } from "../src/editorWrapping";
test("empty and ordinary documents keep wrapping enabled", () => {
expect(hasLongLine("")).toBe(false);
expect(hasLongLine("[section]\n1 2 3\n[/section]")).toBe(false);
});
test("only lines exceeding the threshold disable wrapping", () => {
expect(hasLongLine("x".repeat(longLineThreshold))).toBe(false);
expect(hasLongLine("x".repeat(longLineThreshold + 1))).toBe(true);
expect(hasLongLine("short\n" + "x".repeat(longLineThreshold + 1))).toBe(true);
});
test("LF, CRLF and CR reset the line length", () => {
for (const separator of ["\n", "\r\n", "\r"]) {
expect(hasLongLine(["x".repeat(longLineThreshold), "y".repeat(longLineThreshold)].join(separator))).toBe(false);
}
});
test("large total size does not disable wrapping for short lines", () => {
expect(hasLongLine(("x".repeat(100) + "\n").repeat(30_000))).toBe(false);
});
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { effectFrameIndex, particleInstances, type ParticleMotion } from '../src/gui/effectPlayback';
describe('特效预览时序与粒子运动', () => {
it('按原始时序循环或停在尾帧,处理空动画及零时长', () => {
expect(effectFrameIndex([],0,true)).toBe(-1);
expect(effectFrameIndex([100,200],150,true)).toBe(1);
expect(effectFrameIndex([100,200],350,true)).toBe(0);
expect(effectFrameIndex([100,200],350,false)).toBe(1);
expect(effectFrameIndex([0,0],20,true)).toBe(1);
});
const meta: ParticleMotion={life:1000,frequency:100,maximum:1000,maximumTime:0,gravity:100,position:[10,0,0],area:[0,0,0],alpha:[100,0],velocity:[20,0,0],acceleration:[0,0,0]};
it('限制实例数量,按寿命、速度、重力和透明度计算', () => {
const instances=particleInstances(meta,500);
expect(instances).toHaveLength(6); expect(instances[0]).toEqual({x:20,y:12.5,alpha:.5,age:500});
expect(particleInstances({...meta,life:60000,frequency:16},2000).length).toBeLessThanOrEqual(64);
});
it('生成时间截止后不创建新粒子,重播保持确定性', () => {
const value={...meta,maximumTime:100};
expect(particleInstances(value,500)).toHaveLength(2);
expect(particleInstances(meta,500)).toEqual(particleInstances(meta,500));
expect(particleInstances({...meta,frequency:-1},200)).toHaveLength(1);
});
});
+373
View File
@@ -0,0 +1,373 @@
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");
});
+94
View File
@@ -0,0 +1,94 @@
import { expect, test } from "vitest";
import { createGUIModes, createShopSession } from "../src/gui/state";
import { getGUIProvider } from "../src/gui/registry";
import type { ShopDocument } from "../bindings/pvfine/services/models";
const file = { index: 1, path: "itemshop/test.shp", text: "unsaved draft", editable: true };
function document(name = "商店"): ShopDocument {
return {
name, categoryType: "basic job", categories: [{ id: "0", name: "鬼剑士" }, { id: "1", name: "格斗家" }],
tabs: [0, 1].map((sourceStart) => ({ name: `分页${sourceStart}`, sourceStart, groups: [] })), issues: [],
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
test("仅支持的文件有 GUI,默认文本,各窗格独立并可清理", () => {
expect(getGUIProvider(file)?.readOnly).toBe(false);
expect(getGUIProvider({ ...file, path: "itemshop/SHOP.SHP" })?.id).toBe("shop");
expect(getGUIProvider({ ...file, path: "skill/atgunner/C4.SKL" })?.id).toBe("skill");
expect(getGUIProvider({ ...file, path: "a.equ" })?.id).toBe("item");
expect(getGUIProvider({ ...file, path: "a.txt" })).toBeNull();
const left = createGUIModes(), right = createGUIModes();
expect(left.entries.get(1)?.mode ?? "text").toBe("text");
left.set(1, "gui");
expect(right.entries.has(1)).toBe(false);
left.set(1, "text");
expect(left.entries.get(1)?.opened).toBe(true);
left.prune([]);
expect(left.entries.size).toBe(0);
right.set(1, "gui"); right.reset();
expect(right.entries.size).toBe(0);
});
test("慢加载立即显示状态,读取草稿,默认第一分类;刷新保留选择", async () => {
const pending = deferred<ShopDocument>();
const calls: unknown[] = [];
const session = createShopSession(async (index, text) => { calls.push([index, text]); return pending.promise; });
const loading = session.load(file, "archive1");
expect(session.loading.value).toBe(true);
expect(session.document.value).toBeNull();
expect(calls).toEqual([[1, "unsaved draft"]]);
pending.resolve(document()); await loading;
expect(session.categoryID.value).toBe("0");
session.tabIndex.value = 1; session.categoryID.value = "1";
await session.load({ ...file, text: "new draft" }, "archive1");
expect(session.tabIndex.value).toBe(1);
expect(session.categoryID.value).toBe("1");
expect(file.text).toBe("unsaved draft");
});
test("失败停止加载,保留上次内容,重试恢复", async () => {
let fail = false;
const session = createShopSession(async () => { if (fail) throw new Error("读取失败"); return document(); });
await session.load(file, "archive1"); fail = true;
await session.load(file, "archive1");
expect(session.loading.value).toBe(false);
expect(session.document.value?.name).toBe("商店");
expect(session.error.value).toBe("读取失败");
fail = false; await session.load(file, "archive1");
expect(session.error.value).toBe("");
});
test("切换文件或同路径重新打开归档时清空旧结果,过期请求不能覆盖", async () => {
const a = deferred<ShopDocument>(), b = deferred<ShopDocument>();
let count = 0;
const session = createShopSession(() => (++count === 1 ? a : b).promise);
const first = session.load(file, "epoch1");
const second = session.load(file, "epoch2");
expect(session.document.value).toBeNull();
b.resolve(document("新归档")); await second;
a.resolve(document("旧归档")); await first;
expect(session.document.value?.name).toBe("新归档");
const third = session.load({ ...file, index: 2 }, "epoch2");
expect(session.document.value).toBeNull();
await third;
});
test("卸载或退出 GUI 后忽略请求;空响应明确失败", async () => {
const pending = deferred<ShopDocument>();
const session = createShopSession(() => pending.promise);
const loading = session.load(file, "epoch1");
session.invalidate(true);
pending.resolve(document()); await loading;
expect(session.document.value).toBeNull();
expect(session.loading.value).toBe(false);
const empty = createShopSession(async () => null);
await empty.load(file, "epoch1");
expect(empty.error.value).toBe("未收到商店数据");
expect(empty.loading.value).toBe(false);
});
+68
View File
@@ -0,0 +1,68 @@
[name]
`阿尔比恩悬案称号宝珠自选礼盒 (1个)`
[explain]
`开启后, 可以选择获得1个拨云见日宝珠。
拨云见日宝珠只能用于2017劳动节称号。 使用后, 可以给称号赋予宝珠所拥有的技能加成。
拨云见日宝珠为账号绑定。`
[flavor text]
`<阿尔比恩悬案>`
[grade]
1
[rarity]
2
[usable job]
`[all]`
[/usable job]
[attach type]
`[account]`
[minimum level]
1
[icon]
`Item/stackable/cash_cn.img` 2271
[icon mark]
`item/IconMark.img` 34
[field image]
`Item/stackable/field_material_cn.img` 246
[stackable type]
`[booster selection]` 0
[move wav]
`BONE_TOUCH`
[booster category num]
1 0
[booster selection num]
1
[booster select category]
0 0
[stackable]
490700960 1 490700964 1 490700965 1 490700966 1 490700967 1 490700968 1 490700969 1 490700970 1 490700971 1 490700972 1 490700973 1 490700974 1 490700975 1 490700976 1
[/stackable]
[/booster select category]
[booster category name]
``
[/booster category name]
[impossible contents]
`gift`
[/impossible contents]
+307
View File
@@ -0,0 +1,307 @@
[name]
`武器史诗碎片自选礼盒`
[explain]
`开启后, 可以选择获得50个Lv80及以上的武器史诗碎片 (无法交易)。
获得的史诗碎片可以在菜单选择窗的史诗图鉴里确认。`
[flavor text]
`<幸运冒险家尊享>`
[grade]
1
[rarity]
2
[usable job]
`[all]`
[/usable job]
[attach type]
`[account]`
[minimum level]
1
[icon]
`Item/stackable/consumption.img` 363
[stackable type]
`[booster selection]` 0
[move wav]
`BONE_TOUCH`
[booster category num]
2 8 5
[booster selection num]
1
[booster select category]
0 0
[stackable]
10300537 50 10300539 50 10300540 50
[/stackable]
[/booster select category]
[booster select category]
0 1
[stackable]
10300521 50 10300523 50 10300524 50
[/stackable]
[/booster select category]
[booster select category]
0 2
[stackable]
10300526 50 10300528 50 10300529 50 10300530 50
[/stackable]
[/booster select category]
[booster select category]
0 3
[stackable]
10300532 50 10300534 50 10300535 50
[/stackable]
[/booster select category]
[booster select category]
0 4
[stackable]
10300515 50 10300517 50 10300518 50
[/stackable]
[/booster select category]
[booster select category]
1 0
[stackable]
10300547 50 10300549 50 10300550 50 10300551 50
[/stackable]
[/booster select category]
[booster select category]
1 1
[stackable]
10300563 50 10300565 50 10300566 50 10300567 50
[/stackable]
[/booster select category]
[booster select category]
1 2
[stackable]
10300542 50 10300544 50 10300545 50
[/stackable]
[/booster select category]
[booster select category]
1 3
[stackable]
10300553 50 10300555 50 10300556 50
[/stackable]
[/booster select category]
[booster select category]
1 4
[stackable]
10300558 50 10300560 50 10300561 50
[/stackable]
[/booster select category]
[booster select category]
2 0
[stackable]
10300569 50 10300571 50 10300572 50
[/stackable]
[/booster select category]
[booster select category]
2 1
[stackable]
10300584 50 10300585 50 10300586 50
[/stackable]
[/booster select category]
[booster select category]
2 2
[stackable]
10300579 50 10300581 50 10300582 50
[/stackable]
[/booster select category]
[booster select category]
2 3
[stackable]
10300574 50 10300576 50 10300577 50
[/stackable]
[/booster select category]
[booster select category]
2 4
[stackable]
10300589 50 10300591 50 10300592 50
[/stackable]
[/booster select category]
[booster select category]
3 0
[stackable]
10300599 50 10300601 50 10300602 50
[/stackable]
[/booster select category]
[booster select category]
3 1
[stackable]
10300604 50 10300606 50 10300607 50
[/stackable]
[/booster select category]
[booster select category]
3 2
[stackable]
10300614 50 10300616 50 10300617 50 10300618 50 10300619 50
[/stackable]
[/booster select category]
[booster select category]
3 3
[stackable]
10300609 50 10300611 50 10300612 50
[/stackable]
[/booster select category]
[booster select category]
3 4
[stackable]
10300594 50 10300596 50 10300597 50
[/stackable]
[/booster select category]
[booster select category]
4 0
[stackable]
10300641 50 10300643 50 10300644 50
[/stackable]
[/booster select category]
[booster select category]
4 1
[stackable]
10300636 50 10300638 50 10300639 50
[/stackable]
[/booster select category]
[booster select category]
4 2
[stackable]
10300621 50 10300623 50 10300624 50
[/stackable]
[/booster select category]
[booster select category]
4 3
[stackable]
10300626 50 10300628 50 10300629 50
[/stackable]
[/booster select category]
[booster select category]
4 4
[stackable]
10300631 50 10300633 50 10300634 50
[/stackable]
[/booster select category]
[booster select category]
5 0
[stackable]
10300646 50 10300648 50 10300649 50
[/stackable]
[/booster select category]
[booster select category]
5 1
[stackable]
10300651 50 10300653 50 10300654 50 10300655 50
[/stackable]
[/booster select category]
[booster select category]
5 2
[stackable]
10300657 50 10300659 50 10300660 50
[/stackable]
[/booster select category]
[booster select category]
5 3
[stackable]
10300663 50 10300664 50 10300665 50
[/stackable]
[/booster select category]
[booster select category]
5 4
[/booster select category]
[booster select category]
6 0
[stackable]
10300521 50 10300523 50 10300524 50
[/stackable]
[/booster select category]
[booster select category]
6 1
[stackable]
10300515 50 10300517 50 10300518 50
[/stackable]
[/booster select category]
[booster select category]
6 2
[/booster select category]
[booster select category]
6 3
[/booster select category]
[booster select category]
6 4
[/booster select category]
[booster select category]
7 0
[stackable]
10300699 50 10300696 50 10300700 50
[/stackable]
[/booster select category]
[booster select category]
7 1
[stackable]
10300703 50 10300704 50 10300705 50
[/stackable]
[/booster select category]
[booster select category]
7 2
[/booster select category]
[booster select category]
7 3
[/booster select category]
[booster select category]
7 4
[/booster select category]
[booster category name]
`请选择职业` `鬼剑士/黑暗武士` `格斗家` `神枪手` `魔法师/缔造者` `圣职者` `暗夜使者` `守护者` `魔枪士` `请选择类型。` `光剑` `巨剑` `太刀` `钝器` `短剑` `臂铠` `拳套` `手套` `爪` `东方棍` `左轮枪` `步枪` `手弩` `自动手枪` `手炮` `魔杖` `棍棒` `扫把` `法杖` `矛` `镰刀` `战斧` `十字架` `念珠` `图腾` `匕首` `双剑` `权杖` `苦无` `无` `巨剑` `短剑` `无` `无` `无` `长枪` `战戟`
[/booster category name]
[item category]
`event`
[/item category]
+45
View File
@@ -0,0 +1,45 @@
[name]
`感恩礼盒`
[explain]
`给予所有热爱DNF的勇士的谢礼! 感恩礼盒中会随机出现稀有、传承、神器等防具或挑战书、商城道具等!`
[flavor text]
`<感恩勇士送大礼活动>`
[grade]
1
[rarity]
2
[usable job]
`[all]`
[/usable job]
[attach type]
`[trade]`
[minimum level]
1
[cool time]
1000
[icon]
`Item/stackable/consumption_cn.img` 186
[field image]
`Item/FieldImage.img` 30
[stackable type]
`[booster]` 0
[move wav]
`BONE_TOUCH`
[booster info]
[etc]
1 15646 68 1 18913 68 1 18469 69 1 12485 1 1 3332 1500 12 15256 69 1 18483 1 1 13283 1 1 14505 68 1 13274 68 1 14079 1 1 10069 69 1 10896 68 1 14885 69 1 19650 1 1 18480 1 1 13273 68 1 18882 1 1 10871 67 1 17275 68 1 14093 67 1 12515 68 1 10475 67 1 18477 67 1 18081 67 1 15277 1 1 16877 1 1 14911 68 1 15650 1 1 14506 68 1 14068 69 1 13285 1 1 16478 1 1 10086 69 1 14479 1 1 12100 67 1 12470 69 1 13652 1 1 10922 68 1 16876 1 1 19646 68 1 13281 1 1 3330 2500 6 17273 68 1 17654 68 1 18091 1 1 14912 68 1 3330 900 6 17655 68 1 11637 68 1 10113 1 1 10881 1 1 17652 68 1 10064 69 1 10519 68 1 10894 69 1 14099 67 1 11276 68 1 18470 69 1 18484 1 1 18076 69 1 12081 1 1 19652 1 1 18090 1 1 18476 67 1 14084 69 1 14468 69 1 19282 1 1 18874 67 1 14886 68 1 11658 68 1 19269 68 1 10478 67 1 14063 69 1 19278 1 1 10875 67 1 16077 69 1 14876 1 1 15274 68 1 13284 1 1 11649 1 1 13634 68 1 10869 69 1 12111 1 1 19658 68 1 16073 69 1 10065 69 1 16084 69 1 15632 68 1 19254 69 1 18082 67 1 10872 67 1 14871 69 1 10085 69 1 10100 67 1 17629 68 1 18857 69 1 18089 1 1 18869 69 1 11274 68 1 10087 69 1 13275 68 1 13678 68 1 13253 69 1 17635 68 1 15267 68 1 11270 68 1 18854 69 1 10093 67 1 12095 67 1 16867 69 1 17256 68 1 16081 67 1 10095 67 1 16868 69 1 18084 69 1 16055 69 1 12091 67 1 12868 69 1 17271 68 1 13269 68 1 14875 67 1 13653 1 1 19632 68 1 14101 1 1 18078 69 1 11273 68 1 3331 14529 4 12477 67 1 15253 69 1 12457 69 1 10074 69 1 12106 1 1 16082 67 1 17653 68 1 15273 68 1 17646 68 1 1085 100 10 13286 1 1 16875 68 1 11259 68 1 13270 68 1 11648 68 1 11651 1 1 10068 69 1 12064 69 1 19661 68 1 12486 1 1 10477 67 1 13659 68 1 15657 68 1 16853 69 1 19659 68 1 13660 68 1 12108 1 1 12881 1 1 16476 67 1 16856 69 1 10921 68 1 14064 69 1 13650 1 1 14074 1 1 12894 68 1 11271 68 1 15655 68 1 10454 69 1 16086 1 1 14873 67 1 17632 68 1 15629 68 1 11268 68 1 10870 69 1 11650 1 1 10101 69 1 10489 1 1 16469 69 1 14095 67 1 1085 500 5 14090 69 1 10098 67 1 12475 67 1 15271 68 1 13649 1 1 3205 800 10 14874 67 1 18516 68 1 10077 2 1 16454 69 1 12859 69 1 16471 69 1 15272 68 1 16479 1 1 12875 67 1 13276 68 1 3205 4000 5 3332 500 15 10470 69 1 18455 69 1 11284 1 1 18458 69 1 18915 68 1 17277 1 1 19653 1 1 8 100 10 17253 68 1 18875 68 1 18071 69 1 10099 67 1 13268 68 1 19654 1 1 10885 1 1 12086 69 1 10110 1 1 11659 68 1 10094 67 1 18891 68 1 16873 68 1 10473 69 1 12092 67 1 19629 68 1 12097 67 1 19280 1 1 12882 1 1 10460 69 1 8 450 5 19271 68 1 14910 68 1 10082 1 1 16871 69 1 13651 1 1 16872 69 1 12460 69 1 12099 69 1 19273 68 1 10859 69 1 18515 68 1 12473 69 1 10873 67 1 3331 50 11 16058 69 1 19648 68 1 12063 69 1 12517 68 1 14083 69 1 10109 1 1 18056 69 1 16505 68 1 12853 69 1 12101 67 1 13647 68 1 3205 1000 8 18878 1 1 19268 68 1 10474 67 1 8 3100 3 18075 69 1 12876 67 1 18478 67 1 12870 69 1 19647 68 1 10853 69 1 1085 400 8 12871 67 1 14457 69 1 17268 68 1 10488 1 1 15648 68 1 12476 67 1 10874 67 1 16874 68 1 11631 68 1 11634 68 1 18871 69 1 3330 800 7 12076 1 1 13648 1 1 14082 69 1 14884 69 1 3331 500 11 14472 69 1 10486 1 1 12094 67 1 18868 69 1 16869 69 1 19651 1 1 19274 68 1 12089 69 1 12474 67 1 12072 69 1 10856 69 1 12872 67 1 3332 7000 8 10457 69 1 18086 67 1 15269 68 1 11655 1 1 12885 1 1 2681587 1000 1 14454 69 1 11654 1 1 12917 68 1 17274 68 1 16883 69 1 3330 7000 6 14870 69 1 18482 1 1 3330 500 11 12873 67 1 13272 68 1 18914 68 1 12069 1 1 10103 67 1 11282 1 1 14087 69 1 14853 69 1 16506 68 1 16085 67 1 13271 68 1 10923 68 1 13657 68 1 16475 67 1 18077 69 1 3330 4000 7 10487 1 1 12869 69 1 19281 1 1 10091 69 1 3205 200 15 16075 69 1 18481 1 1 12098 67 1 16083 69 1 11281 1 1 14872 69 1 15647 68 1 16904 68 1 1085 4000 3 12085 69 1 14098 69 1 15656 68 1 19257 69 1 10070 2 1 18870 69 1 12110 1 1 18079 69 1 11253 69 1 10518 68 1 14469 69 1 12107 1 1 11653 1 1 12856 69 1 11275 68 1 17649 1 1 12093 67 1 12488 1 1 12484 1 1 10882 1 1 10092 69 1 14476 67 1 10469 69 1 19660 68 1 14507 68 1 10112 1 1 14869 69 1 12915 68 1 13654 1 1 14089 69 1 12478 67 1 15654 68 1 14480 1 1 10097 67 1 12068 69 1 12090 69 1 15268 68 1 12469 69 1 17648 68 1 18072 69 1 16906 68 1 12489 1 1 8 350 8 17267 68 1 10096 67 1 11283 1 1 12916 68 1 14069 2 1 12109 1 1 14856 69 1 16870 69 1 3332 1000 12 11661 68 1 3331 450 7 11285 1 1 3331 1800 4 10520 68 1 10108 1 1 14477 67 1 11286 1 1 14094 67 1 18074 69 1 11652 1 1 16070 69 1 11272 68 1 10876 67 1 16079 67 1 18890 69 1 12893 69 1 18881 1 1 17647 68 1 14478 67 1 17276 1 1 18085 69 1 14091 69 1 15635 68 1 15270 68 1 11647 68 1 12479 67 1 18872 69 1 18876 68 1 12096 67 1 19276 68 1 17269 68 1 18889 69 1 12067 69 1 12892 69 1 19649 1 1 16076 69 1 16071 69 1 10886 1 1 13658 68 1 10485 1 1 18059 69 1 16468 69 1 11646 68 1 10883 1 1 10895 69 1 16078 69 1 16080 67 1 10884 1 1 10111 1 1 10476 67 1 3332 8500 8 19275 68 1 16074 69 1 14088 69 1 11256 69 1 10073 69 1 10484 1 1 10479 67 1 15276 1 1 12084 69 1 11660 68 1 12884 1 1 3332 500 15 18472 69 1 12487 1 1 18879 1 1 12073 69 1 18873 69 1 18880 1 1 13637 68 1 14877 1 1 12886 1 1 19270Line truncated
[/etc]
[/booster info]
+54
View File
@@ -0,0 +1,54 @@
[name]
`灿烂的徽章自选套装礼盒`
[explain]
`开启后, 可以从8种灿烂的徽章中选择1种, 并获得4个所选种类的徽章。`
[grade]
1
[rarity]
3
[usable job]
`[all]`
[/usable job]
[attach type]
`[free]`
[minimum level]
1
[icon]
`Item/stackable/cash.img` 133
[stackable type]
`[booster selection]` 0
[move wav]
`BONE_TOUCH`
[booster category num]
0
[booster selection num]
1
[booster select category]
0 0
[stackable]
2500033 4 2500036 4 2510046 4 2510037 4 2520037 4 2520043 4 2530040 4 2530037 4
[/stackable]
[/booster select category]
[booster category name]
`请选择徽章。`
[/booster category name]
[npc gift disallowance]
1
[impossible contents]
`gift`
[/impossible contents]
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { editForge } from "../src/gui/forge";
import { upgradeRange } from "../src/gui/upgrade";
import type { UpgradeRow } from "../bindings/pvfine/services/models";
function fixture() {
const normalized = "# 😀\n[table]\n2 1 10000 1\n3 2 5000 1.11\n[/table]\n[separate upgrade max]\n2\n[item weights by grade]\n96 3326 51\n100 3326 51\n[/item weights by grade]";
const field = (value: string, start: number) => ({ value: Number(value), start, end: start + value.length, tokenType: value.includes('.') ? 2 : 0 });
const row = (line: string, level: number, grade = false): UpgradeRow => {
const start = normalized.indexOf(line);
const fields = [...line.matchAll(/\d+(?:\.\d+)?/g)].map(m => field(m[0], start + m.index!));
return { level, fields: grade ? fields.slice(1) : fields, fieldsT2: [], costName: "强烈的气息" } as UpgradeRow;
};
return { text: normalized.replaceAll("\n", "\r\n"), rows: [row("2 1 10000 1",1), row("3 2 5000 1.11",2)], grades: [row("96 3326 51",96,true), row("100 3326 51",100,true)], max: [{ level: 1, fields: [field("2", normalized.indexOf("[separate upgrade max]\n") + "[separate upgrade max]\n".length)], fieldsT2: [], costName: "" } as UpgradeRow] };
}
describe("锻造 GUI 源码编辑", () => {
it("六列布局的归一化字段保留附加系数,只更新成功率", () => {
const text = "[table]\n2 1 2.6 1 10000 1\n[/table]";
const start = text.indexOf("10000");
const row = { level: 1, fields: [
{ value: 2, start: 8, end: 9, tokenType: 0 },
{ value: 1, start: 10, end: 11, tokenType: 0 },
{ value: 10000, start, end: start + 5, tokenType: 0 },
{ value: 1, start: start + 6, end: start + 7, tokenType: 0 },
], fieldsT2: [{ value: 2.6, start: 12, end: 15, tokenType: 2 }, { value: 1, start: 16, end: 17, tokenType: 0 }], costName: "" } as UpgradeRow;
expect(editForge(text, [row], "table", [{ column: 2, value: 75.25 }], 1)).toBe(text.replace("10000", "7525"));
});
it("成功率使用万分比,批量保留属性系数和 CRLF", () => {
const {text,rows}=fixture();
expect(editForge(text,rows,"table",[{column:2,value:75.25}],2)).toBe(text.replace("10000 1","7525 1").replace("5000 1.11","7525 1.11"));
expect(editForge(text,[rows[1]!],"table",[{column:3,value:1.5}],2)).toBe(text.replace("1.11","1.5"));
});
it("装备等级范围单独选择且仅替换材料和数量基数", () => {
const {text,grades}=fixture();
const chosen=upgradeRange(grades,[97,100]);
expect(chosen).toHaveLength(1);
expect(editForge(text,chosen,"grades",[{column:0,value:4444},{column:1,value:80}],2)).toBe(text.replace("100 3326 51","100 4444 80"));
});
it("最大等级限制及无效、过期和重叠数据拒绝", () => {
const {text,rows,max,grades}=fixture();
expect(editForge(text,max,"maximum",[{column:0,value:1}],2)).toBe(text.replace("[separate upgrade max]\r\n2","[separate upgrade max]\r\n1"));
for (const value of [0,3,1.5]) expect(()=>editForge(text,max,"maximum",[{column:0,value}],2)).toThrow();
expect(()=>editForge(text,rows,"table",[{column:2,value:101}],2)).toThrow();
expect(()=>editForge(text,rows,"table",[{column:0,value:1}],2)).toThrow();
expect(()=>editForge(text,grades,"grades",[{column:1,value:1.5}],2)).toThrow();
expect(()=>editForge(text,rows,"table",[{column:3,value:1.5}],2)).toThrow("整数");
expect(()=>editForge(text.replace("5000","6000"),rows,"table",[{column:2,value:1}],2)).toThrow("草稿已变化");
expect(()=>editForge(text,rows,"table",[{column:2,value:1},{column:2,value:2}],2)).toThrow("重叠");
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import { guiProviders, getGUIProvider } from "../src/gui/registry";
import { guiPathMatches, newGUIFilePath, searchGUIFiles } from "../src/gui/launcher";
import type { SearchHit, SearchResult } from "../bindings/pvfine/services/models";
const provider = (id: string) => guiProviders.find(item => item.id === id)!;
const hit = (path: string, fileIndex = 1) => ({ path, fileIndex, name: "测试" }) as SearchHit;
const result = (hits: SearchHit[], nextCursor = -1) => ({ hits, nextCursor }) as SearchResult;
describe("文件 GUI 工具入口", () => {
it("每个 GUI 都提供可识别的新建路径和类型搜索入口", () => {
expect(guiProviders.map(item => item.id)).toEqual(["shop", "world-drop", "skill", "quest", "upgrade", "forge", "item", "independent-drop", "vending-machine", "crane-minigame", "cera-shop"]);
for (const item of guiProviders) {
expect(item.launcher.queries.length).toBeGreaterThan(0);
for (const path of item.launcher.paths) {
expect(newGUIFilePath(item, path)).toBe(path);
expect(getGUIProvider({ index: 0, path, text: "", editable: true })?.id).toBe(item.id);
}
}
});
it("校验完整路径和准确文件类型,支持大小写及 Windows 分隔符", () => {
expect(newGUIFilePath(provider("shop"), " /NPC\\Shop\\NEW.SHP/ ")).toBe("NPC/Shop/NEW.SHP");
for (const path of ["", "npc//a.shp", "npc/../a.shp", "./a.shp", "npc/a.shp.txt", "npc/a.skl"]) {
expect(() => newGUIFilePath(provider("shop"), path)).toThrow();
}
expect(() => newGUIFilePath(provider("forge"), "etc/new.etc")).toThrow("支持的文件类型或路径");
expect(guiPathMatches(provider("world-drop"), "etc/otherworlddrop.etc")).toBe(false);
});
it("关键词结果跨过无关分页,并去重、保留后续游标", async () => {
const search = vi.fn()
.mockResolvedValueOnce(result([hit("npc/a.npc")], 100))
.mockResolvedValueOnce(result([hit("npc/a.shp"), hit("npc/a.shp")], 200));
const page = await searchGUIFiles(provider("shop"), "测试", { queryIndex: 0, offset: 0 }, search, () => true);
expect(page.hits.map(item => item.path)).toEqual(["npc/a.shp"]);
expect(page.next).toEqual({ queryIndex: 0, offset: 200 });
expect(search).toHaveBeenNthCalledWith(2, "测试", 100, 100);
});
it("空关键词依次搜索装备和消耗品,未注册文件也可按扩展名打开", async () => {
const search = vi.fn().mockResolvedValueOnce(result([hit("equipment/a.equ")])).mockResolvedValueOnce(result([hit("stackable/a.stk", 2)]));
const first = await searchGUIFiles(provider("item"), "", { queryIndex: 0, offset: 0 }, search, () => true);
expect(first.next).toEqual({ queryIndex: 1, offset: 0 });
const second = await searchGUIFiles(provider("item"), "", first.next!, search, () => true);
expect(second.hits[0]?.path).toBe("stackable/a.stk");
expect(second.next).toBeNull();
expect(search.mock.calls.map(call => call[0])).toEqual(["*.equ", "*.stk"]);
});
it("限定一次扫描量,保留继续搜索入口", async () => {
const search = vi.fn().mockImplementation(async (_query, cursor) => result([hit("other.txt")], cursor + 100));
const page = await searchGUIFiles(provider("quest"), "name", { queryIndex: 0, offset: 0 }, search, () => true);
expect(search).toHaveBeenCalledTimes(10);
expect(page.hits).toEqual([]);
expect(page.next).toEqual({ queryIndex: 0, offset: 1000 });
});
it("归档或查询变化时丢弃旧搜索结果并停止翻页", async () => {
const search = vi.fn().mockResolvedValue(result([hit("npc/a.shp")], 100));
expect(await searchGUIFiles(provider("shop"), "", { queryIndex: 0, offset: 0 }, search, () => false)).toEqual({ hits: [], next: null });
expect(search).toHaveBeenCalledTimes(1);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { createImageLoader } from "../src/utils/image-loader";
import type { ImageBatchResult } from "../bindings/pvfine/services/models";
const image = { dataUrl: "data:image/png;base64,test", width: 1, height: 1 };
const reference = { path: "Item/test.img", index: 0 };
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
test("coalesces visible requests, deduplicates pending work and uses completed cache", async () => {
const batch = vi.fn(async (refs) => refs.map(() => ({ data: image, error: "" })));
const loader = createImageLoader(() => 3, batch);
const first = loader.load(reference);
expect(loader.load({ path: "item\\test.img", index: 0 })).toBe(first);
const second = loader.load({ path: "item/other.img", index: 2 });
expect(batch).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(8);
expect(batch).toHaveBeenCalledExactlyOnceWith([
{ path: "item/test.img", index: 0 }, { path: "item/other.img", index: 2 },
], 3);
expect(await first).toEqual(image);
expect(await second).toEqual(image);
expect(await loader.load(reference)).toEqual(image);
expect(batch).toHaveBeenCalledTimes(1);
});
test("bounds batch size and simultaneous bridge calls", async () => {
const requests = Array.from({ length: 3 }, () => deferred<ImageBatchResult[]>());
const batch = vi.fn().mockImplementationOnce(() => requests[0]!.promise)
.mockImplementationOnce(() => requests[1]!.promise).mockImplementationOnce(() => requests[2]!.promise);
const loader = createImageLoader(() => 1, batch);
const promises = Array.from({ length: 300 }, (_, index) => loader.load({ path: "item/test.img", index }));
await vi.advanceTimersByTimeAsync(8);
expect(batch).toHaveBeenCalledTimes(2);
expect(batch.mock.calls[0]![0]).toHaveLength(128);
expect(batch.mock.calls[1]![0]).toHaveLength(128);
requests[0]!.resolve(Array.from({ length: 128 }, () => ({ data: image, error: "" })));
await vi.advanceTimersByTimeAsync(0);
expect(batch).toHaveBeenCalledTimes(3);
expect(batch.mock.calls[2]![0]).toHaveLength(44);
requests[1]!.resolve([]);
requests[2]!.resolve([]);
expect((await Promise.all(promises)).filter(Boolean)).toHaveLength(128);
});
test("reset settles queued and active requests and rejects late generation results", async () => {
let generation = 1;
const old = deferred<ImageBatchResult[]>();
const batch = vi.fn().mockImplementationOnce(() => old.promise)
.mockResolvedValue([{ data: image, error: "" }]);
const loader = createImageLoader(() => generation, batch);
const active = loader.load(reference);
await vi.advanceTimersByTimeAsync(8);
const queued = loader.load({ path: "queued.img", index: 0 });
generation = 2;
loader.reset();
expect(await active).toBeNull();
expect(await queued).toBeNull();
const current = loader.load(reference);
await vi.advanceTimersByTimeAsync(8);
expect(await current).toEqual(image);
old.resolve([{ data: { ...image, width: 99 }, error: "" }]);
await vi.advanceTimersByTimeAsync(0);
expect(await loader.load(reference)).toEqual(image);
expect(batch.mock.calls[1]![1]).toBe(2);
});
test("same-generation reset prevents old completion from removing a replacement pending request", async () => {
const old = deferred<ImageBatchResult[]>();
const current = deferred<ImageBatchResult[]>();
const batch = vi.fn().mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise);
const loader = createImageLoader(() => 1, batch);
const first = loader.load(reference);
await vi.advanceTimersByTimeAsync(8);
loader.reset();
expect(await first).toBeNull();
const replacement = loader.load(reference);
await vi.advanceTimersByTimeAsync(8);
old.resolve([{ data: image, error: "" }]);
await vi.advanceTimersByTimeAsync(0);
expect(loader.load(reference)).toBe(replacement);
current.resolve([{ data: image, error: "" }]);
expect(await replacement).toEqual(image);
});
test("isolates per-frame errors and retries failures without caching them", async () => {
const onError = vi.fn();
const batch = vi.fn().mockResolvedValueOnce([{ data: null, error: "broken frame" }, { data: image, error: "" }])
.mockRejectedValueOnce(new Error("bridge failure")).mockResolvedValueOnce([{ data: image, error: "" }]);
const loader = createImageLoader(() => 1, batch, onError);
const bad = loader.load(reference);
const good = loader.load({ path: "other.img", index: 0 });
await vi.advanceTimersByTimeAsync(8);
expect(await bad).toBeNull();
expect(await good).toEqual(image);
const failed = loader.load(reference);
await vi.advanceTimersByTimeAsync(8);
expect(await failed).toBeNull();
const retry = loader.load(reference);
await vi.advanceTimersByTimeAsync(8);
expect(await retry).toEqual(image);
expect(onError).toHaveBeenCalledTimes(2);
});
test("invalid inputs create no bridge work", async () => {
const batch = vi.fn();
const loader = createImageLoader(() => 1, batch);
for (const input of [null, { path: "", index: 0 }, { path: "///", index: 0 }, { path: "x", index: -1 }, { path: "x", index: 0.5 }]) {
expect(await loader.load(input)).toBeNull();
}
await vi.advanceTimersByTimeAsync(8);
expect(batch).not.toHaveBeenCalled();
});
@@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import { applyDropPatches, convertDropRecord, dropListText, dropRecordText, editDropFields, replaceDropSpan, sourceOffset } from "../src/gui/independentDrop";
import type { IndependentDropRow, SkillNumber } from "../bindings/pvfine/services/models";
function fixture() {
const line="0 20 3176 1000000 20 30 40 50 1 2 3 4 5 0 99 -1 1";
const normalized="# 😀\n[independent drop]\n"+line+"\n[list]\n3176 20 3030 80\n[/list]\n[replace list]\n[dungeon condition]\n1 20 2\n[/replace list]\n[/independent drop]\n";
const start=normalized.indexOf(line);
const fields=[...line.matchAll(/-?\d+/g)].map(m=>({value:Number(m[0]),start:start+m.index!,end:start+m.index!+m[0].length,tokenType:0}) as SkillNumber);
return {text:normalized.replaceAll("\n","\r\n"),row:{fields,start,end:normalized.indexOf("[/independent drop]"),hasExtensions:true} as IndependentDropRow};
}
describe("独立掉落源位置编辑",()=>{
it("概率只修改目标字段,保留扩展条件、注释、emoji 和 CRLF",()=>{
const {text,row}=fixture();
expect(editDropFields(text,row,[{column:3,value:752500},{column:8,value:7}])).toBe(text.replace("1000000","752500").replace("1 2 3 4 5 0 99","7 2 3 4 5 0 99"));
});
it("处理列表增删、零权重和重复 ID",()=>{
const content=dropListText([{id:3176,weight:0},{id:3176,weight:2}]);
expect(content).toContain("3176\t0");
const {text}=fixture();const normalized=text.replaceAll("\r\n","\n");
const from=normalized.indexOf("[list]")+6,to=normalized.indexOf("[/list]");
const edited=replaceDropSpan(text,from,to,"\n"+content+"\n");
expect(edited).toContain("[replace list]\r\n[dungeon condition]");
expect(edited).toContain("3176\t2");
expect(sourceOffset(text,3)).toBe(3);
});
it("三种模式生成完整记录",()=>{
const {row}=fixture(),values=row.fields!.map(f=>f.value);
for(const mode of [0,1,2]){
const record=dropRecordText([...values.slice(0,16),mode],[{id:3176,weight:1}],17);
expect(record.includes("[list]")).toBe(mode!==0);
if(mode===2)expect(record).toContain("\t\t17\n");
}
});
it("转换掉落模式时保留相邻注释和额外 section",()=>{
const {text,row}=fixture();
const clean=text.replace(/\[replace list\][\s\S]*?\[\/replace list\]\r\n/,"");
const normalized=clean.replaceAll("\r\n","\n");
const source={...row,hasExtensions:false,listTagStart:normalized.indexOf("[list]"),listStart:normalized.indexOf("[list]")+6,listEnd:normalized.indexOf("[/list]")};
const values=row.fields!.map(f=>f.value);values[16]=2;
const edited=convertDropRecord(clean,source,values,[],17);
expect(edited).toContain("[list]\n\t\t17\n\t[/list]");
expect(edited).toContain("# 😀\r\n[independent drop]\r\n");
expect(edited.match(/\[list\]/g)).toHaveLength(1);
expect(edited).toContain("[/independent drop]\r\n");
expect(()=>convertDropRecord(text,row,values,[],17)).toThrow("扩展");
});
it("拒绝过期、重叠、未知模式、溢出和非法概率",()=>{
const {text,row}=fixture();
expect(()=>editDropFields(text.replace("1000000","900000"),row,[{column:3,value:1}])).toThrow("草稿已变化");
for(const value of [-1,1000001,NaN,1.5])expect(()=>editDropFields(text,row,[{column:3,value}])).toThrow();
expect(()=>editDropFields(text,row,[{column:16,value:0}])).toThrow();
expect(()=>editDropFields(text,row,[{column:3,value:1},{column:3,value:2}])).toThrow("重叠");
expect(()=>applyDropPatches(text,[{token:row.fields![1]!,value:2147483648}])).toThrow();
expect(()=>dropListText([{id:1,weight:-1}])).toThrow();
expect(()=>sourceOffset(text,99999)).toThrow();
expect(()=>dropRecordText([...row.fields!.slice(0,16).map(f=>f.value),5],[],0)).toThrow("未知");
});
});
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { applyBoosterInlineAction, changeBoosterCategory } from '../src/gui/itemBoosterActions';
import { itemTemplate, newBoosterRow, readBooster } from '../src/gui/itemBooster';
const one=itemTemplate('selection');
const block=(x:number,y:number,contents='')=>`[booster select category]\n${x} ${y}\n${contents}[/booster select category]\n`;
const two=`[stackable type]\n\`[booster selection]\` 0\n[booster category num]\n2 2 2\n[booster selection num]\n1\n${block(0,0,'[booster equipment upgrade]\n10 # 强化\n[equipment]\n100 1\n[/equipment]\n')}${block(0,1,'[avatar]\n200 1 3 0 # 时装\n[/avatar]\n')}${block(1,0,'[stackable]\n300 2\n[/stackable]\n')}${block(1,1)}[booster category name]\n\`请选择职业\` \`职业甲\` \`职业乙\` \`请选择物品\` \`武器\` \`时装\` \`材料\`\n[/booster category name]\n[unknown]\n{5=1234} # 其他字段\n`;
const checked=(text:string)=>{const doc=readBooster(text);expect(doc.issue).toBe('');return doc;};
const get=(text:string,x:number,y=0)=>checked(text).groups.find(g=>g.coordinates.join(':')===`${x}:${y}`)!;
describe('礼包行内分类管理',()=>{
it('单层重命名仅修改名称 token,保留 CRLF 与其他字段',()=>{
const text=(one+'[unknown]\n123\n').replaceAll('\n','\r\n');
expect(changeBoosterCategory(text,{kind:'rename-category',level:0,index:0,name:'新分类'})).toBe(text.replace('类别 1','新分类'));
expect(changeBoosterCategory(text,{kind:'rename-category',level:0,index:0,name:'类别 1'})).toBe(text);
});
it('添加空单层分类同步维度、坐标、名称,可立即添加物品',()=>{
let text=changeBoosterCategory(one,{kind:'add-category',level:0,name:'新材料'}),doc=checked(text);
expect(doc.dimensions).toEqual(['1','2']);expect(doc.names).toEqual(['请选择。','类别 1','新材料']);expect(doc.groups[1]!.rows).toEqual([]);
text=applyBoosterInlineAction(text,{kind:'add-item',group:1,row:{id:'200',quantity:'3',weight:''}});
expect(get(text,1).rows[0]).toMatchObject({id:'200',quantity:'3'});
});
it('删除单层分类只删除该类并重排坐标;删除最后一类变为无分类空列表',()=>{
let text=changeBoosterCategory(one,{kind:'add-category',level:0,name:'第二类'});
text=applyBoosterInlineAction(text,{kind:'add-item',group:1,row:{id:'200',quantity:'3',weight:''}});
text=changeBoosterCategory(text,{kind:'delete-category',level:0,index:0});
expect(checked(text).names).toEqual(['请选择。','第二类']);expect(get(text,0).rows[0]!.id).toBe('200');
text=changeBoosterCategory(text,{kind:'delete-category',level:0,index:0});
expect(checked(text).dimensions).toEqual(['0']);expect(get(text,0).rows).toEqual([]);expect(checked(text).names).toEqual(['请选择。']);
});
it('无分类列表添加分类时保留已有物品;缺省名称段可创建',()=>{
const flat=one.replace('[booster category num]\n1 1','[booster category num]\n0').replace(/\[booster category name\][\s\S]*?\[\/booster category name\]/,'');
const text=changeBoosterCategory(flat,{kind:'add-category',level:0,name:'新类'}),doc=checked(text);
expect(doc.dimensions).toEqual(['1','2']);expect(doc.names).toEqual(['请选择。','默认分类','新类']);expect(get(text,0).rows[0]!.id).toBe('1');
});
it('无分类空列表添加首类不创建多余默认分类',()=>{
const empty=one.replace('[booster category num]\n1 1','[booster category num]\n0').replace('`请选择。` `类别 1`','`请选择。`').replace('1 1\n[/etc]','\n[/etc]');
const doc=checked(changeBoosterCategory(empty,{kind:'add-category',level:0,name:'首类'}));
expect(doc.dimensions).toEqual(['1','1']);expect(doc.names).toEqual(['请选择。','首类']);
});
it('二维添加父分类保持其他奖励块、强化、avatar 参数及省略名称可解析',()=>{
const text=changeBoosterCategory(two,{kind:'add-category',level:0,name:'职业丙'}),doc=checked(text);
expect(doc.dimensions).toEqual(['2','3','2']);expect(doc.groups).toHaveLength(6);expect(doc.names.slice(0,5)).toEqual(['请选择职业','职业甲','职业乙','职业丙','请选择物品']);
expect(text).toContain(block(0,0,'[booster equipment upgrade]\n10 # 强化\n[equipment]\n100 1\n[/equipment]\n'));
expect(get(text,0,1).rows[0]!.avatarParams).toEqual(['3','0']);expect(text).toContain('[unknown]\n{5=1234} # 其他字段');
});
it('二维删除父分类同步名称与坐标,不删除其他父分类物品',()=>{
const text=changeBoosterCategory(two,{kind:'delete-category',level:0,index:0}),doc=checked(text);
expect(doc.dimensions).toEqual(['2','1','1']);expect(doc.names).toEqual(['请选择职业','职业乙','请选择物品','材料']);
expect(get(text,0,0).rows[0]!.id).toBe('300');expect(text).not.toContain('100 1');expect(text).not.toContain('200 1');
});
it('增加子分类优先复用当前父分类空位,不改其他父分类',()=>{
const text=changeBoosterCategory(two,{kind:'add-category',level:1,parent:1,name:'消耗品'}),doc=checked(text);
expect(doc.dimensions).toEqual(['2','2','2']);expect(doc.names.at(-1)).toBe('消耗品');expect(get(text,1,1).rows).toEqual([]);
expect(get(text,0,0).equipmentUpgrade).toBe('10');expect(get(text,0,1).rows[0]!.avatarParams).toEqual(['3','0']);
});
it('子分类已满时扩展矩形宽度,其他父分类补空位,原数据不串类',()=>{
const text=changeBoosterCategory(two,{kind:'add-category',level:1,parent:0,name:'新武器'}),doc=checked(text);
expect(doc.dimensions).toEqual(['2','2','3']);expect(doc.groups).toHaveLength(6);
expect(doc.names).toEqual(['请选择职业','职业甲','职业乙','请选择物品','武器','时装','新武器','材料']);
expect(get(text,0,2).rows).toEqual([]);expect(get(text,1,0).rows[0]!.id).toBe('300');expect(get(text,1,2).rows).toEqual([]);
});
it('仅删除选中父分类的子分类并收缩尾部空位,保留其余分类奖励',()=>{
const text=changeBoosterCategory(two,{kind:'delete-category',level:1,parent:0,index:0}),doc=checked(text);
expect(doc.dimensions).toEqual(['2','2','1']);expect(doc.names).toEqual(['请选择职业','职业甲','职业乙','请选择物品','时装','材料']);
expect(get(text,0,0).rows[0]!.id).toBe('200');expect(get(text,0,0).rows[0]!.avatarParams).toEqual(['3','0']);expect(get(text,1,0).rows[0]!.id).toBe('300');
});
it('子分类重命名不改其他名称或强化字段',()=>{
expect(changeBoosterCategory(two,{kind:'rename-category',level:1,parent:0,index:0,name:'强化装备'})).toBe(two.replace('`武器`','`强化装备`'));
});
it('新增和删除分类时完整保留移动后的字符串引用及类型标记;引用不可直接重命名',()=>{
const text=two.replace('`职业乙`','{8=`<31::job_b>`}').replace('`材料`','{10=`<31::material>`}');
const next=changeBoosterCategory(text,{kind:'delete-category',level:0,index:0});
expect(checked(next).names).toEqual(['请选择职业','<31::job_b>','请选择物品','<31::material>']);
expect(next).toContain('{8=`<31::job_b>`}');expect(next).toContain('{10=`<31::material>`}');
expect(()=>changeBoosterCategory(text,{kind:'rename-category',level:0,index:1,name:'错误替换'})).toThrow('字符串表引用');
});
it('用户真实二维样例可增删父分类,未改奖励逐块保留',()=>{
const text=readFileSync(new URL('./fixtures/epic-fragment-selection.stk',import.meta.url),'utf8');
const next=changeBoosterCategory(text,{kind:'delete-category',level:0,index:0}),doc=checked(next);
expect(doc.dimensions[1]).toBe('7');
expect(doc.groups[0]!.rows.map(r=>r.id)).toEqual(readBooster(text).groups.find(g=>g.coordinates.join(':')==='1:0')!.rows.map(r=>r.id));
expect(checked(changeBoosterCategory(next,{kind:'add-category',level:0,name:'新增职业'})).dimensions[1]).toBe('8');
});
it('重命名保留矩形尾部空位及其省略名称,不擅自收缩维度',()=>{
const text=two.replace('2 2 2','2 2 3').replace('[booster category name]',block(0,2)+block(1,2)+'[booster category name]').replace('`时装` `材料`','`时装` `` `材料`');
checked(text);
expect(changeBoosterCategory(text,{kind:'rename-category',level:0,index:1,name:'职业丙'})).toBe(text.replace('`职业乙`','`职业丙`'));
});
it('分类名称中的反引号正确转义,新增分类支持 CRLF',()=>{
const text=one.replaceAll('\n','\r\n');
const next=changeBoosterCategory(text,{kind:'add-category',level:0,name:'测试`名称'});
expect(checked(next).names.at(-1)).toBe('测试`名称');expect(next).toContain('`测试``名称`');
expect(next.replaceAll('\r\n','')).not.toContain('\n');
});
it('删除最后一个二维父分类后,其他字段保留且可重新建立分类',()=>{
let text=changeBoosterCategory(two,{kind:'delete-category',level:0,index:0});
text=changeBoosterCategory(text,{kind:'delete-category',level:0,index:0});
expect(checked(text).dimensions).toEqual(['0']);expect(get(text,0).rows).toEqual([]);
expect(text).toContain('[unknown]\n{5=1234} # 其他字段');
text=changeBoosterCategory(text,{kind:'add-category',level:0,name:'新分类'});
expect(checked(text).names).toEqual(['请选择职业','新分类']);
});
it('无分类旧式第二条名称在建立分类时保留为默认分类名称',()=>{
const text=one.replace('[booster category num]\n1 1','[booster category num]\n1 0');
const next=checked(changeBoosterCategory(text,{kind:'add-category',level:0,name:'新类别'}));
expect(next.names).toEqual(['请选择。','类别 1','新类别']);
});
it('拒绝空名称、错误索引及随机礼包分类操作',()=>{
expect(()=>changeBoosterCategory(one,{kind:'add-category',level:0,name:' '})).toThrow('不能为空');
expect(()=>changeBoosterCategory(one,{kind:'delete-category',level:0,index:9})).toThrow('分类已变化');
expect(()=>changeBoosterCategory(one,{kind:'add-category',level:1,name:'子类'})).toThrow('没有二级分类');
expect(()=>changeBoosterCategory(itemTemplate('booster'),{kind:'add-category',level:0,name:'子类'})).toThrow('没有自选分类');
});
});
describe('礼包行内物品管理',()=>{
it('编辑物品保留强化字段与精确 token 定位',()=>{
const text=applyBoosterInlineAction(two,{kind:'edit-item',group:0,index:0,row:{id:'101',quantity:'4',weight:''}});
expect(text).toBe(two.replace('100 1','101 4'));expect(get(text,0,0).equipmentUpgrade).toBe('10');
});
it('删除最后一个物品得到空分类,可重新添加;avatar 参数不丢失',()=>{
const row={...get(two,0,1).rows[0]!,avatarParams:['4','0'] as [string,string]};
let text=applyBoosterInlineAction(two,{kind:'delete-item',group:1,index:0});expect(get(text,0,1).rows).toEqual([]);
text=applyBoosterInlineAction(text,{kind:'add-item',group:1,row});expect(get(text,0,1).rows[0]!.avatarParams).toEqual(['4','0']);expect(text).toContain('[avatar]');
});
it('随机池新增候选保留抽取项数;删除最后候选后可从空礼包重新添加',()=>{
let text=itemTemplate('booster');const row=newBoosterRow(readBooster(text),readBooster(text).groups[0]);row.id='200';
text=applyBoosterInlineAction(text,{kind:'add-item',group:0,row});expect(checked(text).groups[0]!.pools).toHaveLength(1);
text=applyBoosterInlineAction(text,{kind:'delete-item',group:0,index:1});
text=applyBoosterInlineAction(text,{kind:'delete-item',group:0,index:0});expect(checked(text).groups[0]!.rows).toEqual([]);
text=applyBoosterInlineAction(text,{kind:'add-item',group:0,row});expect(checked(text).groups[0]!.rows[0]!.id).toBe('200');expect(checked(text).groups[0]!.pools![0]!.drawCount).toBe('1');
});
it('行内修改可选数量与随机项数,候选物品数据保持原样',()=>{
expect(checked(applyBoosterInlineAction(one,{kind:'settings',selection:'2'})).selection).toBe('2');
const text=itemTemplate('booster');expect(applyBoosterInlineAction(text,{kind:'settings',pool:0,drawCount:'3'})).toBe(text.replace('1 1 1000 1','3 1 1000 1'));
});
it('无分类列表可删除最后项且保持缺省名称段;非法数值不产生输出',()=>{
const text=one.replace('[booster category num]\n1 1','[booster category num]\n0').replace(/\[booster category name\][\s\S]*?\[\/booster category name\]/,'');
const next=applyBoosterInlineAction(text,{kind:'delete-item',group:0,index:0});expect(get(next,0).rows).toEqual([]);expect(next).not.toContain('[booster category name]');
expect(()=>applyBoosterInlineAction(text,{kind:'add-item',group:0,row:{id:'bad',quantity:'1',weight:''}})).toThrow('正整数');
expect(()=>applyBoosterInlineAction(text,{kind:'edit-item',group:0,index:9,row:{id:'100',quantity:'1',weight:''}})).toThrow('物品已变化');
});
});
describe('商城道具奖励行内编辑',()=>{
const text=itemTemplate('booster').replace('[etc]\n1 1 1000 1\n[/etc]','[cera]\n1 42 1000 50\n[/cera]');
it('修改项数、编辑、添加及删除商城道具,保存仍使用 cera',()=>{
let result=applyBoosterInlineAction(text,{kind:'settings',pool:0,drawCount:'2'});
result=applyBoosterInlineAction(result,{kind:'edit-item',group:0,index:0,row:{id:'43',weight:'500',quantity:'100'}});
result=applyBoosterInlineAction(result,{kind:'add-item',group:0,row:{poolIndex:0,id:'44',weight:'500',quantity:'200'}});
let doc=checked(result);expect(doc.groups[0]!.pools![0]!.drawCount).toBe('2');
expect(doc.groups[0]!.rows.map(r=>[r.id,r.weight,r.quantity])).toEqual([['43','500','100'],['44','500','200']]);
result=applyBoosterInlineAction(result,{kind:'delete-item',group:0,index:0});doc=checked(result);
expect(doc.groups[0]!.rows.map(r=>r.id)).toEqual(['44']);expect(result).toContain('[cera]');expect(result).not.toContain('[etc]');
});
});
+414
View File
@@ -0,0 +1,414 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { addBoosterCategory, boosterGroupLabel, boosterRewardName, editBooster, isFlatBoosterSelection, itemTemplate, newBoosterRow, readBooster } from '../src/gui/itemBooster';
const booster='#PVF_File\r\n[stackable type]\r\n`[booster]` 0\r\n[booster info]\r\n[etc]\r\n1 100 500 2 # 第一项\r\n[/etc]\r\n[stackable]\r\n1 200 500 3 # 第二项\r\n[/stackable]\r\n[/booster info]\r\n[unknown]\r\n{5=1234}\r\n';
// Keep fixture baselines stable under core.autocrlf; CRLF cases are constructed explicitly.
const fixture=(name:string)=>readFileSync(new URL(`./fixtures/${name}`,import.meta.url),'utf8').replace(/\r\n?/g,'\n');
const uncategorizedSelection=fixture('uncategorized-selection.stk');
const epicSelection=fixture('epic-fragment-selection.stk');
const emptyPromptSelection=fixture('empty-prompt-selection.stk');
const clone=(text:string)=>JSON.parse(JSON.stringify(readBooster(text))) as ReturnType<typeof readBooster>;
describe('礼包编辑',()=>{
it('模板可解析,普通材料不出现礼包表单',()=>{
expect(readBooster(itemTemplate('booster'))).toMatchObject({mode:'booster',issue:''});
expect(readBooster(itemTemplate('selection'))).toMatchObject({mode:'selection',issue:''});
expect(readBooster(itemTemplate('material')).mode).toBeNull();
});
it('精确修改奖励数量与权重,保留未知段落、注释、换行及行类型',()=>{
const draft=clone(booster);expect(draft.issue).toBe('');
draft.groups[0]!.rows[0]!.quantity='5';draft.groups[0]!.rows[1]!.weight='0';
expect(editBooster(booster,draft)).toBe(booster.replace('500 2','500 5').replace('500 3','0 3'));
});
it('删除指定奖励保留其他奖励原文,追加奖励位于容器内',()=>{
const draft=clone(booster);draft.groups[0]!.rows.splice(0,1);draft.groups[0]!.rows.push({poolIndex:1,id:'300',quantity:'4',weight:'100'});
const text=editBooster(booster,draft);
expect(text).not.toContain('# 第一项');expect(text).toContain('[stackable]\r\n1 200 500 3 # 第二项\r\n');
expect(readBooster(text).groups[0]!.rows.map(r=>r.id)).toEqual(['200','300']);expect(text).toContain('[unknown]\r\n{5=1234}');
});
it('自选礼包支持说明、可选数量及奖励增删,保留原分类坐标',()=>{
const original=itemTemplate('selection').replace('1 1\n[/etc]','100 2 # 第一项\n200 3 # 第二项\n[/etc]');
const draft=clone(original);expect(draft.issue).toBe('');
draft.names[1]='材料礼包';draft.selection='2';draft.groups[0]!.rows.splice(0,1);draft.groups[0]!.rows.push({id:'300',quantity:'5',weight:''});
const text=editBooster(original,draft),next=readBooster(text);
expect(next.issue).toBe('');expect(next.selection).toBe('2');expect(boosterGroupLabel(next,next.groups[0]!)).toBe('材料礼包');
expect(next.groups[0]!.rows.map(r=>[r.id,r.quantity])).toEqual([['200','3'],['300','5']]);
expect(text).toContain('200 3 # 第二项');expect(text).toContain('# 第一项');expect(text).toContain('[booster select category]\n0 0');
});
it('无分类徽章自选礼包读取八项奖励,仅有一条选择提示',()=>{
const doc=readBooster(uncategorizedSelection);
expect(doc.issue).toBe('');expect(doc.dimensions).toEqual(['0']);expect(doc.selection).toBe('1');
expect(doc.names).toEqual(['请选择徽章。']);expect(doc.groups).toHaveLength(1);
expect(doc.groups[0]!.coordinates).toEqual(['0','0']);expect(boosterGroupLabel(doc,doc.groups[0]!)).toBe('可选物品');
expect(doc.groups[0]!.rows.map(row=>[row.id,row.quantity])).toEqual(['2500033','2500036','2510046','2510037','2520037','2520043','2530040','2530037'].map(id=>[id,'4']));
expect(editBooster(uncategorizedSelection,clone(uncategorizedSelection))).toBe(uncategorizedSelection);
});
it('无分类礼包编辑保留 0 维度、stackable 标签及其他字段,支持 CRLF',()=>{
const text=uncategorizedSelection.replaceAll('\n','\r\n'),draft=clone(text);
draft.groups[0]!.rows[1]!.quantity='8';draft.names[0]='请选择一种徽章。';
expect(editBooster(text,draft)).toBe(text.replace('2500036\t4','2500036\t8').replace('请选择徽章。','请选择一种徽章。'));
});
it('无分类礼包可增删候选奖励,保留奖励容器和提示结构',()=>{
const draft=clone(uncategorizedSelection);draft.groups[0]!.rows.splice(0,1);
draft.groups[0]!.rows.push({id:'2500034',quantity:'2',weight:''});
const text=editBooster(uncategorizedSelection,draft),next=readBooster(text);
expect(next.issue).toBe('');expect(next.dimensions).toEqual(['0']);expect(next.names).toEqual(['请选择徽章。']);
expect(next.groups[0]!.rows.map(row=>row.id)).toEqual(['2500036','2510046','2510037','2520037','2520043','2530040','2530037','2500034']);
expect(text).toContain('[stackable]');expect(text).toContain('[/stackable]');expect(text).not.toContain('[etc]');
expect(()=>addBoosterCategory(draft)).toThrow('仅单层分类');
});
it('无分类也兼容 etc 奖励容器,但拒绝多余维度、分类坐标、提示和内容段',()=>{
const etc=uncategorizedSelection.replace('[stackable]','[etc]').replace('[/stackable]','[/etc]');
expect(readBooster(etc).issue).toBe('');
for(const text of [
uncategorizedSelection.replace('[booster category num]\n\t0','[booster category num]\n\t0 1'),
uncategorizedSelection.replace('[booster select category]\n\t0\t0','[booster select category]\n\t1\t0'),
uncategorizedSelection.replace('[booster select category]\n\t0\t0','[booster select category]\n\t0\t1'),
uncategorizedSelection.replace('`请选择徽章。`','`请选择徽章。` `不应存在的分类`'),
uncategorizedSelection.replace('[/booster select category]','[/booster select category]\n[booster select category]\n0 0\n[etc]\n1 1\n[/etc]\n[/booster select category]'),
])expect(readBooster(text).issue).not.toBe('');
});
it('二维史诗碎片礼包保留空分类和省略的尾部名称,未修改时原文往返',()=>{
const doc=readBooster(epicSelection);
expect(doc.issue).toBe('');expect(doc.dimensions).toEqual(['2','8','5']);expect(doc.groups).toHaveLength(40);
expect(doc.groups.filter(group=>!group.rows.length)).toHaveLength(7);expect(doc.names).toHaveLength(47);
expect(boosterGroupLabel(doc,doc.groups[35]!)).toBe('魔枪士 / 长枪');
expect(boosterGroupLabel(doc,doc.groups[36]!)).toBe('魔枪士 / 战戟');
expect(editBooster(epicSelection,clone(epicSelection))).toBe(epicSelection);
});
it('修改非空分类奖励不补写空分类容器或名称,保留 CRLF 和其他字段',()=>{
const text=epicSelection.replaceAll('\n','\r\n'),draft=clone(text);
draft.groups[0]!.rows[0]!.quantity='60';
expect(editBooster(text,draft)).toBe(text.replace('10300537\t50','10300537\t60'));
});
it('已有名称的空分类可以添加奖励,沿用 stackable 容器',()=>{
const draft=clone(epicSelection),empty=draft.groups.find(group=>group.coordinates.join(':')==='5:4')!;
empty.rows.push({id:'10300665',quantity:'50',weight:''});
const text=editBooster(epicSelection,draft),next=readBooster(text);
expect(next.issue).toBe('');expect(next.groups.filter(group=>!group.rows.length)).toHaveLength(6);
expect(next.groups.find(group=>group.coordinates.join(':')==='5:4')!.rows[0]!.id).toBe('10300665');
expect(text).toContain('[booster select category]\n\t5\t4\n\t[stackable]');expect(text).not.toContain('[etc]');
expect(next.names).toHaveLength(47);
});
it('空分类可以保留空奖励容器,但仍拒绝未知内容、多个奖励段和缺失有效名称',()=>{
const emptyContainer=epicSelection.replace('\t5\t4\n[/booster select category]','\t5\t4\n[stackable]\n[/stackable]\n[/booster select category]');
expect(readBooster(emptyContainer).issue).toBe('');expect(editBooster(emptyContainer,clone(emptyContainer))).toBe(emptyContainer);
const extra=epicSelection.replace('\t5\t4\n[/booster select category]','\t5\t4\n[etc]\n1 1\n[/etc]\n[stackable]\n2 1\n[/stackable]\n[/booster select category]');
expect(readBooster(extra).issue).toContain('特殊自选礼包');
expect(readBooster(epicSelection.replace('`长枪`\t`战戟`','`长枪`')).issue).toContain('分类名称');
const missing=clone(epicSelection);missing.groups[39]!.rows.push({id:'10300665',quantity:'50',weight:''});
expect(()=>editBooster(epicSelection,missing)).toThrow('没有名称');
});
it('equipment 二维自选分类按 ID、数量配对读取,保留 7 5 坐标',()=>{
const blocks=Array.from({length:48},(_,i)=>`[booster select category]\n${Math.floor(i/6)} ${i%6}\n${i===47?'[equipment]\n109010320 1 109000332 1 109030219 1 109040220 1\n[/equipment]\n':''}[/booster select category]`).join('\n');
const names=['请选择职业',...Array.from({length:8},(_,i)=>`职业${i+1}`),'请选择装备',...Array.from({length:48},(_,i)=>`装备分类${i+1}`)];
const text=`[stackable type]\n\`[booster selection]\` 0\n[booster category num]\n2 8 6\n[booster selection num]\n1\n${blocks}\n[booster category name]\n${names.map(name=>'`'+name+'`').join(' ')}\n[/booster category name]\n`;
const doc=readBooster(text);expect(doc.issue).toBe('');expect(doc.groups[47]!.coordinates).toEqual(['7','5']);
expect(doc.groups[47]!.rows.map(row=>[row.id,row.quantity])).toEqual(['109010320','109000332','109030219','109040220'].map(id=>[id,'1']));
expect(editBooster(text,clone(text))).toBe(text);
const draft=clone(text);draft.groups[47]!.rows[0]!.quantity='2';
expect(editBooster(text,draft)).toBe(text.replace('109010320 1','109010320 2'));
});
it('equipment 自选奖励可增删,保留容器、注释与 CRLF',()=>{
const text=itemTemplate('selection').replace('[etc]\n1 1\n[/etc]','[equipment]\n109010320 1 # 第一件\n109000332 1 # 第二件\n[/equipment]').replaceAll('\n','\r\n');
const draft=clone(text);expect(draft.issue).toBe('');draft.groups[0]!.rows.splice(0,1);draft.groups[0]!.rows.push({id:'109030219',quantity:'1',weight:''});
const result=editBooster(text,draft),doc=readBooster(result);
expect(doc.issue).toBe('');expect(doc.groups[0]!.rows.map(row=>row.id)).toEqual(['109000332','109030219']);
expect(result).toContain('109000332 1 # 第二件');expect(result).toContain('[equipment]');expect(result).toContain('[/equipment]');expect(result).not.toContain('[etc]');
});
it('equipment 自选礼包新增分类和填充空分类时沿用 equipment 容器',()=>{
const text=itemTemplate('selection').replaceAll('[etc]','[equipment]').replaceAll('[/etc]','[/equipment]');
const draft=clone(text);addBoosterCategory(draft);draft.groups[1]!.rows[0]!.id='109040220';
const result=editBooster(text,draft);expect(readBooster(result).issue).toBe('');
expect(result.match(/\[equipment\]/g)).toHaveLength(2);expect(result).not.toContain('[etc]');
const empty=result.replace(/\t\[equipment\]\n\t\t109040220\t1\n\t\[\/equipment\]\n/,'');
const filled=clone(empty);expect(filled.issue).toBe('');filled.groups[1]!.rows.push({id:'109040220',quantity:'1',weight:''});
expect(editBooster(empty,filled)).toContain('[equipment]\n\t\t109040220\t1');
});
it('1 0 无分类宝珠礼包允许单个空提示并读取十四项奖励,原文往返不补写名称',()=>{
const doc=readBooster(emptyPromptSelection);
expect(doc.issue).toBe('');expect(doc.dimensions).toEqual(['1','0']);expect(doc.names).toEqual(['']);
expect(doc.groups).toHaveLength(1);expect(doc.groups[0]!.rows).toHaveLength(14);
expect(doc.groups[0]!.rows.map(row=>row.id)).toEqual(['490700960',...Array.from({length:13},(_,i)=>String(490700964+i))]);
expect(isFlatBoosterSelection(doc)).toBe(true);expect(boosterGroupLabel(doc,doc.groups[0]!)).toBe('可选物品');
expect(editBooster(emptyPromptSelection,clone(emptyPromptSelection))).toBe(emptyPromptSelection);
});
it('修改宝珠奖励保留 1 0、空提示、原容器及多行说明,支持 CRLF',()=>{
const text=emptyPromptSelection.replaceAll('\n','\r\n'),draft=clone(text);
draft.groups[0]!.rows[0]!.quantity='2';
expect(editBooster(text,draft)).toBe(text.replace('490700960\t1','490700960\t2'));
});
it('1 0 无分类礼包可增删奖励和填写或清空选择提示',()=>{
const draft=clone(emptyPromptSelection);draft.groups[0]!.rows.splice(0,1);draft.groups[0]!.rows.push({id:'490700977',quantity:'1',weight:''});
let text=editBooster(emptyPromptSelection,draft),doc=readBooster(text);
expect(doc.issue).toBe('');expect(doc.names).toEqual(['']);expect(doc.groups[0]!.rows).toHaveLength(14);
expect(doc.groups[0]!.rows.at(-1)!.id).toBe('490700977');
doc.names[0]='请选择宝珠。';text=editBooster(text,doc);expect(readBooster(text).names).toEqual(['请选择宝珠。']);
doc=clone(text);doc.names[0]='';expect(readBooster(editBooster(text,doc)).names).toEqual(['']);
});
it('1 0 兼容两条旧式名称,仍拒绝多余名称和错误分类坐标',()=>{
const legacy=emptyPromptSelection.replace('\t``','\t`请选择。` `全部物品`');
expect(readBooster(legacy).issue).toBe('');expect(editBooster(legacy,clone(legacy))).toBe(legacy);
expect(readBooster(emptyPromptSelection.replace('\t``','\t`` `多余` `名称`')).issue).toContain('分类名称');
expect(readBooster(emptyPromptSelection.replace('[booster select category]\n\t0\t0','[booster select category]\n\t1\t0')).issue).toContain('坐标');
expect(isFlatBoosterSelection(readBooster(itemTemplate('selection')))).toBe(false);
expect(()=>addBoosterCategory(clone(emptyPromptSelection))).toThrow('仅单层分类');
});
it('新增单层分类同步名称、数量和坐标,不改动原分类',()=>{
const text=itemTemplate('selection'),draft=clone(text);
addBoosterCategory(draft);draft.groups[1]!.rows[0]!.id='200';
addBoosterCategory(draft);draft.groups[2]!.rows[0]!.id='300';
const result=editBooster(text,draft),next=readBooster(result);
expect(next.issue).toBe('');expect(next.dimensions).toEqual(['1','3']);
expect(next.groups.map(g=>g.coordinates)).toEqual([['0','0'],['1','0'],['2','0']]);
expect(next.groups.map(g=>g.rows[0]!.id)).toEqual(['1','200','300']);expect(next.names).toEqual(['请选择。','类别 1','类别 2','类别 3']);
});
it('二维分类按坐标显示名称,重复坐标与未知结构只读',()=>{
const base=itemTemplate('selection');
const text=base.replace('[booster category num]\n1 1','[booster category num]\n2 1 2').replace('[booster category name]', '[booster select category]\n0 1\n[etc]\n2 1\n[/etc]\n[/booster select category]\n[booster category name]').replace('`请选择。` `类别 1`','`请选择。` `类别 1` `请选择物品` `物品甲` `物品乙`');
const doc=readBooster(text);expect(doc.issue).toBe('');expect(boosterGroupLabel(doc,doc.groups[1]!)).toBe('类别 1 / 物品乙');
expect(readBooster(text.replace('0 1\n[etc]','0 0\n[etc]')).issue).toContain('坐标');
expect(readBooster(booster.replace('[stackable]','[custom]').replace('[/stackable]','[/custom]')).issue).toContain('特殊');
});
it('拒绝缺失闭合、重复主段落、非法数值和过期奖励定位',()=>{
expect(readBooster(booster.replace('[/booster info]','')).issue).toContain('闭合');
expect(readBooster(booster+'[booster info]\n[/booster info]').issue).toContain('重复');
const draft=clone(booster);draft.groups[0]!.rows[0]!.id='2147483648';expect(()=>editBooster(booster,draft)).toThrow('整数');
draft.groups[0]!.rows[0]!.id='100';draft.groups[0]!.rows[1]!.sourceIndex=0;expect(()=>editBooster(booster,draft)).toThrow('奖励位置');
});
it('保留分类名称字符串引用的类型标记,禁止写成字面译文',()=>{
const text=itemTemplate('selection').replace('`类别 1`','{8=`<31::category_name>`}');
const draft=clone(text);expect(editBooster(text,draft)).toBe(text);
draft.names[1]='译文';expect(()=>editBooster(text,draft)).toThrow('字符串表引用');
});
});
describe('avatar 自选奖励',()=>{
const ids=['2685210','2685338','2685470','2685595','2685730','2685860','2685989','2686119'];
const avatar=itemTemplate('selection').replace('[etc]\n1 1\n[/etc]',`[avatar]\n${ids.map(id=>`${id} 1 3 0`).join('\n')}\n[/avatar]`);
it('八件时装按四参数读取,原文往返,修改数量和附加参数仅替换对应 token',()=>{
const text=avatar.replaceAll('\n','\r\n'),draft=clone(text);
expect(draft.issue).toBe('');expect(draft.groups[0]!.rows).toHaveLength(8);
expect(draft.groups[0]!.rows.map(r=>r.id)).toEqual(ids);
expect(draft.groups[0]!.rows.every(r=>r.avatarParams?.join()==='3,0')).toBe(true);
expect(editBooster(text,draft)).toBe(text);
draft.groups[0]!.rows[1]!.quantity='2';draft.groups[0]!.rows[2]!.avatarParams=['4','-1'];
expect(editBooster(text,draft)).toBe(text.replace('2685338 1 3 0','2685338 2 3 0').replace('2685470 1 3 0','2685470 1 4 -1'));
});
it('增删按四参数定位并保留注释,新奖励继承附加参数且独立编辑',()=>{
const text=avatar.replace('2685338 1 3 0','2685338 1 3 0 # 保留注释');
const draft=clone(text),group=draft.groups[0]!;
group.rows.splice(0,1);const row=newBoosterRow(draft,group);row.id='2686120';group.rows.push(row);
row.avatarParams![0]='5';expect(group.rows[0]!.avatarParams).toEqual(['3','0']);
const result=editBooster(text,draft),doc=readBooster(result);
expect(doc.issue).toBe('');expect(doc.groups[0]!.rows.map(r=>r.id)).toEqual([...ids.slice(1),'2686120']);
expect(doc.groups[0]!.rows.at(-1)!.avatarParams).toEqual(['5','0']);
expect(result).toContain('2685338 1 3 0 # 保留注释');
});
it('新增分类和填充空分类使用 avatar 四参数容器',()=>{
const draft=clone(avatar);addBoosterCategory(draft);draft.groups[1]!.rows[0]!.id='2686120';
const result=editBooster(avatar,draft);expect(readBooster(result).issue).toBe('');
expect(result).toContain('[avatar]\n\t\t2686120\t1\t3\t0');
const empty=result.replace(/\t\[avatar\]\n\t\t2686120\t1\t3\t0\n\t\[\/avatar\]\n/,'');
const filled=clone(empty),group=filled.groups[1]!,row=newBoosterRow(filled,group);row.id='2686121';group.rows.push(row);
const next=readBooster(editBooster(empty,filled));expect(next.issue).toBe('');
expect(next.groups[1]!.rows[0]!.avatarParams).toEqual(['3','0']);
});
it('二维 1 2 坐标兼容混合奖励类型,各容器使用自己的参数宽度',()=>{
const blocks=Array.from({length:6},(_,i)=>`[booster select category]\n${Math.floor(i/3)} ${i%3}\n${i===5?'[avatar]\n'+ids.map(id=>`${id} 1 3 0`).join(' ' )+'\n[/avatar]':i===0?'[equipment]\n109010320 1\n[/equipment]':i===1?'[stackable]\n100 2\n[/stackable]':''}\n[/booster select category]`).join('\n');
const text=`[stackable type]\n\`[booster selection]\` 0\n[booster category num]\n2 2 3\n[booster selection num]\n1\n${blocks}\n[booster category name]\n${Array.from({length:10},(_,i)=>'\`分类'+i+'\`').join(' ')}\n[/booster category name]`;
const draft=clone(text);expect(draft.issue).toBe('');expect(draft.groups[5]!.coordinates).toEqual(['1','2']);
expect(editBooster(text,draft)).toBe(text);
draft.groups[5]!.rows[7]!.quantity='4';draft.groups[0]!.rows[0]!.quantity='2';
expect(editBooster(text,draft)).toBe(text.replace('2686119 1 3 0','2686119 4 3 0').replace('109010320 1','109010320 2'));
});
it('缺少附加参数或参数非法时拒绝写入,空 avatar 容器新增时要求填写',()=>{
expect(readBooster(avatar.replace('2685210 1 3 0','2685210 1 3')).issue).toContain('两个附加参数');
for(const params of [undefined,['3'],['3',''],['3','2147483648'],['3','1.5']]){
const draft=clone(avatar);draft.groups[0]!.rows[0]!.avatarParams=params as [string,string]|undefined;
expect(()=>editBooster(avatar,draft)).toThrow('附加参数');
}
const text=itemTemplate('selection').replace('[etc]\n1 1\n[/etc]','[avatar]\n[/avatar]'),draft=clone(text);
const row=newBoosterRow(draft,draft.groups[0]);row.id='2685210';draft.groups[0]!.rows.push(row);
expect(row.avatarParams).toEqual(['','']);expect(()=>editBooster(text,draft)).toThrow('附加参数');
row.avatarParams=['3','0'];expect(readBooster(editBooster(text,draft)).issue).toBe('');
});
});
describe('普通礼包段内连续奖励',()=>{
const thanksgiving=fixture('thanksgiving-booster.stk');
const packed=booster.replace('1 100 500 2 # 第一项','1 100 500 2 # 第一项\r\n101 300 4 # 同段第二项\r\n102 200 6 # 同段第三项');
it('感恩礼盒读取 508 项奖励,随机获得项数仅出现一次,原文往返',()=>{
const draft=clone(thanksgiving);expect(draft.issue).toBe('');expect(draft.groups[0]!.rows).toHaveLength(508);
expect(draft.groups[0]!.rows[0]).toMatchObject({sourceIndex:0,id:'15646',weight:'68',quantity:'1'});
expect(draft.groups[0]!.rows[1]).toMatchObject({sourceIndex:1,id:'18913',weight:'68',quantity:'1'});
expect(draft.groups[0]!.rows.at(-1)).toMatchObject({sourceIndex:507,id:'10102',weight:'67',quantity:'1'});
expect(draft.groups[0]!.rows.reduce((sum,r)=>sum+Number(r.weight),0)).toBe(100000);
expect(editBooster(thanksgiving,draft)).toBe(thanksgiving);
});
it('大列表按记录精确修改 ID、数量及权重,不拆分原容器',()=>{
const text=thanksgiving.replaceAll('\n','\r\n'),draft=clone(text),last=draft.groups[0]!.rows.at(-1)!;
last.id='10104';last.weight='70';last.quantity='2';
expect(editBooster(text,draft)).toBe(text.replace('10102\t67\t1','10104\t70\t2'));
});
it('混合单项和多项段时定位正确,保留注释、标记、CRLF 和其他字段',()=>{
const text=packed,draft=clone(text);
expect(draft.issue).toBe('');expect(draft.groups[0]!.rows.map(r=>r.sourceIndex)).toEqual([0,1,2,3]);
draft.groups[0]!.rows[1]!.id='103';draft.groups[0]!.rows[3]!.quantity='9';
expect(editBooster(text,draft)).toBe(text.replace('101 300 4','103 300 4').replace('500 3','500 9'));
});
it('删除段内首项、中项或末项仅移除对应三个参数,其他记录保持正确',()=>{
for(const index of [0,1,2]) {
const draft=clone(packed);draft.groups[0]!.rows.splice(index,1);
const result=editBooster(packed,draft),next=readBooster(result);
expect(next.issue).toBe('');expect(next.groups[0]!.rows.map(r=>r.id)).toEqual(['100','101','102','200'].filter((_,i)=>i!==index));
expect(result).toContain('[etc]');expect(result).toContain('# 第一项');expect(result).toContain('# 同段第二项');expect(result).toContain('[unknown]\r\n{5=1234}');
}
});
it('删除整个多项段后仍能编辑后续段并新增奖励',()=>{
const draft=clone(packed);draft.groups[0]!.rows.splice(0,3);draft.groups[0]!.rows[0]!.weight='700';
draft.groups[0]!.rows.push({poolIndex:1,id:'300',quantity:'2',weight:'300'});
const result=editBooster(packed,draft),next=readBooster(result);
expect(next.issue).toBe('');expect(next.groups[0]!.rows.map(r=>[r.id,r.weight,r.quantity])).toEqual([['200','700','3'],['300','300','2']]);
expect(result).not.toContain('# 第一项');expect(result).toContain('[stackable]');
});
it('随机获得项数按奖励池修改,不改变候选数量与每项数量;拒绝不完整记录',()=>{
const draft=clone(packed);draft.groups[0]!.pools![0]!.drawCount='2';
const result=editBooster(packed,draft),next=readBooster(result);
expect(result).toBe(packed.replace('1 100 500 2','2 100 500 2'));
expect(next.groups[0]!.pools!.map(p=>p.drawCount)).toEqual(['2','1']);
expect(next.groups[0]!.rows.map(r=>r.quantity)).toEqual(['2','4','6','3']);
for(const values of ['1','1 100 2','1 100 500 2 101 300'])expect(readBooster(booster.replace('1 100 500 2',values)).issue).toContain('成组');
});
});
describe('无分类礼包可省略分类名称段',()=>{
const withoutNames=(text:string)=>text.replace(/\[booster category name\][\s\S]*?\[\/booster category name\]\r?\n?/,'');
for(const [label,source] of [['0',uncategorizedSelection],['1 0',emptyPromptSelection]]) {
it(`${label} 缺少名称段可解析,未修改时原文往返,编辑数量不补写名称`,()=>{
const text=withoutNames(source!).replaceAll('\n','\r\n'),draft=clone(text);
expect(draft.issue).toBe('');expect(draft.names).toEqual([]);expect(isFlatBoosterSelection(draft)).toBe(true);
expect(editBooster(text,draft)).toBe(text);
const first=draft.groups[0]!.rows[0]!;
first.quantity='9';draft.selection='2';
const result=editBooster(text,draft),next=readBooster(result);
expect(next.issue).toBe('');expect(next.selection).toBe('2');expect(next.groups[0]!.rows[0]!.quantity).toBe('9');
expect(result).not.toContain('[booster category name]');expect(next.names).toEqual([]);
});
it(`${label} 缺少名称段可增删奖励,仍禁止改动分类结构或凭空写入名称`,()=>{
const text=withoutNames(source!),draft=clone(text),group=draft.groups[0]!;
group.rows.splice(0,1);group.rows.push({id:'999',quantity:'2',weight:''});
const result=editBooster(text,draft),next=readBooster(result);
expect(next.issue).toBe('');expect(next.groups[0]!.rows).toHaveLength(group.rows.length);
expect(next.groups[0]!.rows.at(-1)!.id).toBe('999');expect(result).not.toContain('[booster category name]');
draft.names=['新增名称'];expect(()=>editBooster(text,draft)).toThrow('分类名称');
draft.names=[];draft.dimensions=['1','2'];expect(()=>editBooster(text,draft)).toThrow('分类结构');
});
}
it('有分类仍要求名称段,无分类现存名称段的重复和非法内容仍报错',()=>{
expect(readBooster(withoutNames(itemTemplate('selection'))).issue).toContain('缺失');
expect(readBooster(withoutNames(epicSelection)).issue).toContain('缺失');
expect(readBooster(uncategorizedSelection+'\n[booster category name]\n`重复`\n[/booster category name]').issue).toContain('重复');
expect(readBooster(emptyPromptSelection.replace('``','123')).issue).toContain('名称结构');
});
});
describe('随机礼盒抽取语义',()=>{
const text=itemTemplate('booster').replace('1 1 1000 1','2 100 300 4\n200 700 6');
it('抽取项数属于池,每项发放数量单独保留',()=>{
const doc=readBooster(text),group=doc.groups[0]!;
expect(doc.issue).toBe('');expect(group.pools).toHaveLength(1);expect(group.pools![0]!.drawCount).toBe('2');
expect(group.rows.map(r=>[r.poolIndex,r.id,r.quantity])).toEqual([[0,'100','4'],[0,'200','6']]);
expect(editBooster(text,clone(text))).toBe(text);
});
it('新增候选追加到指定池,保留抽取项数,不额外创建抽取池',()=>{
const draft=clone(text),group=draft.groups[0]!,row=newBoosterRow(draft,group,0);row.id='300';row.quantity='8';group.rows.push(row);
const result=editBooster(text,draft),next=readBooster(result);
expect(next.issue).toBe('');expect(next.groups[0]!.pools).toHaveLength(1);expect(next.groups[0]!.pools![0]!.drawCount).toBe('2');
expect(next.groups[0]!.rows.map(r=>r.id)).toEqual(['100','200','300']);
expect(result.match(/\[etc\]/g)).toHaveLength(1);
});
it('各池抽取项数独立,修改第二池并新增候选不影响第一池',()=>{
const original=text.replace('[/booster info]','[etc]\n1 400 1000 3\n[/etc]\n[/booster info]'),draft=clone(original),group=draft.groups[0]!;
group.pools![1]!.drawCount='3';const row=newBoosterRow(draft,group,1);row.id='500';group.rows.push(row);
const result=editBooster(original,draft),next=readBooster(result);
expect(next.groups[0]!.pools!.map(p=>p.drawCount)).toEqual(['2','3']);
expect(next.groups[0]!.rows.map(r=>[r.poolIndex,r.id])).toEqual([[0,'100'],[0,'200'],[1,'400'],[1,'500']]);
expect(result).toContain('[etc]\n2 100 300 4\n200 700 6\n[/etc]');
});
it('抽取项数必须为正整数,拒绝不存在的池和跨池移动',()=>{
for(const count of ['0','-1','1.5','2147483648','']){
const draft=clone(text);draft.groups[0]!.pools![0]!.drawCount=count;
expect(()=>editBooster(text,draft)).toThrow('随机获得项数');
}
const draft=clone(booster);draft.groups[0]!.rows[0]!.poolIndex=1;expect(()=>editBooster(booster,draft)).toThrow('跨随机池');
const unknown=clone(text),row=newBoosterRow(unknown,unknown.groups[0],9);row.id='300';unknown.groups[0]!.rows.push(row);
expect(()=>editBooster(text,unknown)).toThrow('随机池无效');
});
});
describe('自选装备强化等级',()=>{
const text=itemTemplate('selection').replace('[etc]\n1 1\n[/etc]','[booster equipment upgrade]\n\t10\n[equipment]\n401040027 1 401030028 1 401010027 1 401020031 1 401000031 1\n[/equipment]');
it('读取强化等级与五件装备,名称带前缀,未修改时原文往返',()=>{
const doc=readBooster(text),group=doc.groups[0]!;
expect(doc.issue).toBe('');expect(group.equipmentUpgrade).toBe('10');expect(group.rows).toHaveLength(5);
expect(group.rows.map(r=>r.id)).toEqual(['401040027','401030028','401010027','401020031','401000031']);
expect(boosterRewardName(group,'装备名')).toBe('+10 装备名');
expect(editBooster(text,clone(text))).toBe(text);
});
it('修改数量和增删奖励不改强化段、注释或换行,仍使用 equipment 容器',()=>{
const original=text.replace('\t10','\t10 # 保留强化注释').replaceAll('\n','\r\n'),draft=clone(original),group=draft.groups[0]!;
group.rows[1]!.quantity='3';
expect(editBooster(original,draft)).toBe(original.replace('401030028 1','401030028 3'));
group.rows.splice(0,1);const row=newBoosterRow(draft,group);row.id='401040028';group.rows.push(row);
const result=editBooster(original,draft),next=readBooster(result);
expect(next.issue).toBe('');expect(next.groups[0]!.equipmentUpgrade).toBe('10');
expect(next.groups[0]!.rows.map(r=>r.id)).toEqual(['401030028','401010027','401020031','401000031','401040028']);
expect(result).toContain('[booster equipment upgrade]\r\n\t10 # 保留强化注释\r\n[equipment]');
});
it('强化等级按分类隔离,新分类不自动继承强化等级,零强化明确显示',()=>{
const draft=clone(text);addBoosterCategory(draft);draft.groups[1]!.rows[0]!.id='401040028';
const next=readBooster(editBooster(text,draft));expect(next.issue).toBe('');
expect(boosterRewardName(next.groups[0],'装备甲')).toBe('+10 装备甲');
expect(boosterRewardName(next.groups[1],'装备乙')).toBe('装备乙');
expect(boosterRewardName(readBooster(text.replace('\t10','\t0')).groups[0],'装备')).toBe('+0 装备');
});
it('空强化分类可添加装备,并沿用 equipment 容器而非强化字段标签',()=>{
const empty=text.replace(/401040027 1[^\n]*/,''),draft=clone(empty),group=draft.groups[0]!;
expect(draft.issue).toBe('');const row=newBoosterRow(draft,group);row.id='401040027';group.rows.push(row);
const next=readBooster(editBooster(empty,draft));expect(next.issue).toBe('');expect(next.groups[0]!.equipmentUpgrade).toBe('10');expect(next.groups[0]!.rows[0]!.id).toBe('401040027');
});
it('拒绝重复、非整数、多参数和负数强化等级,编辑草稿不能悄悄改写等级',()=>{
for(const value of ['','-1','1.5','`10`','10 11','2147483648'])expect(readBooster(text.replace('\t10',value)).issue).not.toBe('');
expect(readBooster(text.replace('[equipment]','[booster equipment upgrade]\n5\n[equipment]')).issue).toContain('重复强化');
const draft=clone(text);draft.groups[0]!.equipmentUpgrade='12';expect(()=>editBooster(text,draft)).toThrow('强化等级已变化');
});
});
describe('商城道具随机奖励',()=>{
const text=itemTemplate('booster').replace('[etc]\n1 1 1000 1\n[/etc]','[cera]\n\t1\t42\t1000\t50 # 商城道具\n[/cera]');
it('按抽取项数及三参数记录读取,原文往返',()=>{
const draft=clone(text),group=draft.groups[0]!;
expect(draft.issue).toBe('');expect(group.pools![0]!.source.name).toBe('cera');expect(group.pools![0]!.drawCount).toBe('1');
expect(group.rows).toEqual([{sourceIndex:0,poolIndex:0,id:'42',weight:'1000',quantity:'50'}]);expect(editBooster(text,draft)).toBe(text);
});
it('修改抽取项数、ID、权重及数量,保留标签、注释和 CRLF',()=>{
const source=text.replaceAll('\n','\r\n'),draft=clone(source),group=draft.groups[0]!;
group.pools![0]!.drawCount='2';Object.assign(group.rows[0]!,{id:'43',weight:'500',quantity:'100'});
expect(editBooster(source,draft)).toBe(source.replace('1\t42\t1000\t50','2\t43\t500\t100'));
});
it('混合奖励池增删商城候选,保留其他奖励池',()=>{
const source=text.replace('[/booster info]','[etc]\n2 100 300 4\n200 700 6\n[/etc]\n[/booster info]');
const draft=clone(source),group=draft.groups[0]!,row=newBoosterRow(draft,group,0);row.id='43';row.quantity='60';group.rows.push(row);
const added=editBooster(source,draft),next=clone(added);
expect(next.issue).toBe('');expect(next.groups[0]!.pools!.map(p=>p.source.name)).toEqual(['cera','etc']);
expect(next.groups[0]!.rows.filter(r=>r.poolIndex===0).map(r=>r.id)).toEqual(['42','43']);
expect(added).toContain('[etc]\n2 100 300 4\n200 700 6\n[/etc]');
next.groups[0]!.rows=next.groups[0]!.rows.filter(r=>r.id!=='42');
const result=editBooster(added,next),parsed=readBooster(result);
expect(parsed.issue).toBe('');expect(parsed.groups[0]!.rows.map(r=>r.id)).toEqual(['43','100','200']);expect(result).toContain('[cera]');
});
it('拒绝缺少记录参数的商城奖励',()=>{
for(const value of ['1','1 42 1000','1 42 1000 50 43'])expect(readBooster(text.replace('1\t42\t1000\t50',value)).issue).toContain('成组');
});
});
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { nextTick } from 'vue';
import type { ItemCreateRequest } from '../bindings/pvfine/services/models';
const api=vi.hoisted(()=>({GetFileBasic:vi.fn(),GetFile:vi.fn(),GetAnnotations:vi.fn(),CreateItem:vi.fn(),refreshInfo:vi.fn(),events:new Map<string,Array<()=>void>>()}));
vi.mock('@wailsio/runtime',()=>({Events:{On:(name:string,fn:()=>void)=>api.events.set(name,[...(api.events.get(name)??[]),fn])}}));
vi.mock('../bindings/pvfine/services',()=>({EditorService:api,ArchiveService:api,FileGUIService:api}));
vi.mock('../src/stores/archive',()=>({useArchiveStore:()=>({refreshInfo:api.refreshInfo})}));
vi.mock('../src/stores/script',()=>({useScriptStore:()=>({})}));
vi.mock('../src/stores/explorer',()=>({useExplorerStore:()=>({getFilePath:()=>'',refreshTreeTags:vi.fn()})}));
import { useEditorStore } from '../src/stores/editor';
import { useFileGUIStore } from '../src/stores/fileGUI';
const meta=(index:number)=>({index,path:index===1?'stackable/stackable.lst':'stackable/source.stk',text:'original',editable:true,dataType:1,size:8,tags:[],annotations:[],modified:false});
const request:ItemCreateRequest={path:'stackable/new.stk',id:'999',name:'新物品',text:'[name]\n`copy`',revision:1,drafts:[]};
beforeEach(()=>{setActivePinia(createPinia());vi.resetAllMocks();api.events.clear();api.GetFileBasic.mockImplementation(async i=>meta(i));api.GetAnnotations.mockResolvedValue([]);api.GetFile.mockResolvedValue(null);});
describe('物品创建工作区',()=>{
it('提交包含所有未保存草稿,仅推进被消费列表的基线',async()=>{
const editor=useEditorStore();await editor.openGUIFile(1);await editor.openGUIFile(2);
editor.updateContent(1,'list draft');editor.updateContent(2,'source draft');
api.CreateItem.mockResolvedValue({fileIndex:3,path:request.path,id:'999',files:[{fileIndex:1,path:'stackable/stackable.lst',beforeText:'list draft',text:'registered list'}]});
await editor.createItem(request);
expect(api.CreateItem.mock.calls[0]![0].drafts).toEqual([{fileIndex:1,path:'stackable/stackable.lst',text:'list draft'},{fileIndex:2,path:'stackable/source.stk',text:'source draft'}]);
expect(editor.getFileDraft(1)).toMatchObject({text:'registered list',original:'registered list',modified:true});
expect(editor.getFileDraft(2)).toMatchObject({text:'source draft',original:'original'});
expect(editor.dirtyCount).toBe(1);expect(editor.saving).toBe(false);expect(editor.guiApplying).toBe(false);
});
it('创建期间锁定编辑,失败后保留草稿和状态',async()=>{
const editor=useEditorStore();await editor.openGUIFile(1);editor.updateContent(1,'draft');
let reject!:(e:Error)=>void;api.CreateItem.mockImplementation(()=>new Promise((_,r)=>{reject=r;}));
const work=editor.createItem(request);await nextTick();
expect(editor.guiApplying).toBe(true);editor.updateContent(1,'unexpected');expect(editor.getFileDraft(1)?.text).toBe('draft');
reject(new Error('ID conflict'));await expect(work).rejects.toThrow('ID conflict');
expect(editor.getFileDraft(1)).toMatchObject({text:'draft',original:'original'});expect(editor.guiApplying).toBe(false);
});
it('拒绝跨归档的旧返回值',async()=>{
const editor=useEditorStore(),gui=useFileGUIStore();await editor.openGUIFile(1);editor.updateContent(1,'draft');
let resolve!:(v:unknown)=>void;api.CreateItem.mockImplementation(()=>new Promise(r=>{resolve=r;}));
const work=editor.createItem(request);await nextTick();gui.epoch++;
resolve({fileIndex:3,path:request.path,id:'999',files:[{fileIndex:1,path:'stackable/stackable.lst',beforeText:'draft',text:'old response'}]});
await expect(work).rejects.toThrow('归档已切换');expect(editor.getFileDraft(1)?.text).toBe('draft');expect(editor.saving).toBe(false);
});
it('刷新失败仍返回已创建结果并保留提示,避免重复创建',async()=>{
const editor=useEditorStore();api.CreateItem.mockResolvedValue({fileIndex:3,path:request.path,id:'999',files:[]});api.refreshInfo.mockRejectedValue(new Error('refresh'));
expect((await editor.createItem(request)).fileIndex).toBe(3);expect(editor.guiRefreshWarning).toContain('已创建');
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { editItem, itemFieldIssue, itemGroups, itemSection, parseItem, type ItemField } from '../src/gui/item';
import { getGUIProvider } from '../src/gui/registry';
const field = (name: string) => itemGroups.flatMap(g => g.fields).find(f => f.name === name)!;
const source = '#PVF_File\r\n[name]\r\n`装备😀` # 名称\r\n[equipment physical attack]\r\n100 200\r\n[if]\r\n[cooltime]\r\n3000\r\n[/if]\r\n[then]\r\n[physical attack]\r\n10\r\n[/then]\r\n[custom]\r\n{5=1234} # 保留\r\n';
describe('物品 GUI 精确编辑', () => {
it('追加 GUI provider,识别大小写扩展名', () => {
for (const path of ['equipment/A.EQU', 'stackable/a.stk']) expect(getGUIProvider({ index: 0, path, text: '', editable: true })?.id).toBe('item');
expect(getGUIProvider({ index: 0, path: 'a.equ.txt', text: '', editable: true })).toBeNull();
});
it('修改多值属性保留触发器、未知类型、注释和 CRLF', () => {
expect(editItem(source, field('equipment physical attack'), ['150', '250'])).toBe(source.replace('100 200', '150 250'));
expect(itemSection(parseItem(source), 'physical attack')).toBeUndefined();
expect(editItem(source, field('physical attack'), ['30'])).toBe(source + '[physical attack]\r\n\t30\r\n');
});
it('同名顶层和嵌套字段互不覆盖,保留闭合段', () => {
const text = '[physical attack]\n20\n[then]\n[physical attack]\n50\n[/then]\n';
expect(editItem(text, field('physical attack'), ['40'])).toBe(text.replace('20', '40'));
const job = '[usable job]\n`[swordman]` # 职业\n`[mage]`\n[/usable job]\n';
expect(editItem(job, field('usable job'), ['[fighter]', '[mage]'])).toBe(job.replace('[swordman]', '[fighter]'));
});
it('保留多行字符串、转义反引号和类型标记', () => {
const text = '[basic explain]\n{8=`原文``😀\n[rarity]\n5`} # 注释\n[rarity]\n3';
expect(editItem(text, field('basic explain'), ['新`说明\n第二行'])).toBe(text.replace('{8=`原文``😀\n[rarity]\n5`}', '{8=`新``说明\n第二行`}'));
expect(editItem(text, field('rarity'), ['4'])).toBe(text.replace(/3$/, '4'));
expect(editItem(text, field('basic explain'), ['原文`😀\n[rarity]\n5'])).toBe(text);
});
it('保护字符串引用、重复字段、嵌套字段和特殊类型', () => {
for (const text of ['[name]\n`a`\n[NAME]\n`b`', '[name]\n{10=`<31::key>`}', '[name]\n`<31::key>`', '[name]\n{5=42}', '[name]\n[inner]\n`x`\n[/inner]\n[/name]']) {
expect(itemFieldIssue(parseItem(text), field('name'))).not.toBe('');
expect(() => editItem(text, field('name'), ['新名称'])).toThrow();
}
});
it('拒绝破损结构、整数溢出和形状改变', () => {
for (const text of ['[if]\n[physical attack]\n10', '[then]\n[physical attack]\n10', '[name]\n`x', '[name', '[name]\n{8=`x`', '[/then]', '[if]\n[name]\n`x`\n[/then]']) expect(() => parseItem(text)).toThrow();
for (const value of ['', '1.5', '2147483648', '-2147483649', 'NaN', '1e4']) expect(() => editItem(source, field('rarity'), [value])).toThrow();
expect(() => editItem(source, field('equipment physical attack'), ['3'])).toThrow('参数数量');
expect(editItem(source, field('rarity'), ['0'])).toContain('[rarity]\r\n\t0\r\n');
});
it('图像与类型的混合参数分别校验,新增字段放在文件末尾', () => {
const icon = field('icon');
expect(editItem('[icon]\n`a.img` 1 # 图标\n', icon, ['b.img', '9'])).toBe('[icon]\n`b.img` 9 # 图标\n');
expect(() => editItem(source, icon, ['a.img', '1.2'])).toThrow();
expect(editItem('[name]\n`a`', field('equipment type'), ['[weapon]', '3'])).toBe('[name]\n`a`\n[equipment type]\n\t`[weapon]`\t3\n');
});
it('忽略行注释与块注释中的标签和反引号,精确保留注释', () => {
const text = '// [name] `oops\n[name]\n`名称` /* [name] */\n[price]\n10// [then]\n/* `未闭合字符串\n[price] */\n';
expect(editItem(text, field('price'), ['20'])).toBe(text.replace('10//', '20//'));
expect(() => parseItem('[name]\n`x` /* 未闭合')).toThrow('注释未闭合');
});
it('忽略注释中标签,嵌套效果之后仍能编辑普通字段', () => {
const text = source + '# [name] [/bad]\r\n[minimum level]\r\n90\r\n';
expect(editItem(text, field('minimum level'), ['110'])).toBe(text.replace('90\r\n', '110\r\n'));
const custom: ItemField = { name: 'custom', label: '', kinds: ['integer'] };
expect(() => editItem(text, custom, ['2'])).toThrow('特殊类型');
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from 'vitest';
import { createItemNameSession } from '../src/gui/itemNames';
function deferred<T>() { let resolve!: (value: T) => void; const promise=new Promise<T>(r=>{resolve=r;});return{resolve,promise}; }
describe('礼包关联物品名称',()=>{
it('合并普通与自选奖励 ID,只查询一次重复 ID,保留未知物品标记',async()=>{
const read=vi.fn(async()=>({'1':'药剂','4':'装备😀'}));const session=createItemNameSession(read);
await session.load(['1','4','1',' 1 ','999','','invalid']);
expect(read).toHaveBeenCalledExactlyOnceWith(['1','4','999']);expect(session.names.value).toEqual({'1':'药剂','4':'装备😀','999':''});
});
it('修改奖励 ID 后淘汰旧名称,延迟结果不能覆盖新奖励',async()=>{
const old=deferred<Record<string,string>>(),next=deferred<Record<string,string>>();let calls=0;
const session=createItemNameSession(()=>++calls===1?old.promise:next.promise);
const first=session.load(['1']);const second=session.load(['2']);next.resolve({'2':'新奖励'});await second;
old.resolve({'1':'旧奖励'});await first;expect(session.names.value).toEqual({'2':'新奖励'});
});
it('切换归档清空名称并忽略旧归档响应,防止同 ID 串名',async()=>{
const pending=deferred<Record<string,string>>();const session=createItemNameSession(()=>pending.promise);
const first=session.load(['1']);session.invalidate(true);pending.resolve({'1':'旧归档'});await first;
expect(session.names.value).toEqual({});expect(session.loading.value).toBe(false);
});
it('同归档刷新保留已显示名称,失败后可以重试,空列表不发请求',async()=>{
const read=vi.fn().mockResolvedValueOnce({'1':'原名称'}).mockRejectedValueOnce(new Error('read failed')).mockResolvedValueOnce({'1':'更新名称'});
const session=createItemNameSession(read);await session.load(['1']);session.invalidate();await session.load(['1']);
expect(session.names.value['1']).toBe('原名称');expect(session.error.value).toContain('read failed');
await session.load(['1']);expect(session.names.value['1']).toBe('更新名称');expect(session.error.value).toBe('');
await session.load([]);expect(session.names.value).toEqual({});expect(read).toHaveBeenCalledTimes(3);
});
});
@@ -0,0 +1,33 @@
import { describe,expect,it,vi } from 'vitest';
import { createItemSkillCatalog } from '../src/gui/itemSkillCatalog';
import type { SkillTreeSkill } from '../bindings/pvfine/services/models';
const entry=(id:number,name:string):SkillTreeSkill=>({id,name,path:`skill/test/${id}.skl`,fileIndex:id});
function deferred<T>(){let resolve!:(value:T)=>void;const promise=new Promise<T>(r=>resolve=r);return{resolve,promise};}
describe('物品技能选择归档会话',()=>{
it('技能 ID 按职业隔离,合并重复职业查询,同一归档复用缓存',async()=>{
const read=vi.fn(async(job:string)=>[entry(12,job==='[swordman]'?'鬼斩':'魔法弹')]),session=createItemSkillCatalog(read);
await session.load(['[swordman]','[mage]','[swordman]','[all]','invalid']);
expect(read).toHaveBeenCalledTimes(2);expect(session.skills.value['[swordman]']![0]!.name).toBe('鬼斩');expect(session.skills.value['[mage]']![0]!.name).toBe('魔法弹');
await session.load(['[swordman]']);expect(read).toHaveBeenCalledTimes(2);
});
it('切换职业时延迟返回不会覆盖新请求,旧职业仍可重新读取',async()=>{
const old=deferred<SkillTreeSkill[]>(),next=deferred<SkillTreeSkill[]>();const session=createItemSkillCatalog(job=>job==='[swordman]'?old.promise:next.promise);
const first=session.load(['[swordman]']),second=session.load(['[mage]']);next.resolve([entry(12,'新技能')]);await second;old.resolve([entry(12,'旧技能')]);await first;
expect(session.skills.value).toEqual({'[mage]':[entry(12,'新技能')]});
await session.load(['[swordman]']);expect(session.skills.value['[swordman]']![0]!.name).toBe('旧技能');
});
it('切换归档清空缓存并忽略旧归档响应',async()=>{
const pending=deferred<SkillTreeSkill[]>(),session=createItemSkillCatalog(()=>pending.promise);const request=session.load(['[swordman]']);
session.invalidate(true);pending.resolve([entry(1,'旧归档')]);await request;
expect(session.skills.value).toEqual({});expect(session.loading.value).toBe(false);
});
it('部分职业失败不丢失成功结果,可以重试失败职业',async()=>{
let retry=false;const read=vi.fn(async(job:string)=>{if(job==='[mage]'&&!retry)throw new Error('failed');return[entry(1,job)];});
const session=createItemSkillCatalog(read);await session.load(['[mage]','[swordman]']);expect(session.errors.value['[mage]']).toContain('failed');expect(session.skills.value['[swordman]']).toHaveLength(1);
retry=true;await session.load(['[mage]','[swordman]']);expect(read).toHaveBeenCalledTimes(3);expect(session.errors.value).toEqual({});
});
it('空列表和非职业标签不触发查询,空职业列表也会缓存',async()=>{
const read=vi.fn(async()=>null),session=createItemSkillCatalog(read);await session.load(['[all]','','[bad]\n[tag]']);expect(read).not.toHaveBeenCalled();
await session.load(['[mage]']);await session.load(['[mage]']);expect(read).toHaveBeenCalledTimes(1);expect(session.skills.value['[mage]']).toEqual([]);
});
});
@@ -0,0 +1,105 @@
import { describe,expect,it } from 'vitest';
import { applySkillDataSelection, defaultItemSkillEffect, editItemSkillEffect, itemSkillEffectSummary, readItemSkillEffects, type ItemSkillEffect } from '../src/gui/itemSkillEffects';
const base='#PVF_File\r\n[name]\r\n`装备😀`\r\n[if]\r\n[cooltime]\r\n3000\r\n[/if]\r\n[then]\r\n[skill levelup]\r\n`[swordman]` 99 5\r\n[/skill levelup]\r\n[/then]\r\n[unknown]\r\n{5=1234} # 未知字段\r\n';
const level:ItemSkillEffect={kind:'level',job:'[swordman]',skill:'12',value:'2'};
const data:ItemSkillEffect={kind:'data',job:'[at fighter]',skill:'233',scope:'[all]',data:'[cooltime]',index:'0',operator:'%',value:'-6'};
const range=defaultItemSkillEffect('range');
const parsed=(text:string)=>{const result=readItemSkillEffects(text);expect(result.issues).toEqual([]);return result;};
describe('装备技能增强精确编辑',()=>{
it('三个格式可新增解析,合并同类段落,忽略触发器中的嵌套效果',()=>{
let text=editItemSkillEffect(base,{kind:'add',effect:level});text=editItemSkillEffect(text,{kind:'add',effect:data});text=editItemSkillEffect(text,{kind:'add',effect:range});
expect(parsed(text).entries.map(e=>e.effect)).toEqual([level,data,range]);expect(text).toContain(base);
text=editItemSkillEffect(text,{kind:'add',effect:{...data,index:'1',value:'1.5'}});
text=editItemSkillEffect(text,{kind:'add',effect:{...level,skill:'14',value:'-1'}});
text=editItemSkillEffect(text,{kind:'add',effect:{...range,lower:'20',upper:'30'}});
expect(parsed(text).entries).toHaveLength(6);expect(text.match(/\[skill data up\]/g)).toHaveLength(1);expect(text.match(/\[all skill item container\]/g)).toHaveLength(1);
});
it('等级加成支持连续记录、重复段落及字符串类型标记,未改时原文往返',()=>{
const text=base+'[skill levelup]\r\n{7=`[swordman]`} 12 2 # 第一项\r\n`[mage]` 20 1\r\n[/skill levelup]\r\n[skill levelup]\r\n`[swordman]` 14 3\r\n[/skill levelup]\r\n';
const doc=parsed(text);expect(doc.entries).toHaveLength(3);
expect(editItemSkillEffect(text,{kind:'edit',entry:0,effect:doc.entries[0]!.effect})).toBe(text);
expect(editItemSkillEffect(text,{kind:'edit',entry:0,effect:{...level,job:'[mage]',value:'4'}})).toBe(text.replace('{7=`[swordman]`} 12 2','{7=`[mage]`} 12 4'));
});
it('数据增强精确修改序号、数值、作用范围,保留未知字段和 CRLF',()=>{
const text=editItemSkillEffect(base,{kind:'add',effect:data}),next={...data,scope:'[dungeon type]',index:'1',value:'-1.5'};
const output=editItemSkillEffect(text,{kind:'edit',entry:0,effect:next});
expect(parsed(output).entries[0]!.effect).toEqual(next);expect(output).toContain(base);
expect(output.replaceAll('\r\n','')).not.toContain('\n');
});
it('删除连续记录只删除目标参数,删除最后记录移除整段',()=>{
let text=editItemSkillEffect('',{kind:'add',effect:level});text=editItemSkillEffect(text,{kind:'add',effect:{...level,skill:'14'}});
text=editItemSkillEffect(text,{kind:'delete',entry:0});expect(parsed(text).entries[0]!.effect).toMatchObject({skill:'14'});
text=editItemSkillEffect(text,{kind:'delete',entry:0});expect(parsed(text).entries).toEqual([]);expect(text).not.toContain('[skill levelup]');
});
it('区间修改按字段定位,保留注释及已有额外条件,未改原文往返',()=>{
const text=editItemSkillEffect(base,{kind:'add',effect:range}).replace('85\r\n','85 # 等级上限\r\n');
const doc=parsed(text);expect(editItemSkillEffect(text,{kind:'edit',entry:0,effect:doc.entries[0]!.effect})).toBe(text);
const next={...range,job:'[swordman]',upper:'90',value:'2'};
const result=editItemSkillEffect(text,{kind:'edit',entry:0,effect:next});
expect(parsed(result).entries[0]!.effect).toEqual(next);expect(result).toContain('90 # 等级上限');expect(result).toContain(base);
});
it('区间额外条件可增删改,支持多个区间独立修改与删除',()=>{
let text=editItemSkillEffect('',{kind:'add',effect:{...range,extra:[]}});
text=editItemSkillEffect(text,{kind:'edit',entry:0,effect:{...range,extra:['[normal skill]','[ex skill]']}});
expect(parsed(text).entries[0]!.effect).toMatchObject({extra:['[normal skill]','[ex skill]']});
text=editItemSkillEffect(text,{kind:'add',effect:{...range,lower:'30'}});
text=editItemSkillEffect(text,{kind:'edit',entry:0,effect:{...range,extra:[]}});
expect(parsed(text).entries[0]!.effect).toMatchObject({extra:[]});expect(parsed(text).entries).toHaveLength(2);
text=editItemSkillEffect(text,{kind:'delete',entry:0});expect(parsed(text).entries[0]!.effect).toMatchObject({lower:'30'});
text=editItemSkillEffect(text,{kind:'delete',entry:0});expect(text).not.toContain('[all skill item container]');
});
it('特殊结构分段只读,不影响其他有效增强;不修改未知条件字段',()=>{
const text='[skill data up]\n`[mage]` 1 `custom`\n[/skill data up]\n'+editItemSkillEffect(base,{kind:'add',effect:level});
const doc=readItemSkillEffects(text);expect(doc.issues.length).toBeGreaterThan(0);expect(doc.entries).toHaveLength(1);
const next=editItemSkillEffect(text,{kind:'edit',entry:0,effect:{...level,value:'4'}});expect(next).toContain(text.slice(0,text.indexOf('#PVF_File')));
const special=editItemSkillEffect('',{kind:'add',effect:range}).replace('[skill phase]','[special field]\n1\n[skill phase]');
expect(readItemSkillEffects(special).issues.length).toBeGreaterThan(0);
});
it('校验职业、技能 ID、数值、等级范围和操作符,拒绝代码注入及过期定位',()=>{
for(const effect of [
{...level,job:'swordman'},{...level,job:'[mage]\n[name]'},{...level,skill:'-1'},{...level,value:'1.5'},
{...data,index:'-1'},{...data,value:'NaN'},{...data,value:'3.5e38'},{...data,operator:'eval'},
{...range,lower:'90',upper:'1'},{...range,extra:['[/all skill item]']},
])expect(()=>editItemSkillEffect(base,{kind:'add',effect})).toThrow();
const text=editItemSkillEffect(base,{kind:'add',effect:level});
expect(()=>editItemSkillEffect(text,{kind:'edit',entry:0,effect:data})).toThrow('类型');
expect(()=>editItemSkillEffect(text,{kind:'delete',entry:9})).toThrow('条目已变化');
});
it('新增效果不追加到参数损坏的同名段落,坏记录保留且新记录可见',()=>{
const text='[skill data up]\n`mage` 1 `[all]` `[cooltime]` 0 `%` 2\n[/skill data up]\n';
const next=editItemSkillEffect(text,{kind:'add',effect:data}),doc=readItemSkillEffects(next);
expect(next).toContain(text);expect(doc.issues.length).toBeGreaterThan(0);expect(doc.entries.map(e=>e.effect)).toEqual([data]);
expect(next.match(/\[skill data up\]/g)).toHaveLength(2);
});
it('区间标签大小写与空格不因无修改应用而重写',()=>{
const text=editItemSkillEffect('',{kind:'add',effect:range}).replace('[all]','[ ALL ]');
const entry=parsed(text).entries[0]!;expect(editItemSkillEffect(text,{kind:'edit',entry:0,effect:entry.effect})).toBe(text);
});
it('缺失闭合或损坏的文本不能通过新增增强绕过解析检查',()=>{
expect(()=>editItemSkillEffect('[if]\n[probability]\n10',{kind:'add',effect:level})).toThrow();
expect(()=>editItemSkillEffect('[name]\n`未闭合',{kind:'add',effect:range})).toThrow();
});
it('摘要区分技能等级、数据修改和等级区间,不替数据序号猜测语义',()=>{
expect(itemSkillEffectSummary(level)).toBe('技能等级 +2');
expect(itemSkillEffectSummary(data)).toContain('冷却时间 [0] % -6');
expect(itemSkillEffectSummary(range)).toContain('Lv1–85');
});
});
describe('从技能 GUI 选择增强数据', () => {
it('使用原始动态索引并保留装备作用范围、运算符与小数操作数', () => {
const source = { ...data, value: '-7.5' } as Extract<ItemSkillEffect, {kind:'data'}>;
const result = applySkillDataSelection(source, { job: '[mage]', skill: '14', dynamic: true, index: 2, mode: 'dungeon' });
expect(result).toEqual({ ...source, job: '[mage]', skill: '14', data: '[level]', index: '2' });
expect(source).toMatchObject({ job: '[at fighter]', data: '[cooltime]', index: '0' });
expect(parsed(editItemSkillEffect('', {kind:'add',effect:result})).entries[0]!.effect).toEqual(result);
});
it('静态数据对应 static,索引零有效,决斗场选择不猜测装备作用范围', () => {
expect(applySkillDataSelection(data as Extract<ItemSkillEffect,{kind:'data'}>, {job:'[mage]',skill:'14',dynamic:false,index:0,mode:'pvp'})).toMatchObject({data:'[static]',index:'0',scope:'[all]'});
});
it('拒绝失效索引和非法技能身份', () => {
const selection={job:'[mage]',skill:'14',dynamic:true,index:0,mode:'dungeon'};
for(const index of [-1, 1.5, NaN, Infinity, 2147483648]) expect(()=>applySkillDataSelection(data as Extract<ItemSkillEffect,{kind:'data'}>, {...selection,index})).toThrow();
expect(()=>applySkillDataSelection(data as Extract<ItemSkillEffect,{kind:'data'}>, {...selection,skill:'bad'})).toThrow();
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import { editItemTrigger, readItemTriggers, triggerClauseRows, triggerClauseSpec, triggerClauses, formatItemTriggerBlock } from '../src/gui/itemTriggers';
const base = '#PVF_File\r\n[name]\r\n`装备😀`\r\n[if]\r\n[attack success]\r\n1 # 命中\r\n[cooltime]\r\n3000\r\n[/if]\r\n# 条件与动作间注释\r\n[then]\r\n[target]\r\n{7=`myself`} -1\r\n[custom]\r\n{5=1234} # 未知动作\r\n[passive object]\r\n48030 1 2 3 80 0 {8=`a.ptl`} # 对象一\r\n48045 4 5 6 90 2 `` # 对象二\r\n[/passive object]\r\n[/then]\r\n[unknown]\r\n42 # 后续字段\r\n';
function doc(text: string) { const result = readItemTriggers(text); expect(result.issues).toEqual([]); return result; }
function objectRows(text: string) { const section = doc(text).triggers[0]!.branches[0]!.children.find(c => c.name === 'passive object')!; return triggerClauseRows(section, triggerClauseSpec('then', section)!)!; }
describe('触发器精确编辑', () => {
it('识别配对、嵌套动作、重复对象行和类型标记', () => {
const trigger = doc(base).triggers[0]!;
expect(trigger.condition.children.map(c=>c.name)).toEqual(['attack success','cooltime']);
expect(objectRows(base)).toEqual([['48030','1','2','3','80','0','a.ptl'],['48045','4','5','6','90','2','']]);
expect(doc(base).triggers).toHaveLength(1);
});
it('修改单个 token 保留 CRLF、未知段、注释、转义与 marker', () => {
const rows = objectRows(base); rows[0]![6] = 'new`😀.ptl'; rows[1]![4] = '99';
const output = editItemTrigger(base,{kind:'edit-clause',trigger:0,branch:0,clause:2,rows});
const expected = base.replace('{8=`a.ptl`}','{8=`new``😀.ptl`}').replace('6 90 2','6 99 2');
const start = expected.indexOf('[if]'), end = expected.indexOf('[/then]') + '[/then]'.length;
expect(output).toBe(expected.slice(0, start) + formatItemTriggerBlock(expected.slice(start, end)) + expected.slice(end));
expect(editItemTrigger(base,{kind:'edit-clause',trigger:0,branch:0,clause:0,rows:[['myself','-1']]})).toBe(base);
});
it('追加条件与动作不修改现有结构,不读取 then 内同名条件', () => {
let text = editItemTrigger(base,{kind:'add-clause',trigger:0,name:'time',rows:[['1000','1','0']]});
text = editItemTrigger(text,{kind:'add-clause',trigger:0,branch:0,name:'stat',rows:[['attack speed','+','10']]});
expect(doc(text).triggers[0]!.condition.children.at(-1)?.name).toBe('time');
expect(doc(text).triggers[0]!.branches[0]!.children.at(-1)?.name).toBe('stat');
expect(text).toContain('# 条件与动作间注释'); expect(text).toContain('[unknown]\r\n42');
});
it('重复记录可增加及单独删除,保留原记录注释', () => {
const rows = objectRows(base), extra = ['5','0','0','0','1','0',''];
let text = editItemTrigger(base,{kind:'edit-clause',trigger:0,branch:0,clause:2,rows:[...rows,extra]});
expect(objectRows(text)).toEqual([...rows,extra]); expect(text).toContain('# 对象一');
text = editItemTrigger(text,{kind:'delete-row',trigger:0,branch:0,clause:2,row:0});
expect(objectRows(text)).toEqual([rows[1],extra]); expect(text).toContain('# 对象一');
});
it('非闭合多行字段追加记录不吞掉后续字段,删除只删除目标字段', () => {
const text = '[if]\n[hit]\n1\n[/if]\n[then]\n[stat]\n`attack speed` `+` 10 # old\n[duration]\n5000\n[/then]\n';
const next=editItemTrigger(text,{kind:'edit-clause',trigger:0,branch:0,clause:0,rows:[['attack speed','+','10'],['cast speed','+','20']]});
expect(doc(next).triggers[0]!.branches[0]!.children[1]!.tokens[0]!.value).toBe('5000');
expect(next).toContain('# old');
const deleted=editItemTrigger(next,{kind:'delete-clause',trigger:0,branch:0,clause:0});
expect(deleted).toContain('[duration]\n\t\t5000'); expect(deleted).not.toContain('[stat]');
});
it('添加随机分支包裹现有 then,保留原分支并以零概率新增', () => {
const result = editItemTrigger(base,{kind:'add-branch',trigger:0}), trigger=doc(result).triggers[0]!;
expect(trigger.multiple).toBe(true); expect(trigger.branches).toHaveLength(2); expect(result).toContain('\t\t[custom]\r\n\t\t\t{5=1234} # 未知动作');
expect(trigger.branches.map(b=>b.children.find(c=>c.name==='then probability')?.tokens[0]?.value)).toEqual(['100.0','0.0']);
const again=editItemTrigger(result,{kind:'add-branch',trigger:0}); expect(doc(again).triggers[0]!.branches).toHaveLength(3);
const removed=editItemTrigger(again,{kind:'delete-branch',trigger:0,branch:1}); expect(doc(removed).triggers[0]!.branches).toHaveLength(2);
const one=editItemTrigger(removed,{kind:'delete-branch',trigger:0,branch:1}); expect(()=>editItemTrigger(one,{kind:'delete-branch',trigger:0,branch:0})).toThrow('至少保留');
});
it('分支概率支持浮点,动作内字符串标签和换行不改变嵌套边界', () => {
let text=editItemTrigger('',{kind:'add-trigger',condition:'use skill',rows:[['swordman','5']],multiple:true});
text=editItemTrigger(text,{kind:'edit-clause',trigger:0,branch:0,clause:1,rows:[['33.3']]});
text=editItemTrigger(text,{kind:'add-clause',trigger:0,branch:0,name:'speech on',rows:[['0','2000','你好`\n[/then]']]});
expect(doc(text).triggers[0]!.branches).toHaveLength(1); expect(text).toContain('33.3');
expect(doc(text).triggers[0]!.condition.children[0]!.paired).toBe(true);
});
it('整组删除保留组间注释及后续未知字段', () => {
const text=editItemTrigger(base,{kind:'delete-trigger',trigger:0});
expect(doc(text).triggers).toHaveLength(0); expect(text).toContain('# 条件与动作间注释'); expect(text).toContain('[unknown]\r\n42 # 后续字段');
});
it('拒绝坏结构、过期位置、整数溢出、未支持字段和错误参数形状', () => {
for (const text of ['[if]\n[time]\n1','[if]\n[/if]\n[if]\n[/if]\n[then]\n[/then]']) expect(()=>editItemTrigger(text,{kind:'add-trigger',condition:'hit',rows:[['1']],multiple:false})).toThrow();
for(const value of ['NaN','2147483648','1\n[name]','1.2']) expect(()=>editItemTrigger(base,{kind:'add-clause',trigger:0,name:'cooltime',rows:[[value]]})).toThrow();
expect(()=>editItemTrigger(base,{kind:'delete-trigger',trigger:9})).toThrow('已变化');
expect(()=>editItemTrigger(base,{kind:'delete-clause',trigger:0,branch:0,clause:1})).toThrow('特殊');
expect(()=>editItemTrigger(base,{kind:'add-clause',trigger:0,branch:0,name:'passive object',rows:[['1','2']]})).toThrow('参数');
expect(()=>editItemTrigger(base,{kind:'add-trigger',condition:'use command',rows:[['1']],multiple:false})).toThrow('command');
});
it('每个预设可生成有效配对结构(资源 ID 需提供合法值)', () => {
for(const spec of triggerClauses.filter(c=>c.name!=='use command')) {
const row=spec.params.map(p=>p.resource&&p.kind==='integer'?'1':p.default);
const text=editItemTrigger('',{kind:'add-trigger',condition:'hit',rows:[['1']],multiple:false});
expect(()=>editItemTrigger(text,{kind:'add-clause',trigger:0,branch:spec.scope==='then'?0:undefined,name:spec.name,rows:[row]})).not.toThrow();
}
});
});
describe('操作键兼容已有 command', () => {
it('只复用当前文件已有的 command,不生成或更改二进制 token', () => {
const text='[command]\n{5=138}\n'+base;
const result=editItemTrigger(text,{kind:'add-trigger',condition:'use command',rows:[['1']],multiple:false});
expect(result).toContain(text); expect(doc(result).triggers).toHaveLength(2);
expect(result.match(/\[command\]/g)).toHaveLength(1);
expect(editItemTrigger(text,{kind:'add-clause',trigger:0,name:'use command',rows:[['1']]})).toContain('{5=138}');
});
});
describe('GUI 写入 DSL 的嵌套缩进', () => {
const expected = '[if]\n\t[attack success]\n\t\t1\n[/if]\n[multiple then]\n\t[then]\n\t\t[target]\n\t\t\t`myself`\t-1\n\t\t[then probability]\n\t\t\t100.0\n\t[/then]\n[/multiple then]\n';
it('新建多分支按容器层级缩进,前面的普通物品字段保持原样', () => {
const prefix='[anti evil]\n\t340\n';
expect(editItemTrigger(prefix,{kind:'add-trigger',condition:'attack success',rows:[['1']],multiple:true})).toBe(prefix+expected);
});
it('新增恢复动作和随机分支正确缩进,不在动作前额外插入空行', () => {
let text=editItemTrigger('',{kind:'add-trigger',condition:'attack success',rows:[['1']],multiple:true});
text=editItemTrigger(text,{kind:'add-clause',trigger:0,branch:0,name:'restore',rows:[['hp','%','10','0']]});
text=editItemTrigger(text,{kind:'add-branch',trigger:0});
expect(text).toContain('\t\t[restore]\n\t\t\t`hp`\t`%`\t10\t0\n\t[/then]');
expect(text).not.toContain('\n\n[restore]');
expect(text).toContain('\t[then]\n\t\t[target]\n\t\t\t`myself`\t-1\n\t\t[then probability]\n\t\t\t0.0\n\t[/then]\n[/multiple then]');
});
it('修改旧版无缩进草稿时修正当前触发器层级,保留其他段落、换行与 token 文本', () => {
const prefix='[anti evil]\r\n\t340\r\n',suffix='\r\n[unknown]\r\n {5=9} # 不触碰\r\n';
const body='[if]\r\n[attack success]\r\n1\r\n[/if]\r\n[multiple then]\r\n[then]\r\n[target]\r\n{7=`myself`} -1\r\n[restore]\r\n`hp` `%` 10 0 # 恢复\r\n[then probability]\r\n100.0\r\n[/then]\r\n[/multiple then]';
const text=editItemTrigger(prefix+body+suffix,{kind:'edit-clause',trigger:0,branch:0,clause:1,rows:[['hp','%','20','0']]});
expect(text.startsWith(prefix)).toBe(true);expect(text.endsWith(suffix)).toBe(true);
expect(text).toContain('\t\t[restore]\r\n\t\t\t`hp` `%` 20 0 # 恢复');
expect(text).toContain('\t\t\t{7=`myself`} -1');
expect(text.replaceAll('\r\n','')).not.toContain('\n');
});
it('嵌套闭合动作逐层缩进,多行字符串和注释的内容不变,重复格式化幂等', () => {
const raw='[if]\n[use skill]\n`swordman` 5\n[/use skill]\n[/if]\n[then]\n[speech on]\n0 2000 `第一行\n [then]\n\t原有缩进😀` # 注释\n[/speech on]\n/* 保留\n [unknown]\n*/\n[/then]';
const formatted=formatItemTriggerBlock(raw);
expect(formatted).toContain('\t[use skill]\n\t\t`swordman` 5\n\t[/use skill]');
expect(formatted).toContain('`第一行\n [then]\n\t原有缩进😀` # 注释');
expect(formatted).toContain('/* 保留\n [unknown]\n*/');
expect(formatted).toContain('\t[/speech on]\n');
expect(formatItemTriggerBlock(formatted)).toBe(formatted);
});
it('不修改没有变化的表单,闭合标签同一行时不会截断或改写 token', () => {
expect(editItemTrigger(base,{kind:'edit-clause',trigger:0,branch:0,clause:2,rows:objectRows(base)})).toBe(base);
let text='[if] [hit] 1 [/if]\n[then] [target] `myself` -1 [/then]';
text=editItemTrigger(text,{kind:'add-clause',trigger:0,branch:0,name:'restore',rows:[['hp','%','10','0']]});
expect(doc(text).triggers[0]!.branches[0]!.children.map(s=>s.name)).toEqual(['target','restore']);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { expect, test, vi } from "vitest";
import { createSSRApp } from "vue";
import { renderToString } from "@vue/server-renderer";
import { createPinia, setActivePinia } from "pinia";
import LogPanel from "../src/components/LogPanel.vue";
import { logCapacity, useLogStore } from "../src/stores/logs";
const virtual = vi.hoisted(() => ({ props: undefined as any }));
vi.mock("naive-ui", async () => {
const { defineComponent, h } = await import("vue");
const wrapper = defineComponent({ setup: (_, { slots }) => () => h("div", slots.default?.()) });
return {
NButton: wrapper, NCheckbox: wrapper, NCheckboxGroup: wrapper, NIcon: wrapper, NTooltip: wrapper,
NVirtualList: defineComponent({
props: {
items: { type: Array, required: true }, itemSize: Number, itemResizable: Boolean,
keyField: String, defaultScrollIndex: Number, onScroll: Function,
},
setup(props, { slots }) {
virtual.props = props;
// 模拟虚拟列表只请求可见行,确认面板没有提前渲染全部条目。
return () => h("div", props.items.slice(-12).map((item: unknown) => slots.default?.({ item })));
},
}),
};
});
test("满容量日志延迟到展开后渲染,并通过虚拟列表按需显示多行内容", async () => {
const pinia = createPinia();
setActivePinia(pinia);
const logs = useLogStore();
logs.ingest(Array.from({ length: logCapacity }, (_, index) => ({
id: index + 1, timestamp: "2026-10-21T00:00:00Z", source: "test", level: "WARN",
message: `日志 ${index}\n多行内容`,
})));
const render = () => renderToString(createSSRApp(LogPanel).use(pinia));
virtual.props = undefined;
expect(await render()).not.toContain('class="log-line');
expect(virtual.props).toBeUndefined();
logs.expanded = true;
const html = await render();
expect(virtual.props.items).toHaveLength(logCapacity);
expect(virtual.props.itemResizable).toBe(true);
expect(virtual.props.keyField).toBe("id");
expect(virtual.props.defaultScrollIndex).toBe(logCapacity - 1);
expect(html.match(/class="log-line /g)).toHaveLength(12);
expect(html).toContain(`日志 ${logCapacity - 1}\n多行内容`);
virtual.props.onScroll({ target: { scrollHeight: 48000, scrollTop: 0, clientHeight: 200 } });
expect(logs.follow).toBe(false);
logs.add("ERROR", "test", "最新错误");
logs.levels = ["ERROR"];
expect(await render()).toContain("最新错误");
expect(virtual.props.items).toHaveLength(1);
expect(virtual.props.defaultScrollIndex).toBeUndefined();
virtual.props.onScroll({ target: { scrollHeight: 200, scrollTop: 0, clientHeight: 200 } });
expect(logs.follow).toBe(true);
logs.clear();
expect(await render()).toContain("暂无日志");
});
+79
View File
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { createApp } from "vue";
import { createPinia, setActivePinia } from "pinia";
const runtime = vi.hoisted(() => ({
handlers: new Map<string, (event: { data: unknown }) => void>(),
emit: vi.fn(),
}));
vi.mock("@wailsio/runtime", () => ({
Events: {
On: (name: string, handler: (event: { data: unknown }) => void) => runtime.handlers.set(name, handler),
Emit: runtime.emit,
},
}));
import { installLogging } from "../src/logging";
import { useLogStore } from "../src/stores/logs";
const listeners = new Map<string, (event: any) => void>();
beforeEach(() => {
vi.useFakeTimers();
setActivePinia(createPinia());
runtime.handlers.clear();
listeners.clear();
runtime.emit.mockReset().mockResolvedValue(undefined);
vi.stubGlobal("window", { addEventListener: (name: string, handler: (event: any) => void) => listeners.set(name, handler) });
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
test("subscribes before requesting history and receives backend logs", () => {
installLogging(createApp({}));
expect(runtime.emit).toHaveBeenCalledWith("app:logs-request");
runtime.handlers.get("app:logs")?.({ data: [{ id: 1, timestamp: "2026-10-07T12:00:00Z", level: "INFO", message: "startup" }] });
runtime.handlers.get("app:log")?.({ data: { id: 2, timestamp: "2026-10-07T12:00:01Z", level: "ERROR", message: "failed" } });
expect(useLogStore().entries).toHaveLength(0);
vi.runAllTimers();
expect(useLogStore().entries.map((entry) => entry.message)).toEqual(["startup", "failed"]);
});
test("captures console, browser and Vue exceptions", () => {
const app = createApp({});
const previous = vi.fn();
app.config.errorHandler = previous;
installLogging(app);
console.error("caught", new Error("console error"));
console.warn("warning");
listeners.get("error")?.({ error: new Error("browser error") });
listeners.get("unhandledrejection")?.({ reason: new Error("promise error") });
app.config.errorHandler?.(new Error("vue error"), null, "render");
const logs = useLogStore();
expect(logs.entries).toHaveLength(5);
expect(logs.errorCount).toBe(4);
expect(previous).toHaveBeenCalledOnce();
});
test("生产环境不写 Vue 开发警告,仍捕获业务警告与实际异常", () => {
vi.stubEnv("DEV", false);
vi.stubEnv("MODE", "production");
const warningOutput = console.warn;
const app = createApp({});
installLogging(app);
console.warn("[Vue warn]: development-only trace");
console.warn("业务警告");
console.error("业务错误");
app.config.errorHandler?.(new Error("Vue actual error"), null, "runtime trace");
const logs = useLogStore();
expect(warningOutput).toHaveBeenCalledOnce();
expect(logs.entries).toHaveLength(3);
expect(logs.errorCount).toBe(2);
expect(logs.entries.some((entry) => entry.message.includes("development-only") || entry.message.includes("runtime trace"))).toBe(false);
});
+120
View File
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { isReactive, watch } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { formatLogValue, logCapacity, useLogStore } from "../src/stores/logs";
beforeEach(() => setActivePinia(createPinia()));
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
function backend(id: number, level = "INFO") {
return { id, timestamp: "2026-10-07T10:00:00Z", source: "Go", level, message: `entry ${id}` };
}
test("records while collapsed and filters exact selected levels", () => {
const logs = useLogStore();
logs.add("DEBUG", "test", "debug");
logs.add("INFO", "test", "info");
logs.add("WARN", "test", "warning");
logs.add("ERROR", "test", new Error("failure"));
expect(logs.expanded).toBe(false);
expect(logs.entries).toHaveLength(4);
expect(logs.filtered).toHaveLength(3);
expect(logs.errorCount).toBe(1);
logs.levels = ["ERROR", "DEBUG"];
expect(logs.filtered.map((entry) => entry.level)).toEqual(["DEBUG", "ERROR"]);
logs.levels = [];
expect(logs.filtered).toEqual([]);
});
test("history and live events are deduplicated and backend order is restored", () => {
const logs = useLogStore();
logs.ingest(backend(3));
logs.ingest([backend(1), backend(2), backend(3)]);
expect(logs.entries.map((entry) => entry.backendID)).toEqual([1, 2, 3]);
logs.ingest(backend(2));
expect(logs.entries).toHaveLength(3);
});
test("retains only the latest entries", () => {
const logs = useLogStore();
logs.ingest(Array.from({ length: logCapacity + 50 }, (_, i) => backend(i + 1)));
expect(logs.entries).toHaveLength(logCapacity);
expect(logs.entries[0].backendID).toBe(51);
logs.add("ERROR", "test", "latest");
expect(logs.entries).toHaveLength(logCapacity);
expect(logs.entries.at(-1)?.message).toBe("latest");
});
test("clear prevents delayed history replay from restoring cleared entries", () => {
const logs = useLogStore();
logs.ingest(backend(10));
logs.clear();
logs.ingest([backend(5), backend(10), backend(11)]);
expect(logs.entries.map((entry) => entry.backendID)).toEqual([11]);
logs.add("ERROR", "test", "new error");
expect(logs.errorCount).toBe(1);
logs.clear();
expect(logs.errorCount).toBe(0);
});
test("handles ERR, unknown levels and malformed events", () => {
const logs = useLogStore();
logs.ingest([null, {}, { ...backend(0) }, backend(1, "ERR"), backend(2, "unknown")]);
expect(logs.entries.map((entry) => entry.level)).toEqual(["ERROR", "INFO"]);
});
test("formats exceptions, objects and circular console arguments", () => {
expect(formatLogValue(new Error("failure"))).toContain("failure");
expect(formatLogValue({ state: "error" })).toBe('{"state":"error"}');
const circular: { self?: unknown } = {};
circular.self = circular;
expect(formatLogValue(circular)).toBe("[object Object]");
});
test("日志突发异步合并为一次界面更新,并保留容量内的顺序与错误", () => {
vi.useFakeTimers();
const logs = useLogStore();
let publications = 0;
const stop = watch(() => logs.entries, () => publications++, { flush: "sync" });
for (let id = 4000; id > 0; id--) logs.enqueueBackendLogs(backend(id, id === 4000 ? "ERROR" : "DEBUG"));
expect(logs.entries).toHaveLength(0);
vi.runAllTimers();
expect(publications).toBe(1);
expect(logs.entries).toHaveLength(logCapacity);
expect(logs.entries[0].backendID).toBe(2001);
expect(logs.entries.at(-1)?.backendID).toBe(4000);
expect(logs.errorCount).toBe(1);
expect(isReactive(logs.entries[0])).toBe(false);
const previous = logs.entries;
logs.add("INFO", "test", "new entry");
expect(previous.at(-1)?.backendID).toBe(4000);
stop();
});
test("清空时取消待发布日志,延迟历史不会重新出现", () => {
vi.useFakeTimers();
const logs = useLogStore();
logs.enqueueBackendLogs([backend(10), backend(11)]);
logs.clear();
logs.enqueueBackendLogs([backend(10), backend(11), backend(12)]);
vi.runAllTimers();
expect(logs.entries.map((entry) => entry.backendID)).toEqual([12]);
});
test("生产日志保留操作结果和异常,跳过 DEBUG 与 Go 运行时信息", () => {
vi.stubEnv("DEV", false);
vi.stubEnv("MODE", "production");
const logs = useLogStore();
const format = vi.fn(() => "debug details");
logs.add("DEBUG", "performance", { toJSON: format });
logs.ingest([
backend(1, "DEBUG"), backend(2, "INFO"), backend(3, "WARN"), backend(4, "ERROR"),
{ ...backend(5, "INFO"), source: "archive:saved" },
{ ...backend(6, "INFO"), source: undefined },
]);
expect(format).not.toHaveBeenCalled();
expect(logs.entries.map((entry) => entry.backendID)).toEqual([3, 4, 5]);
});
+44
View File
@@ -0,0 +1,44 @@
import { expect, test } from "vitest";
import { EditorState } from "@codemirror/state";
import { ensureSyntaxTree } from "@codemirror/language";
import { highlightTree, tags, tagHighlighter } from "@lezer/highlight";
import { archiveEditorLanguage, luaLanguage } from "../src/luaLanguage";
test("归档中的 Lua 后缀识别兼容大小写,其余文件保持 PVF 语法", () => {
expect(archiveEditorLanguage("scripts/example.lua")).toBe("lua");
expect(archiveEditorLanguage("scripts/example.LUA")).toBe("lua");
expect(archiveEditorLanguage("scripts/example.lua.bak")).toBe("pvf");
expect(archiveEditorLanguage("equipment/example.equ")).toBe("pvf");
});
test("Lua 高亮识别关键字、数字、转义字符串以及跨行长字符串和注释", () => {
const doc = [
"local count = 42",
'local text = "hello\\\"world"',
"-- single line comment",
"local long = [=[first",
"second]=]",
"--[==[block",
"comment]==]",
"return count",
].join("\n");
const state = EditorState.create({ doc, extensions: luaLanguage.extension });
const tree = ensureSyntaxTree(state, doc.length, 1000);
expect(tree).not.toBeNull();
const highlighted: Record<string, string[]> = {};
highlightTree(tree!, tagHighlighter([
{ tag: tags.keyword, class: "keyword" },
{ tag: tags.number, class: "number" },
{ tag: tags.string, class: "string" },
{ tag: tags.comment, class: "comment" },
]), (from, to, style) => {
(highlighted[style] ??= []).push(doc.slice(from, to));
});
expect(highlighted.keyword).toContain("local");
expect(highlighted.keyword).toContain("return");
expect(highlighted.number).toEqual(["42"]);
expect(highlighted.string?.join("\n")).toContain('[=[first\nsecond]=]');
expect(highlighted.string).toContain('"hello\\\"world"');
expect(highlighted.comment).toContain("-- single line comment");
expect(highlighted.comment?.join("\n")).toContain("--[==[block\ncomment]==]");
});
+87
View File
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { assertMCPIdle, setMCPActive } from "../src/mcpActivity";
const state = vi.hoisted(() => ({
handlers: new Map<string, (event: any) => any>(),
drafts: vi.fn(() => [] as unknown[]),
sync: vi.fn().mockResolvedValue(undefined),
acknowledge: vi.fn().mockResolvedValue(undefined),
report: vi.fn().mockResolvedValue(undefined),
snapshot: vi.fn().mockResolvedValue({ operationId: "" }),
unregister: vi.fn(),
script: { running: false, applying: false },
}));
vi.mock("@wailsio/runtime", () => ({ Events: { On: (name: string, handler: (event: any) => any) => { state.handlers.set(name, handler); return vi.fn(); } } }));
vi.mock("../bindings/pvfine/services", () => ({ MCPService: { Acknowledge: state.acknowledge, ReportWindow: state.report, WindowState: state.snapshot, UnregisterWindow: state.unregister, CancelActiveScript: vi.fn() } }));
vi.mock("../src/stores/editor", () => ({ useEditorStore: () => ({ archiveDiffDrafts: state.drafts, syncMCPWorkspace: state.sync, closeAllTabs: vi.fn() }) }));
vi.mock("../src/stores/archive", () => ({ useArchiveStore: () => ({ loading: false }) }));
vi.mock("../src/stores/script", () => ({ useScriptStore: () => state.script }));
vi.mock("../src/stores/import", () => ({ useImportStore: () => ({ running: false }) }));
vi.mock("../src/stores/export", () => ({ useExportStore: () => ({ preparing: false, running: false }) }));
vi.mock("../src/stores/batch", () => ({ useBatchStore: () => ({ loading: false, applying: false }) }));
vi.mock("../src/stores/version", () => ({ useVersionStore: () => ({ committing: false }) }));
import { installMCPBridge } from "../src/mcpBridge";
let root: { inert: boolean };
let unload: (() => void) | undefined;
beforeEach(() => {
vi.clearAllMocks(); state.handlers.clear(); state.drafts.mockReturnValue([]); state.script.running = false;
state.snapshot.mockResolvedValue({ operationId: "" });
root = { inert: false };
const node = () => ({ style: { cssText: "", display: "" }, setAttribute: vi.fn(), appendChild: vi.fn(), addEventListener: vi.fn(), contains: () => false, remove: vi.fn(), textContent: "" });
vi.stubGlobal("document", { getElementById: () => root, createElement: node, body: { appendChild: vi.fn() } });
vi.stubGlobal("window", { addEventListener: (name: string, fn: () => void) => { if (name === "beforeunload") unload = fn; }, removeEventListener: vi.fn(), setInterval: vi.fn(() => 1), clearInterval: vi.fn() });
});
afterEach(() => { unload?.(); unload = undefined; setMCPActive(false); vi.unstubAllGlobals(); });
test("AI 准备屏障冻结编辑,拒绝未提交草稿,结束后释放", async () => {
installMCPBridge(false);
await vi.waitFor(() => expect(state.report).toHaveBeenCalled());
state.drafts.mockReturnValue([{}]);
await state.handlers.get("mcp:prepare")!({ data: { operationId: "operation-a", kind: "archive_save" } });
expect(root.inert).toBe(true);
expect(() => assertMCPIdle()).toThrow("AI 正在操作");
expect(state.acknowledge).toHaveBeenCalledWith("main", "operation-a", true, false);
await state.handlers.get("mcp:released")!({ data: { operationId: "operation-a" } });
expect(root.inert).toBe(false);
expect(() => assertMCPIdle()).not.toThrow();
});
test("GUI 草稿检查报错也按未提交草稿处理", async () => {
installMCPBridge(false);
state.drafts.mockImplementation(() => { throw new Error("GUI draft"); });
await state.handlers.get("mcp:prepare")!({ data: { operationId: "operation-b" } });
expect(state.acknowledge).toHaveBeenCalledWith("main", "operation-b", true, false);
});
test("窗口重载后恢复运行中的屏障,独立脚本源码不会计作归档草稿", async () => {
state.snapshot.mockResolvedValue({ operationId: "running-task", kind: "script_run" } as any);
installMCPBridge(true);
await vi.waitFor(() => expect(root.inert).toBe(true));
expect(state.drafts).not.toHaveBeenCalled();
expect(() => assertMCPIdle()).toThrow();
});
test("旧的心跳响应不能释放刚建立的准备屏障", async () => {
let resolve!: (value: any) => void;
state.snapshot.mockReturnValue(new Promise(done => { resolve = done; }));
installMCPBridge(false);
await vi.waitFor(() => expect(state.snapshot).toHaveBeenCalled());
await state.handlers.get("mcp:prepare")!({ data: { operationId: "new-operation" } });
resolve({ operationId: "" });
await Promise.resolve(); await Promise.resolve();
expect(root.inert).toBe(true);
expect(() => assertMCPIdle()).toThrow();
});
test("应用后的标签同步完成前,不释放界面也不确认下一个操作", async () => {
let resolve!: () => void;
state.sync.mockReturnValue(new Promise<void>(done => { resolve = done; }));
installMCPBridge(false);
await state.handlers.get("mcp:prepare")!({ data: { operationId: "apply-task" } });
state.handlers.get("mcp:workspace-changed")!({ data: { kind: "script_apply" } });
const releasing = state.handlers.get("mcp:released")!({ data: { operationId: "apply-task" } });
await Promise.resolve();
expect(root.inert).toBe(true);
resolve(); await releasing;
expect(state.sync).toHaveBeenCalled();
expect(root.inert).toBe(false);
});
@@ -0,0 +1,14 @@
import { expect, test } from "vitest";
import { getPreviewProvider } from "../src/previews/registry";
import type { PreviewFile } from "../src/previews/types";
const file: PreviewFile = { index: 1, path: "equipment/test.equ", text: "", editable: true };
test("只有包含 equipment type section 的 .equ 文件启用装备预览", () => {
expect(getPreviewProvider(file)).toBeNull();
expect(getPreviewProvider({ ...file, text: "[name]\n`测试`\n[equipment type]\n`[armor]`" })?.id).toBe("equ");
expect(getPreviewProvider({ ...file, text: "[equipment type]\r\n`[weapon]`" })?.id).toBe("equ");
expect(getPreviewProvider({ ...file, text: "# [equipment type]\n[name]\n`测试`" })).toBeNull();
expect(getPreviewProvider({ ...file, text: "[name]\n`[equipment type]`" })).toBeNull();
expect(getPreviewProvider({ ...file, path: "equipment/test.txt", text: "[equipment type]\n`[armor]`" })).toBeNull();
});
@@ -0,0 +1,37 @@
import { expect, test } from "vitest";
import { EditorState } from "@codemirror/state";
import { foldable } from "@codemirror/language";
import { pvfSectionFolding } from "../src/pvfSectionFolding";
function foldedText(state: EditorState, lineNumber: number): string | null {
const line = state.doc.line(lineNumber);
const range = foldable(state, line.from, line.to);
return range ? state.doc.sliceString(range.from, range.to) : null;
}
test("成对 section 包含嵌套内容与结束标签,无结束标签停在下一个同级 section", () => {
const state = EditorState.create({
doc: "[outer]\n[parent]\n1\n[child]\n2\n[/child]\n[second]\n3\n[/outer]\n[after]\n4",
extensions: pvfSectionFolding,
});
expect(foldedText(state, 1)).toBe("\n[parent]\n1\n[child]\n2\n[/child]\n[second]\n3\n[/outer]");
expect(foldedText(state, 2)).toBe("\n1");
expect(foldedText(state, 4)).toBe("\n2\n[/child]");
expect(foldedText(state, 7)).toBe("\n3");
expect(foldedText(state, 10)).toBe("\n4");
});
test("忽略注释和字符串中的标签,文档编辑后更新折叠边界", () => {
const state = EditorState.create({
doc: "[first]\n`[fake]`\n# [comment]\n[second]\n2",
extensions: pvfSectionFolding,
});
expect(foldedText(state, 1)).toBe("\n`[fake]`\n# [comment]");
expect(foldedText(state, 2)).toBeNull();
expect(foldedText(state, 3)).toBeNull();
const next = state.update({ changes: { from: state.doc.length, insert: "\n[third]\n3" } }).state;
expect(foldedText(next, 4)).toBe("\n2");
expect(foldedText(next, 6)).toBe("\n3");
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { editQuest, parseQuest, questValues, questAnnotationText, questDisplayValue, questReference, questReferenceAnnotation } from '../src/gui/quest';
import type { EditorAnnotation } from '../bindings/pvfine/services/models';
import { getGUIProvider } from '../src/gui/registry';
const source = '#PVF_File\n[name]\n`任务😀` # 名称\n[type]\n`[seeking]`\n[sub type]\n-1\n[int data]\n100 2 # 第一组\n200 3\n[/int data]\n[custom]\n{8=`<1::text>`}\n';
describe('任务 GUI', () => {
it('识别 qst 并读取草稿,忽略字符串内标签', () => {
expect(getGUIProvider({ index: 1, path: 'quest/TEST.QST', text: source, editable: true })?.id).toBe('quest');
const doc = parseQuest(source);
expect(questValues(doc, 'name')).toEqual(['任务😀']);
expect(questValues(doc, 'type')).toEqual(['[seeking]']);
expect(questValues(doc, 'int data')).toEqual(['100', '2', '200', '3']);
expect(doc.sections).toHaveLength(5);
});
it('仅修改目标 token,保留未知字段、注释、空白及 CRLF', () => {
const text = source.replaceAll('\n', '\r\n');
expect(editQuest(text, [{ name: 'int data', values: ['100', '9', '200', '3'], kind: 'integer' }])).toBe(text.replace('100 2', '100 9'));
expect(editQuest(text, [{ name: 'name', values: ['新`任务😀\n对话'], kind: 'string' }])).toBe(text.replace('`任务😀`', '`新``任务😀\n对话`'));
});
it('增删列表参数保留闭合标签和注释,支持空字段', () => {
const added = editQuest(source, [{ name: 'int data', values: ['100', '2', '200', '3', '300', '4'], kind: 'integer' }]);
expect(added).toContain('200 3\t300\t4\n[/int data]');
const removed = editQuest(source, [{ name: 'int data', values: [], kind: 'integer' }]);
expect(removed).toContain('# 第一组');
expect(removed).toContain('[/int data]');
expect(questValues(parseQuest(removed), 'int data')).toEqual([]);
expect(questValues(parseQuest(editQuest('[int data]\n# keep\n[/int data]', [{ name: 'int data', values: ['1', '2'], kind: 'integer' }])), 'int data')).toEqual(['1', '2']);
});
it('新增字段追加到末尾,切换类型保留原始参数', () => {
const added = editQuest(source, [{ name: 'reward selection int data', values: ['100', '1'], kind: 'integer', paired: true }]);
expect(added.startsWith(source)).toBe(true);
expect(added).toContain('[reward selection int data]\n\t100\t1\n[/reward selection int data]');
const changed = editQuest(source, [{ name: 'type', values: ['[condition under clear]'], kind: 'string' }, { name: 'sub type', values: ['6'], kind: 'integer' }]);
expect(questValues(parseQuest(changed), 'sub type')).toEqual(['6']);
expect(questValues(parseQuest(changed), 'int data')).toEqual(['100', '2', '200', '3']);
});
it('重复字段、字符串引用和特殊类型禁止覆盖', () => {
expect(() => editQuest('[name]\n`a`\n[name]\n`b`', [{ name: 'name', values: ['c'], kind: 'string' }])).toThrow('重复字段');
const ref = '[name]\n{8=`<1::name>`}\n[level]\n1 120';
expect(() => editQuest(ref, [{ name: 'name', values: ['c'], kind: 'string' }])).toThrow('字符串引用');
expect(editQuest(ref, [{ name: 'level', values: ['10', '120'], kind: 'integer' }])).toContain('{8=`<1::name>`}');
expect(() => editQuest('[level]\n`1`', [{ name: 'level', values: ['2'], kind: 'integer' }])).toThrow('类型不匹配');
});
it('拒绝未闭合、交叉闭合、非整数和溢出', () => {
for (const text of ['[name]\n`oops', '[name', '[outer]\n[inner]\n1\n[/outer]\n[/inner]', '[name]\n{8=`x`']) expect(() => parseQuest(text)).toThrow();
for (const value of ['', '1.5', 'NaN', '2147483648', '-2147483649']) expect(() => editQuest(source, [{ name: 'sub type', values: [value], kind: 'integer' }])).toThrow();
});
it('嵌套区块只读保留,子字段不会覆盖顶层字段', () => {
const text = '[name]\n`root`\n[change depend on grow job]\n1\n[name]\n`nested`\n[solve message]\n`dialogue`\n[/change depend on grow job]\n[level]\n1 115';
const doc = parseQuest(text);
expect(questValues(doc, 'name')).toEqual(['root']);
expect(questValues(doc, 'solve message')).toEqual([]);
expect(doc.sections.find(s => s.name === 'change depend on grow job')?.issue).toContain('嵌套');
expect(() => editQuest(text, [{ name: 'change depend on grow job', kind: 'integer', values: ['2'] }])).toThrow('嵌套');
expect(editQuest(text, [{ name: 'level', kind: 'integer', values: ['2', '115'] }])).toBe(text.replace('1 115', '2 115'));
const paired = '[outer]\n[inner]\n1\n[/inner]\n[other]\n2\n[/outer]\n[level]\n3';
expect(parseQuest(paired).sections.map(s => s.name)).toEqual(['outer', 'level']);
expect(editQuest(paired, [{ name: 'level', kind: 'integer', values: ['4'] }])).toBe(paired.replace('[level]\n3', '[level]\n4'));
});
it('正确处理反引号转义、多行文本和注释中的标签', () => {
const text = '# [name]\n[name]\n`a``b\n[other]`\n[level]\n1 120';
expect(questValues(parseQuest(text), 'name')).toEqual(['a`b\n[other]']);
expect(editQuest(text, [{ name: 'level', values: ['5', '120'], kind: 'integer' }])).toBe(text.replace('1 120', '5 120'));
});
});
describe('110+ 任务字符串', () => {
const annotation: EditorAnnotation = { start: 0, end: 1, title: '冒险任务', content: '', type: 'placeholder', targetFileIndex: -1, rarity: -1, placeholder: { tableIndex: 31, key: 'quest_name' } };
it('解析 8/10 引用和字面文本,忽略引用内容中的标签及花括号', () => {
const text = '[name]\n{8=`<31::quest_name>`}\n[depend message]\n{10=`<31::quest_dialogue>`}\n[grade]\n{10=`[daily]`}\n[solve message]\n{8=`文字``与}\n[标签]`}';
const doc = parseQuest(text);
expect(questValues(doc, 'grade')).toEqual(['[daily]']);
expect(doc.sections[0]?.tokens[0]).toMatchObject({ markerType: 8, kind: 'string', reference: { tableIndex: 31, key: 'quest_name' } });
expect(doc.sections[1]?.tokens[0]?.markerType).toBe(10);
expect(questValues(doc, 'solve message')).toEqual(['文字`与}\n[标签]']);
expect(questReference('< 31 :: quest_name >')).toEqual({ tableIndex: 31, key: 'quest_name' });
});
it('编辑字面文本保留 8/10 类型、转义、注释和其他引用', () => {
const text = '[name]\r\n{8=`<31::quest_name>`}\r\n[grade]\r\n{10= `[daily]` } # keep\r\n[solve message]\r\n{8=`原文`}';
expect(editQuest(text, [{ name: 'grade', kind: 'string', values: ['[epic]'] }])).toBe(text.replace('{10= `[daily]` }', '{10=`[epic]`}'));
expect(editQuest(text, [{ name: 'grade', kind: 'string', values: ['[daily]'] }])).toBe(text);
expect(editQuest(text, [{ name: 'solve message', kind: 'string', values: ['新`文本\n下一行'] }])).toBe(text.replace('{8=`原文`}', '{8=`新``文本\n下一行`}'));
});
it('引用禁止改写为内联文本或删除,数值编辑保留原始标记', () => {
for (const marker of ['{8=`<31::quest_name>`}', '{10=`<31::quest_name>`}', '`<31::quest_name>`']) {
const text = `[name]\n${marker}\n[level]\n1 120`;
expect(() => editQuest(text, [{ name: 'name', kind: 'string', values: ['替换'] }])).toThrow('修改译文');
expect(() => editQuest(text, [{ name: 'name', kind: 'string', values: [] }])).toThrow('修改译文');
expect(editQuest(text, [{ name: 'level', kind: 'integer', values: ['5', '120'] }])).toBe(text.replace('1 120', '5 120'));
}
});
it('按表号和键解析译文,摆脱 DSL 改动后的过期位置', () => {
const doc = parseQuest('[name]\n{10=`<31::quest_name>`}');
expect(questDisplayValue(doc, 'name', [annotation])).toBe('冒险任务');
expect(questReferenceAnnotation({ tableIndex: 32, key: 'quest_name' }, [annotation])).toBeUndefined();
expect(questDisplayValue(doc, 'name', [])).toBe('<31::quest_name>');
const fallback = { ...annotation, title: '冒险任务(未翻译)', placeholder: { ...annotation.placeholder!, fallback: true } };
expect(questAnnotationText(fallback)).toBe('冒险任务');
expect(questAnnotationText({ ...annotation, title: '原文(未翻译)' })).toBe('原文(未翻译)');
expect(questAnnotationText({ ...annotation, placeholder: { ...annotation.placeholder!, missing: true } })).toBe('');
});
});
+38
View File
@@ -0,0 +1,38 @@
import { readFileSync } from "node:fs";
import { expect, test } from "vitest";
import { editQuest, parseQuest } from "../src/gui/quest";
const corpus = process.env.PVF_GUI_QUEST_CORPUS;
test.skipIf(!corpus)("real archive quests preserve source ranges and support integer edits", () => {
const fixtures: { text: string }[] = JSON.parse(readFileSync(corpus!, "utf8"));
expect(fixtures.length).toBeGreaterThan(0);
let failed = 0, issues = 0, edited = 0;
const errors = new Map<string, number>();
for (const { text } of fixtures) {
let doc: ReturnType<typeof parseQuest>;
try {
doc = parseQuest(text);
} catch (cause) {
failed++;
const message = cause instanceof Error ? cause.message.split("\n")[0]! : String(cause);
errors.set(message, (errors.get(message) ?? 0) + 1);
continue;
}
if (doc.sections.some(s => s.issue)) issues++;
expect(editQuest(text, [])).toBe(text);
const field = doc.sections.find(s => !s.issue && s.tokens.length > 0 && s.tokens.every(t => t.kind === "integer"));
if (!field) continue;
const values = field.tokens.map(t => t.value);
const first = field.tokens[0]!;
const next = first.value === "0" ? "1" : "0";
values[0] = next;
expect(editQuest(text, [{ name: field.name, kind: "integer", values }])).toBe(
text.slice(0, first.start) + next + text.slice(first.end),
);
edited++;
}
console.log(JSON.stringify({ quests: fixtures.length, failed, issues, edited, errors: Object.fromEntries(errors) }));
expect(failed).toBe(Number(process.env.PVF_GUI_QUEST_EXPECTED_REJECTIONS ?? 0));
expect(edited).toBeGreaterThan(0);
}, 120_000);
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from "vitest";
import { RARITY_COLORS, RARITY_NAMES, RARITY_UNKNOWN, rarityColor, rarityName } from "../src/rarity";
test("已配置的稀有度返回名称与颜色", () => {
expect(rarityName(0)).toBe("normal");
expect(rarityName(4)).toBe("epic");
expect(rarityName(6)).toBe("legendary");
expect(rarityName(7)).toBe("mythic");
expect(rarityName(8)).toBe("primordial");
expect(rarityColor(4)).toBe(RARITY_COLORS.epic);
expect(rarityColor(0)).toBe(RARITY_COLORS.normal);
expect(rarityColor(7)).toBe(RARITY_COLORS.mythic);
expect(rarityColor(8)).toBe(RARITY_COLORS.primordial);
});
test("缺少 [rarity] 或越界时回退为未知,调用方自行决定默认色", () => {
for (const unknown of [RARITY_UNKNOWN, -2, RARITY_NAMES.length, 4.5, Number.NaN, undefined, null]) {
expect(rarityName(unknown)).toBeNull();
expect(rarityColor(unknown)).toBeUndefined();
}
});
test("每个稀有度都有独立配色", () => {
const colors = RARITY_NAMES.map((name) => RARITY_COLORS[name]);
expect(new Set(colors).size).toBe(RARITY_NAMES.length);
});
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { editSafeUpgradeBatch, safeUpgradeRows, upgradeRange } from "../src/gui/upgrade";
import type { SafeUpgradeRow } from "../bindings/pvfine/services/models";
function fixture() {
const lines = ["0 7 0 0 9 10327281 40 124448", "1 7 20000 5000 9 10327281 41 124448", "2 7 94000 1000 9 10327281 1529 4626036"];
const prefix = "# 😀\n[table]\n[table type]\n`normal` 99\n[/table]\n[safe upgrade]\n";
const suffix = "\n[/safe upgrade]\n[other]\n88";
const normalized = prefix + lines.join("\n") + suffix;
let offset = prefix.length;
const raw: SafeUpgradeRow[] = lines.map(line => {
const fields = [...line.matchAll(/\d+/g)].map(match => ({ value: Number(match[0]), start: offset + match.index!, end: offset + match.index! + match[0].length, tokenType: 0 }));
offset += line.length + 1;
return { fields, costName: "炉岩核" } as SafeUpgradeRow;
});
return { text: normalized.replaceAll("\n", "\r\n"), lines, prefix, suffix, rows: safeUpgradeRows(raw) };
}
describe("安全强化编辑", () => {
it("允许单条修改失败率,按 100000 分母转换,保留隐藏字段与原始等级", () => {
const { text, rows } = fixture();
expect(rows.map(row => row.level)).toEqual([1, 2, 3]);
expect(rows[0]?.costName).toBe("炉岩核");
expect(editSafeUpgradeBatch(text, [rows[2]!], [{ column: 0, value: 87.501 }])).toBe(text.replace("2 7 94000 1000 9", "2 7 87501 1000 9"));
expect(editSafeUpgradeBatch(text, [rows[2]!], [{ column: 0, value: 100 }])).toContain("2 7 100000 1000 9");
expect(editSafeUpgradeBatch(text, [rows[2]!], [{ column: 0, value: 0 }])).toContain("2 7 0 1000 9");
});
it("显示 +1 对应原始等级 0,批量修改不偏移记录且保留原始等级、隐藏字段", () => {
const { text, rows, lines, prefix, suffix } = fixture();
const targets = upgradeRange(rows, [1, 2]);
expect(targets).toHaveLength(2);
const result = editSafeUpgradeBatch(text, targets, [{ column: 0, value: 50 }, { column: 1, value: 2000 }, { column: 2, value: 100 }, { column: 3, value: 55 }, { column: 4, value: 99 }]);
expect(result).toBe((prefix + ["0 7 50000 2000 9 55 99 100", "1 7 50000 2000 9 55 99 100", lines[2]].join("\n") + suffix).replaceAll("\n", "\r\n"));
});
it("拒绝过期数据、越界、重复定位与小数材料数量", () => {
const { text, rows } = fixture();
expect(() => editSafeUpgradeBatch(text, rows, [{ column: 0, value: 101 }])).toThrow();
expect(() => editSafeUpgradeBatch(text, rows, [{ column: 4, value: 1.2 }])).toThrow();
expect(() => editSafeUpgradeBatch(text, rows, [{ column: 2, value: 2147483648 }])).toThrow();
expect(() => editSafeUpgradeBatch(text.replace("94000", "93000"), [rows[2]!], [{ column: 0, value: 20 }])).toThrow("草稿已变化");
expect(() => editSafeUpgradeBatch(text, rows, [{ column: 1, value: 10 }, { column: 1, value: 20 }])).toThrow("重叠");
});
});
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { sectionAt } from "../src/sectionContext";
describe("section context", () => {
it("uses the same name for opening and closing tags", () => {
const text = "[name]\n`value`\n[/name]";
expect(sectionAt(text, 2)).toBe("name");
expect(sectionAt(text, text.indexOf("[/") + 2)).toBe("name");
expect(sectionAt(text, text.indexOf("value"))).toBeNull();
});
it("excludes comments, multiline strings and escaped backticks", () => {
const text = "# [fake]\n`start\n[fake] `` [also fake]\nend`\n[real]";
expect(sectionAt(text, text.indexOf("[fake]") + 1)).toBeNull();
expect(sectionAt(text, text.lastIndexOf("[fake]") + 1)).toBeNull();
expect(sectionAt(text, text.indexOf("[also") + 1)).toBeNull();
expect(sectionAt(text, text.indexOf("[real]") + 1)).toBe("real");
});
it("ignores incomplete tags and normalizes whitespace", () => {
expect(sectionAt("[", 0)).toBeNull();
expect(sectionAt("[/ name ]", 3)).toBe("name");
expect(sectionAt("[ ]", 1)).toBeNull();
});
});
+72
View File
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
const mockSettingsService = vi.hoisted(() => ({
GetSettings: vi.fn().mockResolvedValue({}),
SaveSettings: vi.fn().mockResolvedValue({}),
}));
vi.mock("@wailsio/runtime", () => ({
Events: { On: vi.fn(), Emit: vi.fn() },
Browser: { OpenURL: vi.fn().mockResolvedValue(undefined) },
}));
vi.mock("../bindings/pvfine/services", () => ({
SettingsService: mockSettingsService,
UpdateService: {
Version: vi.fn().mockResolvedValue("1.0.0"),
CheckForUpdates: vi.fn().mockResolvedValue(undefined),
},
}));
import { useSettingsStore, type SettingsTab } from "../src/stores/settings";
function formatVersionLabel(rawVersion: string): string {
const raw = rawVersion.trim().replace(/^v/i, "");
if (!raw) return "";
return raw === "dev" ? raw : `v${raw}`;
}
describe("设置-关于页面与配置", () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it("支持切换至关于标签页", () => {
const settings = useSettingsStore();
expect(settings.activeTab).toBe("general");
settings.open("about");
expect(settings.activeTab).toBe("about");
expect(settings.visible).toBe(true);
const validTabs: SettingsTab[] = [
"general",
"editor",
"npk",
"shortcuts",
"system",
"about",
];
expect(validTabs).toContain("about");
});
it("正确格式化版本号标签", () => {
expect(formatVersionLabel("1.0.0")).toBe("v1.0.0");
expect(formatVersionLabel("v1.0.0")).toBe("v1.0.0");
expect(formatVersionLabel("V2.3.4")).toBe("v2.3.4");
expect(formatVersionLabel("dev")).toBe("dev");
expect(formatVersionLabel("vdev")).toBe("dev");
expect(formatVersionLabel("")).toBe("");
expect(formatVersionLabel(" ")).toBe("");
});
it("包含正确的官网与 GitHub 地址配置", () => {
const officialSite = "https://pvfineapp.com/";
const githubRepo = "https://github.com/dof-dev/pvfine";
expect(officialSite).toMatch(/^https:\/\/pvfineapp\.com\/?$/);
expect(githubRepo).toMatch(/^https:\/\/github\.com\/dof-dev\/pvfine\/?$/);
});
});
+21
View File
@@ -0,0 +1,21 @@
import { expect, test } from "vitest";
import { costForm, materialRow, validateCosts, materialInputs } from "../src/gui/shopForm";
import type { ShopItem } from "../bindings/pvfine/services/models";
test("载入新商品成本保留金币和所有材料,空值与零价格不同", () => {
const item = { costs: [{ kind: "gold", quantity: "0" }, { kind: "material", itemId: "2", quantity: "10", name: "材料" }] } as ShopItem;
const form = costForm(item);
expect(form.gold).toBe("0");
expect(materialInputs(form)).toEqual([{ itemId: "2", quantity: "10" }]);
expect(costForm().gold).toBe("");
expect(costForm().materials).toEqual([]);
expect(form.materials[0].key).not.toBe(costForm(item).materials[0].key);
});
test("批量只验证选中成本;拦截非法数量但允许明确清除成本", () => {
const form = { gold: "-1", materials: [materialRow("", "abc")] };
expect(validateCosts(form, false, false)).toBe("");
expect(validateCosts(form, true, false)).toContain("金币");
expect(validateCosts(form, false, true)).toContain("兑换道具");
expect(validateCosts({ gold: "", materials: [] }, true, true)).toBe("");
expect(validateCosts({ gold: "2147483648", materials: [] }, true, true)).not.toBe("");
});
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, it, vi } from "vitest";
import {
assignBinding,
bindingFromEvent,
commandForBinding,
dispatchShortcut,
effectiveBinding,
formatBinding,
isValidShortcutBinding,
reservedBindingReason,
} from "../src/shortcuts";
function keyEvent(code: string, options: Partial<KeyboardEvent> = {}): KeyboardEvent {
return {
code,
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
defaultPrevented: false,
isComposing: false,
repeat: false,
preventDefault: vi.fn(),
...options,
} as KeyboardEvent;
}
describe("应用快捷键", () => {
it("将不同平台的主修饰键规范为 Mod 并使用物理键码", () => {
expect(bindingFromEvent(keyEvent("KeyS", { metaKey: true, shiftKey: true }), true)).toBe("Mod+Shift+KeyS");
expect(bindingFromEvent(keyEvent("KeyS", { ctrlKey: true, shiftKey: true }), false)).toBe("Mod+Shift+KeyS");
expect(bindingFromEvent(keyEvent("Backslash", { ctrlKey: true }), false)).toBe("Mod+Backslash");
expect(formatBinding("Mod+Backslash", true)).toMatch(/^⌘ /);
expect(formatBinding("Mod+KeyO", false)).toBe("Ctrl+O");
});
it("提供新增应用命令的默认键位", () => {
const defaults = {
"search.advanced": "Mod+Shift+KeyF",
"settings.open": "Mod+Comma",
"editor.closeOthers": "Mod+Shift+KeyW",
"editor.closeAll": "Mod+Alt+KeyW",
} as const;
for (const [command, binding] of Object.entries(defaults)) {
expect(effectiveBinding(command as keyof typeof defaults, {})).toBe(binding);
expect(commandForBinding(binding, {})).toBe(command);
}
expect(effectiveBinding("version.open", {})).toBe("");
expect(bindingFromEvent(keyEvent("Comma", { metaKey: true }), true)).toBe("Mod+Comma");
});
it("拒绝裸字母、仅修饰键和系统或编辑器保留键", () => {
expect(bindingFromEvent(keyEvent("KeyA"), false)).toBeNull();
expect(bindingFromEvent(keyEvent("KeyA", { shiftKey: true }), false)).toBeNull();
expect(bindingFromEvent(keyEvent("ControlLeft", { ctrlKey: true }), false)).toBeNull();
expect(isValidShortcutBinding("Alt+Tab")).toBe(false);
expect(isValidShortcutBinding("F5")).toBe(true);
expect(isValidShortcutBinding("Shift+F5")).toBe(true);
expect(reservedBindingReason("Mod+KeyQ")).toMatch(/系统/);
expect(reservedBindingReason("Alt+ArrowLeft")).toMatch(/编辑器/);
expect(reservedBindingReason("Mod+Shift+KeyG")).toMatch(/编辑器/);
});
it("确认替换时解绑旧命令,清除和恢复默认有确定语义", () => {
const assigned = assignBinding("settings.open", "Mod+KeyO", {});
expect(assigned.displaced).toBe("archive.open");
expect(assigned.overrides["archive.open"]).toBe("");
expect(commandForBinding("Mod+KeyO", assigned.overrides)).toBe("settings.open");
const cleared = assignBinding("settings.open", "", assigned.overrides);
expect(effectiveBinding("settings.open", cleared.overrides)).toBe("");
const restored = assignBinding("archive.open", "Mod+KeyO", cleared.overrides);
expect(restored.overrides["archive.open"]).toBeUndefined();
expect(effectiveBinding("archive.open", restored.overrides)).toBe("Mod+KeyO");
});
it("弹窗、已处理的编辑器事件和不可用命令优先于应用分发", () => {
const execute = vi.fn();
const event = keyEvent("KeyO", { ctrlKey: true });
expect(dispatchShortcut(event, {}, true, execute, undefined, undefined, false)).toBeNull();
expect(dispatchShortcut(keyEvent("KeyO", { ctrlKey: true, defaultPrevented: true }), {}, false, execute, undefined, undefined, false)).toBeNull();
expect(dispatchShortcut(event, {}, false, execute, undefined, () => false, false)).toBeNull();
expect(execute).not.toHaveBeenCalled();
expect(dispatchShortcut(event, {}, false, execute, undefined, () => true, false)).toBe("archive.open");
expect(event.preventDefault).toHaveBeenCalledOnce();
expect(execute).toHaveBeenCalledWith("archive.open");
});
it("关闭其他和关闭全部标签的组合键分发到不同命令", () => {
const execute = vi.fn();
expect(dispatchShortcut(keyEvent("KeyW", { ctrlKey: true, shiftKey: true }), {}, false, execute, undefined, undefined, false)).toBe("editor.closeOthers");
expect(dispatchShortcut(keyEvent("KeyW", { ctrlKey: true, altKey: true }), {}, false, execute, undefined, undefined, false)).toBe("editor.closeAll");
expect(execute.mock.calls.map(([command]) => command)).toEqual(["editor.closeOthers", "editor.closeAll"]);
});
it("workspace.save 快捷键异常拦截:saveActiveTab 抛出错误时捕获并路由至消息提示", async () => {
const errorMessages: string[] = [];
const mockMessageApi = {
error: (msg: string) => errorMessages.push(msg),
};
// 模拟 saveActiveTab 抛出 GUI 未应用修改错误
const saveActiveTab = vi.fn().mockRejectedValue(
new Error("当前文件存在未应用的界面修改,请先在界面中点击【应用修改】后再保存"),
);
function executeSaveShortcut() {
saveActiveTab().catch((err: any) => {
mockMessageApi.error(err?.message ?? String(err));
});
}
const event = keyEvent("KeyS", { ctrlKey: true });
const dispatched = dispatchShortcut(
event,
{},
false,
(cmd) => {
if (cmd === "workspace.save") executeSaveShortcut();
},
undefined,
() => true,
false,
);
expect(dispatched).toBe("workspace.save");
expect(saveActiveTab).toHaveBeenCalledOnce();
// 等待微任务异步捕获完成
await Promise.resolve();
expect(errorMessages).toEqual([
"当前文件存在未应用的界面修改,请先在界面中点击【应用修改】后再保存",
]);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { nextTick } from "vue";
const api = vi.hoisted(() => ({
GetFileBasic: vi.fn(), GetFile: vi.fn(), SetText: vi.fn(), GetAnnotations: vi.fn(),
Save: vi.fn(), ResolveFiles: vi.fn(), refreshInfo: vi.fn(), showArchiveEditor: vi.fn(),
events: new Map<string, Array<() => void>>(),
}));
vi.mock("@wailsio/runtime", () => ({ Events: { On: (name: string, fn: () => void) => {
api.events.set(name, [...(api.events.get(name) ?? []), fn]);
} } }));
vi.mock("../bindings/pvfine/services", () => ({ EditorService: api, ArchiveService: api, FileGUIService: {} }));
vi.mock("../src/stores/archive", () => ({ useArchiveStore: () => ({ refreshInfo: api.refreshInfo }) }));
vi.mock("../src/stores/script", () => ({ useScriptStore: () => api }));
vi.mock("../src/stores/explorer", () => ({ useExplorerStore: () => ({ getFilePath: (index: number) => `skill/test/${index}.skl`, refreshTreeTags: vi.fn() }) }));
import { useEditorStore } from "../src/stores/editor";
function meta(index: number, text = "original", path = `skill/test/${index}.skl`) {
return { index, path, text, editable: true, dataType: 1, size: text.length, tags: [], annotations: [], modified: false };
}
beforeEach(() => {
setActivePinia(createPinia());
vi.resetAllMocks();
api.events.clear();
api.GetFileBasic.mockImplementation(async (index) => meta(index));
api.GetFile.mockResolvedValue(null);
api.GetAnnotations.mockResolvedValue([]);
api.SetText.mockResolvedValue(undefined);
});
describe("通用技能 GUI 独立草稿", () => {
it("浏览超过 20 个技能也不创建标签,切换和关闭 GUI 后仍保留草稿", async () => {
const editor = useEditorStore();
for (let index = 1; index <= 25; index++) await editor.openGUIFile(index);
editor.updateContent(1, "modified");
editor.closeAllTabs();
expect((await editor.openGUIFile(1)).text).toBe("modified");
expect(editor.tabs).toHaveLength(0);
expect(editor.activeKey).toBeNull();
expect(api.showArchiveEditor).not.toHaveBeenCalled();
expect(editor.dirtyCount).toBe(1);
expect(await editor.saveFileTab(1)).toBe(true);
expect(api.SetText).toHaveBeenCalledExactlyOnceWith(1, "modified");
expect(editor.tabs).toHaveLength(0);
expect(editor.dirtyCount).toBe(0);
});
it("显式打开编辑器才创建标签,共用原草稿并避免重复保存", async () => {
const editor = useEditorStore();
const draft = await editor.openGUIFile(1);
editor.updateContent(1, "GUI draft");
await editor.openFile(1);
expect(editor.tabs).toHaveLength(1);
expect(editor.tabs[0]).toBe(draft);
expect(editor.activeTab?.text).toBe("GUI draft");
editor.updateContent(1, "text draft");
expect(await editor.openGUIFile(1)).toBe(draft);
expect(draft.text).toBe("text draft");
expect(editor.dirtyCount).toBe(1);
expect(await editor.saveAllDirty()).toBe(1);
expect(api.SetText).toHaveBeenCalledTimes(1);
expect(api.GetFileBasic).toHaveBeenCalledTimes(1);
});
it("主工具栏保存包含独立草稿,写入失败时保留草稿与脏状态", async () => {
const editor = useEditorStore();
await editor.openGUIFile(1);
await editor.openGUIFile(2);
editor.updateContent(1, "first");
editor.updateContent(2, "second");
api.SetText.mockRejectedValueOnce(new Error("write failed"));
await expect(editor.saveFileTab(1)).rejects.toThrow("write failed");
expect(editor.getFileDraft(1)?.original).toBe("original");
expect(editor.dirtyCount).toBe(2);
expect(editor.saving).toBe(false);
api.Save.mockResolvedValue({ modifiedCount: 0 });
await editor.save();
expect(api.SetText).toHaveBeenCalledWith(1, "first");
expect(api.SetText).toHaveBeenCalledWith(2, "second");
expect(api.Save).toHaveBeenCalledOnce();
expect(editor.dirtyCount).toBe(0);
expect(editor.tabs).toHaveLength(0);
});
it("文件索引变化时按路径重绑定,保存使用新索引且保留草稿", async () => {
const editor = useEditorStore();
await editor.openGUIFile(1);
editor.updateContent(1, "draft");
api.ResolveFiles.mockResolvedValue([{ path: "skill/test/1.skl", fileIndex: 100, size: 8, dataType: 1, tags: [], isDir: false }]);
await editor.refreshAfterArchiveChange();
expect(editor.getFileDraft("skill/test/1.skl")?.index).toBe(100);
expect(editor.getFileDraft(1)).toBeUndefined();
expect(editor.tabs).toHaveLength(0);
await editor.saveFileTab(100);
expect(api.SetText).toHaveBeenCalledExactlyOnceWith(100, "draft");
});
it("关闭归档清空草稿,并拒绝跨归档的慢请求", async () => {
let resolve!: (value: ReturnType<typeof meta>) => void;
api.GetFileBasic.mockReturnValue(new Promise((done) => { resolve = done; }));
const editor = useEditorStore();
const pending = editor.openGUIFile(1);
await nextTick();
for (const fn of api.events.get("archive:closed") ?? []) fn();
resolve(meta(1));
await expect(pending).rejects.toThrow("归档已切换");
expect(editor.getFileDraft(1)).toBeUndefined();
expect(editor.dirtyCount).toBe(0);
expect(editor.tabs).toHaveLength(0);
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { editSkillNumbers, skillPreview } from "../src/gui/skill";
import type { SkillMode, SkillNumber, SkillProperty } from "../bindings/pvfine/services/models";
const number = (value: number, start = 0, end = 1, tokenType = 0): SkillNumber => ({ value, start, end, tokenType });
const mode: SkillMode = { id: "dungeon", width: 2, static: [number(10000)], levels: [[number(50), number(123)], [number(100), number(456)]], properties: [], issues: [] };
const property: SkillProperty = { template: "时间<int>秒,伤害<int>\n减速<float2>%% / <float1>%%", bindings: [
{ dynamic: false, index: 0, multiplier: .001 }, { dynamic: true, index: 0, multiplier: 2 },
{ dynamic: true, index: 1, multiplier: .1 }, { dynamic: true, index: 1, multiplier: .01 },
] };
describe("技能描述与数据编辑", () => {
it("引用模板标准化后渲染整型、小数、多行和可编辑绑定", () => {
// ReadSkill normalizes quorum / decimalN aliases to this shared contract.
const normalized: SkillProperty = { ...property, template: "时间<int>秒,伤害<int>\n减速<float1>%% / <float3>%%" };
const preview = skillPreview(normalized, mode, 2);
expect(preview.map((p) => p.text).join("")).toBe("时间10秒,伤害200\n减速45.6% / 4.560%");
expect(preview.filter((p) => p.token).map((p) => p.binding?.index)).toEqual([0, 0, 1, 1]);
});
it("渲染多行、多个占位符、倍率、百分号和等级", () => {
expect(skillPreview(property, mode, 1).map((p) => p.text).join("")).toBe("时间10秒,伤害100\n减速12.30% / 1.2%");
expect(skillPreview(property, mode, 2).map((p) => p.text).join("")).toContain("伤害200\n减速45.60%");
expect(skillPreview(property, mode, 2).filter((p) => p.token)).toHaveLength(4);
});
it("缺失绑定或索引时保留文本并禁用占位符", () => {
const preview = skillPreview({ template: "伤害<int>", bindings: [{ dynamic: true, index: 9, multiplier: 1 }] }, mode, 1);
expect(preview[1]?.token).toBeUndefined();
expect(preview[1]?.text).toBe("(无对应数据)");
});
it("批量替换同一列,仅修改目标数值并保留格式", () => {
const text = "[level info]\n2\n10 20 # first\n30 40\n[/level info]";
const tokens = [number(10, text.indexOf("10"), text.indexOf("10") + 2), number(30, text.indexOf("30"), text.indexOf("30") + 2)];
expect(editSkillNumbers(text, tokens, "set", 99)).toBe(text.replace("10", "99").replace("30", "99"));
expect(editSkillNumbers(text, tokens, "add", 5)).toBe(text.replace("10", "15").replace("30", "35"));
expect(editSkillNumbers(text, tokens, "multiply", 1.25)).toBe(text.replace("10", "13").replace("30", "38"));
});
it("保留浮点类型、UTF-16 定位和 CRLF", () => {
const text = "`😀`\r\n1.5\r\n2";
expect(editSkillNumbers(text, [number(1.5, 5, 8, 2)], "set", 2)).toBe("`😀`\r\n2.0\r\n2");
});
it("拒绝过期定位、溢出和重叠,避免部分写入", () => {
expect(() => editSkillNumbers("99", [number(10, 0, 2)], "set", 5)).toThrow("草稿已变化");
expect(() => editSkillNumbers("1", [number(1)], "set", 2147483648)).toThrow("32 位范围");
expect(() => editSkillNumbers("1", [number(1, 0, 1, 2)], "multiply", 1e40)).toThrow("32 位范围");
expect(() => editSkillNumbers("1", [number(1), number(1)], "add", 1)).toThrow("重叠");
expect(() => editSkillNumbers("1", [number(1)], "set", Infinity)).toThrow();
});
});
@@ -0,0 +1,61 @@
import { beforeEach, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { effectScope, toRef } from "vue";
const events = vi.hoisted(() => new Map<string, () => void>());
vi.mock("@wailsio/runtime", () => ({ Events: { On: (name: string, handler: () => void) => events.set(name, handler) } }));
import { skillTreeCellNode, skillTreeCellSelectionKey } from "../src/gui/skillTree";
import type { SkillTreeNode } from "../bindings/pvfine/services/models";
import { useFileGUIStore } from "../src/stores/fileGUI";
beforeEach(() => { events.clear(); setActivePinia(createPinia()); });
it("弹窗组件销毁后,重新创建的选择引用仍恢复职业、转职和SP/TP", () => {
const first = effectScope();
first.run(() => {
const gui = useFileGUIStore();
toRef(gui.skillTreeSelection, "job").value = "[swordman]";
toRef(gui.skillTreeSelection, "branch").value = "weaponmaster";
toRef(gui.skillTreeSelection, "kind").value = "tp";
});
first.stop();
const reopened = effectScope();
reopened.run(() => {
expect(useFileGUIStore().skillTreeSelection).toEqual({job: "[swordman]", branch: "weaponmaster", kind: "tp"});
});
reopened.stop();
});
it("各位置独立记忆,切换转职和重开弹窗后恢复,其他技能树不串用", () => {
const cell = {key: "235:938", nodes: [319, 112].map(id => ({id}) as SkillTreeNode)};
const key = skillTreeCellSelectionKey("clientonly/skilltree/swordman_sp.co", "[swordman]", "sp", "weaponmaster", cell.key);
const second = {...cell, key: "329:938"};
const secondKey = skillTreeCellSelectionKey("clientonly/skilltree/swordman_sp.co", "[swordman]", "sp", "weaponmaster", second.key);
const first = effectScope();
first.run(() => {
const gui = useFileGUIStore();
gui.skillTreeCellSelections[key] = 319;
gui.skillTreeCellSelections[secondKey] = 112;
gui.skillTreeSelection.branch = "asura";
});
first.stop();
const reopened = effectScope();
reopened.run(() => {
const gui = useFileGUIStore();
gui.skillTreeSelection.branch = "weaponmaster";
// Reloading creates new node objects; restore by ID rather than reference.
const reloaded = {...cell, nodes: cell.nodes.map(n => ({...n}))};
expect(skillTreeCellNode(reloaded, gui.skillTreeCellSelections[key]).id).toBe(319);
expect(skillTreeCellNode(second, gui.skillTreeCellSelections[secondKey]).id).toBe(112);
for (const context of [
["other.co", "[swordman]", "sp", "weaponmaster"],
["clientonly/skilltree/swordman_sp.co", "[mage]", "sp", "weaponmaster"],
["clientonly/skilltree/swordman_sp.co", "[swordman]", "tp", "weaponmaster"],
["clientonly/skilltree/swordman_sp.co", "[swordman]", "sp", "asura"],
]) {
const otherKey = skillTreeCellSelectionKey(context[0]!, context[1]!, context[2]!, context[3]!, cell.key);
expect(skillTreeCellNode(cell, gui.skillTreeCellSelections[otherKey]).id).toBe(112);
}
events.get("archive:opened")!();
expect(skillTreeCellNode(cell, gui.skillTreeCellSelections[key]).id).toBe(112);
});
reopened.stop();
});
+210
View File
@@ -0,0 +1,210 @@
import { describe, expect, it } from "vitest";
import { skillTreeEdge, skillTreeEdgeLayout, groupSkillTreeCells, skillTreeRowLevels, skillTreeCellNode, skillTreeDropTarget, moveSkillTreeCell, skillTreeCanvasHeight, addSkillTreeNode, removeSkillTreeNode, editSkillTreeLink } from "../src/gui/skillTree";
import type { SkillTreeNode } from "../bindings/pvfine/services/models";
const node = (x: number, y: number): SkillTreeNode => ({id: 1, sourceStart: 0, sourceClose: -1, nextTokens: [], positionTokens: [], x, y, name: "测试", explain: "", positionKind: "icon pos", position: [x, y], next: [], path: "", fileIndex: -1, icon: null, requiredLevel: null});
describe("技能树连线", () => {
it("端点与实际路径一致,同位置关联绕开卡片,仍可选中编辑", () => {
expect(skillTreeEdgeLayout(node(0, 0), node(94, 0)).end).toEqual({x: 90, y: 16});
expect(skillTreeEdgeLayout(node(0, 0), node(0, 134)).end).toEqual({x: 16, y: 131});
expect(skillTreeEdgeLayout(node(47, 67), node(47, 67))).toEqual({path: "M 81 83 H 91 V 57 H 63 V 64", end: {x: 63, y: 64}});
});
it("横向连线停在目标图标前,反向关系也保留箭头方向", () => {
expect(skillTreeEdge(node(0, 0), node(94, 0))).toBe("M 34 16 H 62 V 16 H 90");
expect(skillTreeEdge(node(94, 0), node(0, 0))).toBe("M 92 16 H 64 V 16 H 36");
});
it("纵向跨行连接保留像素偏移,并绕开图标中心", () => {
expect(skillTreeEdge(node(23, 0), node(47, 134))).toBe("M 39 40 V 85.5 H 63 V 131");
});
});
describe("同位置技能", () => {
it("保留坐标和文件顺序,同格技能可分别选择", () => {
const a = {...node(235, 938), id: 319};
const b = {...node(235, 938), id: 112};
const c = {...node(329, 938), id: 113};
const cells = groupSkillTreeCells([a, c, b]);
expect(cells.map(cell => cell.nodes.map(n => n.id))).toEqual([[319, 112], [113]]);
expect(cells[0]!.nodes[1]).toBe(b);
expect([b.x, b.y]).toEqual([235, 938]);
});
});
describe("技能树行等级", () => {
it("SP 按最左侧技能取学习等级,同坐标按文件顺序,不随所选技能变化", () => {
const nodes = [
{...node(94, 0), requiredLevel: 20},
{...node(23, 0), requiredLevel: 5},
{...node(23, 0), requiredLevel: 10},
{...node(0, 134), requiredLevel: 0},
{...node(0, 201), requiredLevel: null},
];
const levels = skillTreeRowLevels(nodes, "sp");
expect(levels.get(0)).toBe(5);
expect(levels.has(1)).toBe(false);
expect(levels.get(2)).toBe(0);
expect(levels.get(3)).toBeNull();
});
it("TP 不产生等级标注", () => {
expect(skillTreeRowLevels([{...node(0, 0), requiredLevel: 50}], "tp").size).toBe(0);
});
});
describe("同位置技能默认显示", () => {
it("默认取文件中最后一个技能;记忆有效则恢复,技能已移除则回退到最后一个", () => {
const cell = {key: "0:0", nodes: [{...node(0, 0), id: 0}, {...node(0, 0), id: 112}, {...node(0, 0), id: 319}]};
expect(skillTreeCellNode(cell).id).toBe(319);
expect(skillTreeCellNode(cell, 0).id).toBe(0);
expect(skillTreeCellNode(cell, 112).id).toBe(112);
expect(skillTreeCellNode(cell, 999).id).toBe(319);
});
});
describe("技能树拖拽", () => {
it("吸附47×67网格,拒绝边界、已占用位置及半格图标的交叠", () => {
const a = {...node(0, 0), sourceStart: 1};
const b = {...node(94, 67), sourceStart: 2};
const half = {...node(23, 134), sourceStart: 3};
const cell = {key: "0:0", nodes: [a]};
const nodes = [a, b, half];
expect(skillTreeDropTarget(nodes, cell, 46, 66, 564, 268)).toMatchObject({x: 47, y: 67, valid: true});
expect(skillTreeDropTarget(nodes, cell, 94, 67, 564, 268).valid).toBe(false);
expect(skillTreeDropTarget(nodes, cell, 0, 134, 564, 268).valid).toBe(false);
expect(skillTreeDropTarget(nodes, cell, 47, 134, 564, 268).valid).toBe(false);
expect(skillTreeDropTarget(nodes, cell, -47, 0, 564, 268).valid).toBe(false);
expect(skillTreeDropTarget(nodes, cell, 564, 0, 564, 268).valid).toBe(false);
expect(skillTreeDropTarget(nodes, cell, 0, 268, 564, 268).valid).toBe(false);
});
it("整组移动混合坐标,仅改数字,保留关联、CRLF、emoji及其他分支", () => {
const text = "// 😀\r\n[character job]\r\n`[swordman]` `none`\r\n[skill info]\r\n[index]\r\n1\r\n[cell pos]\r\n2\t3 // 坐标\r\n[next skill]\r\n9\r\n[/skill info]\r\n[skill info]\r\n[index]\r\n2\r\n[icon pos]\r\n94 201\r\n[/skill info]\r\n[/character job]\r\n[character job]\r\n`[mage]` `none`\r\n[skill info]\r\n[index]\r\n1\r\n[cell pos]\r\n2\t3\r\n[/skill info]\r\n[/character job]";
const normalized = text.replaceAll("\r\n", "\n");
const tokens = (raw: string, values: number[]) => {
const start = normalized.indexOf(raw);
return values.map((value, i) => ({value, start: start + (i ? String(values[0]).length + 1 : 0), end: start + (i ? String(values[0]).length + 1 : 0) + String(value).length, tokenType: 0}));
};
const a = {...node(94, 201), sourceStart: 1, positionKind: "cell pos", positionTokens: tokens("2\t3", [2, 3])};
const b = {...node(94, 201), sourceStart: 2, positionKind: "icon pos", positionTokens: tokens("94 201", [94, 201])};
const cell = {key: "94:201", nodes: [a, b]};
const result = moveSkillTreeCell(text, cell.nodes, cell, 47, 67, 564, 268);
expect(result).toBe(text.replace("2\t3 // 坐标", "1\t1 // 坐标").replace("94 201", "47 67"));
expect(() => moveSkillTreeCell(text.replace("94 201", "95 201"), cell.nodes, cell, 47, 67, 564, 268)).toThrow("已变化");
expect(() => moveSkillTreeCell(text, cell.nodes, cell, 23, 67, 564, 268)).toThrow("网格");
});
});
describe("技能树追加行", () => {
it("末尾始终有空行,移入后继续扩展,画布外下一行仍不可放置", () => {
const source = {...node(0, 201), sourceStart: 1};
const cell = {key: "0:201", nodes: [source]};
const height = skillTreeCanvasHeight([source]);
expect(height).toBe(335);
expect(skillTreeDropTarget([source], cell, 47, 268, 564, height).valid).toBe(true);
expect(skillTreeDropTarget([source], cell, 47, 335, 564, height).valid).toBe(false);
expect(skillTreeCanvasHeight([{...source, y: 268}])).toBe(402);
expect(skillTreeCanvasHeight([])).toBe(268);
});
it("像素坐标有纵向偏移时,新增行仍完整留空", () => {
const height = skillTreeCanvasHeight([node(23, 220)]);
expect(height).toBe(402);
expect(height - 67).toBeGreaterThan(220 + 56);
});
});
describe("点击空白格新增技能", () => {
it("只在当前分支结束前插入,保留CRLF、emoji、其他分支及原有注释", () => {
const text = "// 😀\r\n[character job]\r\n`[mage]` `none`\r\n// 保留\r\n[/character job]\r\n[character job]\r\n`[swordman]` `none`\r\n[/character job]";
const branch = {job: "[mage]", name: "none", nodes: [], insertOffset: text.replaceAll("\r\n", "\n").indexOf("[/character job]")};
const result = addSkillTreeNode(text, branch, 0, 94, 201, 564, 335);
expect(result).toBe(text.replace("// 保留\r\n", "// 保留\r\n\t[skill info]\r\n\t\t[index]\r\n\t\t\t0\r\n\t\t[cell pos]\r\n\t\t\t2\t3\r\n\t[/skill info]\r\n"));
});
it("拒绝重复ID、占用位置、缺失结束标签和失效插入位置", () => {
const text = "[character job]\n`[mage]` `none`\n[/character job]";
const existing = {...node(0, 0), id: 5, sourceStart: 20};
const branch = {job: "[mage]", name: "none", nodes: [existing], insertOffset: text.indexOf("[/character job]")};
expect(() => addSkillTreeNode(text, branch, 5, 47, 0, 564, 268)).toThrow("已包含");
expect(() => addSkillTreeNode(text, branch, 7, 0, 0, 564, 268)).toThrow("占用");
expect(() => addSkillTreeNode(text, {...branch, insertOffset: -1}, 7, 47, 0, 564, 268)).toThrow("结束标签");
expect(() => addSkillTreeNode("// 变化\n" + text, branch, 7, 47, 0, 564, 268)).toThrow("已变化");
});
});
describe("技能树删除", () => {
const fixture = () => {
const record = (id: number, next = "") => `[skill info]\n[index]\n${id}\n[cell pos]\n0 0\n${next}[/skill info]\n`;
const first = record(7, "// [/skill info] 😀\n");
const second = record(8, "[next skill]\n7 9 7 // 保留注释\n");
const other = record(7);
const text = `[character job]\n\`[swordman]\` \`none\`\n${first}${second}[/character job]\n[character job]\n\`[swordman]\` \`asura\`\n${other}[/character job]\n`;
const a = {...node(0, 0), id: 7, sourceStart: text.indexOf(first), sourceClose: text.indexOf("[/skill info]", text.indexOf("😀"))};
const linkStart = text.indexOf("7 9 7");
const b = {...node(0, 0), id: 8, sourceStart: text.indexOf(second), sourceClose: text.indexOf("[/skill info]", linkStart), next: [7, 9, 7], nextTokens: [0, 2, 4].map((offset, i) => ({value: i === 1 ? 9 : 7, start: linkStart + offset, end: linkStart + offset + 1, tokenType: 2}))};
return {text, first, second, a, b, branch: {job: "[swordman]", name: "none", insertOffset: -1, nodes: [a, b]}};
};
it("只移除选中记录及当前分支所有入边,保留重叠技能、注释、另一分支和CRLF", () => {
const {text, first, a, branch} = fixture();
const expected = text.replace(first, "").replace("7 9 7 //", " 9 //");
expect(removeSkillTreeNode(text, branch, a)).toBe(expected);
expect(removeSkillTreeNode(text.replaceAll("\n", "\r\n"), branch, a)).toBe(expected.replaceAll("\n", "\r\n"));
});
it("保留同行的其他文本、删除仅剩技能后允许空分支", () => {
const text = "[character job]\n`[swordman]` `none`\n[skill info][index]7[cell pos]0 0[/skill info] // 留下\n[/character job]";
const a = {...node(0, 0), id: 7, sourceStart: text.indexOf("[skill info]"), sourceClose: text.indexOf("[/skill info]")};
const branch = {job: "[swordman]", name: "none", insertOffset: -1, nodes: [a]};
expect(removeSkillTreeNode(text, branch, a)).toBe("[character job]\n`[swordman]` `none`\n // 留下\n[/character job]");
});
it("拒绝缺失关闭标签、过期关联及不属于分支的技能", () => {
const {text, a, branch} = fixture();
expect(() => removeSkillTreeNode(text, branch, {...a})).toThrow("技能已变化");
a.sourceClose = -1;
expect(() => removeSkillTreeNode(text, branch, a)).toThrow("不完整");
const fresh = fixture();
expect(() => removeSkillTreeNode(fresh.text.replace("7 9 7", "6 9 7"), fresh.branch, fresh.a)).toThrow("关联记录已变化");
fresh.b.nextTokens = [];
expect(() => removeSkillTreeNode(fresh.text, fresh.branch, fresh.a)).toThrow("关联记录不完整");
});
});
describe("技能树关联编辑", () => {
const fixture = () => {
const record = "[skill info]\n[index]\n1\n[cell pos]\n0 0\n[next skill]\n2 99 // 😀 顺序与注释保留\n[next skill]\n2\n[/skill info]";
const text = "// 😀\n[character job]\n`[swordman]` `none`\n" + record + "\n[/character job]\n";
const first = text.indexOf("2 99"), last = text.lastIndexOf("2\n");
const source = {...node(0, 0), sourceStart: text.indexOf("[skill info]"), sourceClose: text.indexOf("[/skill info]"), next: [2, 99, 2], nextTokens: [
{value: 2, start: first, end: first + 1, tokenType: 0},
{value: 99, start: first + 2, end: first + 4, tokenType: 0},
{value: 2, start: last, end: last + 1, tokenType: 0},
]};
const branch = {job: "[swordman]", name: "none", insertOffset: -1, nodes: [source, {...node(47, 0), id: 2}, {...node(94, 0), id: 3}]};
return {text, source, branch};
};
it("修改和删除按条目索引定位,保留重复关联、失效目标、注释与CRLF", () => {
const {text, source, branch} = fixture();
expect(editSkillTreeLink(text, branch, source, 2, 3)).toBe(text.replace("[next skill]\n2\n", "[next skill]\n3\n"));
expect(editSkillTreeLink(text, branch, source, 1, null)).toBe(text.replace("2 99 //", "2 //"));
expect(editSkillTreeLink(text.replaceAll("\n", "\r\n"), branch, source, 0, 3)).toBe(text.replace("2 99 //", "3 99 //").replaceAll("\n", "\r\n"));
});
it("新增追加到技能记录末尾,保留现有关联的顺序", () => {
const {text, source, branch} = fixture();
expect(editSkillTreeLink(text, branch, source, -1, 3)).toBe(text.replace("[/skill info]", "\t[next skill]\n\t\t3\n[/skill info]"));
const empty = {...source, next: [], nextTokens: []};
const emptyBranch = {...branch, nodes: [empty, ...branch.nodes.slice(1)]};
const blankText = text.replace(/2 99/g, " ").replace(/2\n/g, " \n");
expect(editSkillTreeLink(blankText, emptyBranch, empty, -1, 3)).toContain("\t[next skill]\n\t\t3\n[/skill info]");
});
it("没有 next skill 标签时可新增首条关联,且不改其他记录", () => {
const text = "[skill info][index]1[cell pos]0 0[/skill info]\n[skill info][index]2[cell pos]1 0[/skill info]";
const source = {...node(0, 0), sourceClose: text.indexOf("[/skill info]")};
const branch = {job: "[swordman]", name: "none", insertOffset: -1, nodes: [source, {...node(47, 0), id: 2}]};
expect(editSkillTreeLink(text, branch, source, -1, 2)).toBe(text.replace("0 0[/skill info]", "0 0\n\t[next skill]\n\t\t2\n[/skill info]"));
});
it("拒绝跨分支目标、自关联、重复新增、过期索引与过期文本", () => {
const {text, source, branch} = fixture();
expect(() => editSkillTreeLink(text, branch, source, -1, 2)).toThrow("已存在");
expect(() => editSkillTreeLink(text, branch, source, -1, 1)).toThrow("自身");
expect(() => editSkillTreeLink(text, branch, source, -1, 99)).toThrow("当前分支");
expect(() => editSkillTreeLink(text, branch, source, 3, null)).toThrow("位置已变化");
expect(() => editSkillTreeLink(text.replace("2 99", "3 99"), branch, source, 0, null)).toThrow("记录已变化");
source.sourceClose = -1;
expect(() => editSkillTreeLink(text, branch, source, -1, 3)).toThrow("不完整");
});
});
@@ -0,0 +1,47 @@
import { expect, test } from "vitest";
import { EditorState } from "@codemirror/state";
import { ensureSyntaxTree } from "@codemirror/language";
import { highlightTree, tags, tagHighlighter } from "@lezer/highlight";
import { archiveEditorLanguage } from "../src/luaLanguage";
import { squirrelLanguage } from "../src/squirrelLanguage";
test("归档中的 nut 后缀启用 Squirrel,兼容大小写并保留其他语言识别", () => {
expect(archiveEditorLanguage("scripts/example.nut")).toBe("squirrel");
expect(archiveEditorLanguage("scripts/example.NUT")).toBe("squirrel");
expect(archiveEditorLanguage("scripts/example.nut.bak")).toBe("pvf");
expect(archiveEditorLanguage("scripts/example.lua")).toBe("lua");
expect(archiveEditorLanguage("equipment/example.equ")).toBe("pvf");
});
test("Squirrel 高亮识别特有关键字、数字、转义字符串和跨行注释", () => {
const doc = [
"local count = 42;",
'local text = "hello\\\"world";',
"// single line comment",
"/* block",
"comment */",
"foreach (item in items) { yield clone item; }",
"return null;",
].join("\n");
const state = EditorState.create({ doc, extensions: squirrelLanguage.extension });
const tree = ensureSyntaxTree(state, doc.length, 1000);
expect(tree).not.toBeNull();
const highlighted: Record<string, string[]> = {};
highlightTree(tree!, tagHighlighter([
{ tag: tags.keyword, class: "keyword" },
{ tag: tags.number, class: "number" },
{ tag: tags.string, class: "string" },
{ tag: tags.comment, class: "comment" },
{ tag: tags.atom, class: "atom" },
]), (from, to, style) => {
(highlighted[style] ??= []).push(doc.slice(from, to));
});
expect(highlighted.keyword).toEqual(expect.arrayContaining([
"local", "foreach", "in", "yield", "clone", "return",
]));
expect(highlighted.number).toEqual(["42"]);
expect(highlighted.string).toContain('"hello\\\"world"');
expect(highlighted.comment).toContain("// single line comment");
expect(highlighted.comment?.join("\n")).toContain("/* block\ncomment */");
expect(highlighted.atom).toContain("null");
});
+32
View File
@@ -0,0 +1,32 @@
import { expect, test } from "vitest";
import { computeDiffLines } from "../src/textDiff";
const diff = (beforeText: string, afterText: string, operation = "modify") => computeDiffLines({ beforeText, afterText, operation, textAvailable: true });
test("统一 diff 包含首块头、正确的旧新行号和三行上下文", () => {
const before = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`);
const after = [...before]; after[4] = "changed first"; after[24] = "changed second";
const result = diff(before.join("\n"), after.join("\n"));
expect(result.addCount).toBe(2); expect(result.removeCount).toBe(2);
expect(result.lines.filter(line => line.kind === "hunk").map(line => line.text)).toEqual(["@@ -2,7 +2,7 @@", "@@ -22,7 +22,7 @@"]);
expect(result.lines.find(line => line.text === "changed second")).toMatchObject({ kind: "add", newLine: 25, oldLine: undefined });
});
test("新增、删除及空文件的行号和块头", () => {
expect(diff("", "one\ntwo", "add").lines[0].text).toBe("@@ -0,0 +1,2 @@");
expect(diff("one", "", "delete").lines[0].text).toBe("@@ -1,1 +0,0 @@");
expect(diff("", "", "add").lines).toEqual([]);
expect(diff("one", "one").lines).toEqual([]);
});
test("纯插入和删除使用正确的零长度旧新侧块头", () => {
expect(diff("a\nb", "a\nx\nb").lines[0].text).toBe("@@ -1,2 +1,3 @@");
expect(diff("a\nx\nb", "a\nb").lines[0].text).toBe("@@ -1,3 +1,2 @@");
});
test("大文件少量修改保持紧凑,输出最多 2000 行并明确截断", () => {
const before = Array.from({ length: 5000 }, (_, i) => `${i}`);
const after = [...before]; after[4000] = "edit";
expect(diff(before.join("\n"), after.join("\n")).lines.length).toBe(9);
const result = diff("", before.join("\n"), "add");
expect(result.truncated).toBe(true); expect(result.lines.length).toBe(2000); expect(result.addCount).toBe(5000);
});
test("换行风格相同化和不可用文本返回空 diff", () => {
expect(diff("a\r\nb\r", "a\nb\n").lines).toEqual([]);
expect(computeDiffLines(null).lines).toEqual([]);
});
+25
View File
@@ -0,0 +1,25 @@
import { beforeEach, expect, test, vi } from "vitest";
import { hasArchiveChanges, hasUnsavedWorkspaceChanges } from "../src/unsaved";
const state = vi.hoisted(() => ({
archive: { modifiedCount: 0 },
editor: { dirtyCount: 0 },
fileSets: { dirty: false },
script: { dirty: false, workspaceDetached: false, detachedDirty: false },
version: { status: { changedFiles: 0, needsSave: false } },
}));
vi.mock("../src/stores/archive", () => ({ useArchiveStore: () => state.archive }));
vi.mock("../src/stores/editor", () => ({ useEditorStore: () => state.editor }));
vi.mock("../src/stores/fileSets", () => ({ useFileSetStore: () => state.fileSets }));
vi.mock("../src/stores/script", () => ({ useScriptStore: () => state.script }));
vi.mock("../src/stores/version", () => ({ useVersionStore: () => state.version }));
beforeEach(() => { state.editor.dirtyCount = 0; });
test("editor dirty count protects unsaved drafts at workspace exit and archive backup", () => {
expect(hasUnsavedWorkspaceChanges()).toBe(false);
expect(hasArchiveChanges()).toBe(false);
state.editor.dirtyCount = 1;
expect(hasUnsavedWorkspaceChanges()).toBe(true);
expect(hasArchiveChanges()).toBe(true);
});
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, expect, test, vi } from "vitest";
import { createSSRApp } from "vue";
import { renderToString } from "@vue/server-renderer";
import UpdatePrompt from "../src/components/UpdatePrompt.vue";
const state = vi.hoisted(() => ({
listeners: new Map<string, (event: any) => void>(),
info: vi.fn(), emit: vi.fn().mockResolvedValue(undefined), error: vi.fn(),
}));
vi.mock("@wailsio/runtime", () => ({ Events: {
On: (name: string, callback: (event: any) => void) => {
state.listeners.set(name, callback);
return () => state.listeners.delete(name);
},
Emit: state.emit,
} }));
vi.mock("naive-ui", () => ({
useDialog: () => ({ info: state.info }),
useMessage: () => ({ info: vi.fn(), error: state.error }),
}));
beforeEach(async () => {
vi.clearAllMocks();
state.listeners.clear();
state.info.mockReturnValue({ destroy: vi.fn() });
await renderToString(createSSRApp(UpdatePrompt));
});
function requestUpdate() {
state.listeners.get("app:update-available")!({ data: {
currentVersion: "1.2.3", version: "1.2.4", responseEvent: "app:update-response:1",
} });
return state.info.mock.calls.at(-1)![0];
}
test("显示版本且等待明确点击下载,重复点击只提交一次", async () => {
const prompt = requestUpdate();
expect(prompt.content).toContain("1.2.3");
expect(prompt.content).toContain("1.2.4");
expect(prompt.positiveText).toBe("下载更新");
expect(prompt.autoFocus).toBe(false);
expect(state.emit).not.toHaveBeenCalled();
requestUpdate();
expect(state.info).toHaveBeenCalledTimes(1);
await prompt.onPositiveClick();
await prompt.onPositiveClick();
expect(state.emit).toHaveBeenCalledExactlyOnceWith("app:update-response:1", { accepted: true });
});
for (const action of ["onNegativeClick", "onClose", "onEsc"]) {
test(`${action} 取消下载并允许再次提示`, async () => {
const prompt = requestUpdate();
await prompt[action]();
expect(state.emit).toHaveBeenCalledExactlyOnceWith("app:update-response:1", { accepted: false });
requestUpdate();
expect(state.info).toHaveBeenCalledTimes(2);
});
}
test("确认发送失败时提示错误,允许重试", async () => {
const prompt = requestUpdate();
state.emit.mockRejectedValueOnce(new Error("断开连接"));
await expect(prompt.onPositiveClick()).resolves.toBe(false);
expect(state.error).toHaveBeenCalled();
await prompt.onNegativeClick();
expect(state.emit).toHaveBeenLastCalledWith("app:update-response:1", { accepted: false });
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { editUpgrade, editUpgradeBatch, upgradeRange, upgradeRows, upgradeSuccessRate } from "../src/gui/upgrade";
import { getGUIProvider } from "../src/gui/registry";
import type { UpgradeRow } from "../bindings/pvfine/services/models";
function fixture(crlf = false) {
const normalized = "# 😀\n[table]\n2 0.018 0.1 0.033 0.3 0.6 12500 2 3 3037 10 1 0.018 0.1 0.033 0.17 0.6\n[/table]\n[other]\n99";
const start = normalized.indexOf("12500");
let offset = start;
const fields = [12500, 2, 3, 3037, 10].map((value) => {
const token = { value, start: offset, end: offset + String(value).length, tokenType: 0 };
offset = token.end + 1;
return token;
});
return { text: crlf ? normalized.replaceAll("\n", "\r\n") : normalized, row: { level: 1, fields, costName: "材料" } as UpgradeRow };
}
describe("强化/增幅 GUI", () => {
it("按等级范围同时批量设置概率、失败操作、降级参数和材料消耗", () => {
const { text, row } = fixture();
const rows = [1, 2, 3].map((level) => ({ ...row, level, fields: row.fields!.map(field => ({ ...field, start: field.start + (level - 1) * (text.length + 1), end: field.end + (level - 1) * (text.length + 1) })) }));
const source = [text, text, text].join("\n");
const targets = upgradeRange(rows, [2, 3]);
expect(targets.map(row => row.level)).toEqual([2, 3]);
const changed = text.replace("12500 2 3 3037 10", "50000 2 1 55 99");
expect(editUpgradeBatch(source, targets, [{ column: 0, value: 50 }, { column: 1, value: 2 }, { column: 2, value: 1 }, { column: 3, value: 55 }, { column: 4, value: 99 }])).toBe([text, changed, changed].join("\n"));
expect(editUpgradeBatch(source, targets, [{ column: 1, value: 3 }])).toBe([text, text.replace("12500 2 3", "12500 3 0"), text.replace("12500 2 3", "12500 3 0")].join("\n"));
expect(() => upgradeRange(rows, [3, 2])).toThrow("等级范围");
expect(() => editUpgradeBatch(source, targets, [{ column: 0, value: 50 }, { column: 4, value: -1 }])).toThrow();
expect(() => editUpgradeBatch(source, targets, [])).toThrow("至少选择");
});
it("新格式 T1/T2 独立编辑,仅改变 normal 表的对应字段,保留其它表与 CRLF", () => {
const rowText = "74.3 0 0 0 0 63 0 90.5625 90000 88000 2 3 3 0 3037 260 8.2 0.62 10.3 3.501 17.17 23.94 0 27.531";
const prefix = "# 😀\n[table]\n[table type]\n`event` 90000 88000\n[/table]\n[table]\n[table type]\n`normal` ";
const suffix = "\n[/table]\n[table]\n[table type]\n`amplify` " + rowText + "\n[/table]";
const text = (prefix + rowText + suffix).replaceAll("\n", "\r\n");
const tokens = [...rowText.matchAll(/\d+(?:\.\d+)?/g)].map(match => ({ value: Number(match[0]), start: prefix.length + match.index!, end: prefix.length + match.index! + match[0].length, tokenType: match[0].includes('.') ? 2 : 0 }));
const row = { level: 18, fields: [8, 10, 11, 14, 15].map(i => tokens[i]!), fieldsT2: [9, 12, 13].map(i => tokens[i]!), costName: "材料" } as UpgradeRow;
const t1 = upgradeRows([row], "T1"), t2 = upgradeRows([row], "T2");
expect(upgradeSuccessRate(t1[0]!)).toBe(10);
expect(upgradeSuccessRate(t2[0]!)).toBe(12);
const expected = (normalRow: string) => (prefix + normalRow + suffix).replaceAll("\n", "\r\n");
expect(editUpgrade(text, t1, 0, 20)).toBe(expected(rowText.replace("90000 88000", "80000 88000")));
expect(editUpgrade(text, t2, 0, 25)).toBe(expected(rowText.replace("90000 88000", "90000 75000")));
expect(editUpgrade(text, t1, 1, 1)).toBe(expected(rowText.replace("2 3 3 0", "1 0 3 0")));
expect(editUpgrade(text, t2, 1, 2)).toBe(expected(rowText.replace("2 3 3 0", "2 3 2 0")));
expect(editUpgrade(text, t2, 3, 55)).toBe(expected(rowText.replace("3037", "55")));
expect(() => upgradeRows([fixture().row], "T2")).toThrow("缺少防具");
});
it("只匹配两个概率文件,兼容路径大小写与分隔符", () => {
const file = { index: 1, path: "ETC\\UPGRADE.ETC", text: "", editable: true };
expect(getGUIProvider(file)?.id).toBe("upgrade");
expect(getGUIProvider({ ...file, path: "etc/amplifyupgrade.etc" })?.id).toBe("upgrade");
expect(getGUIProvider({ ...file, path: "etc/upgrade_separate.etc" })?.id).toBe("forge");
});
it("概率换算保留千分之一百分比,覆盖 0% 和 100%", () => {
const { text, row } = fixture();
expect(upgradeSuccessRate(row)).toBe(87.5);
expect(editUpgrade(text, [row], 0, 100)).toBe(text.replace("12500", "0"));
expect(editUpgrade(text, [row], 0, 0)).toBe(text.replace("12500", "100000"));
expect(editUpgrade(text, [row], 0, 87.501)).toBe(text.replace("12500", "12499"));
});
it("失败操作同步清零降级参数,保留其它字段和 CRLF", () => {
const { text, row } = fixture(true);
expect(editUpgrade(text, [row], 1, 3)).toBe(text.replace("12500 2 3", "12500 3 0"));
expect(editUpgrade(text, [row], 1, 1)).toBe(text.replace("12500 2 3", "12500 1 0"));
expect(editUpgrade(text, [row], 1, 2)).toBe(text);
expect(editUpgrade(text, [row], 3, 999)).toBe(text.replace("3037", "999"));
});
it("批量替换材料,只更改目标列", () => {
const { text, row } = fixture();
const shift = text.length + 1;
const second = { ...row, level: 2, fields: row.fields!.map(field => ({ ...field, start: field.start + shift, end: field.end + shift })) };
const source = text + "\n" + text;
expect(editUpgrade(source, [row, second], 3, 55)).toBe(source.replaceAll("3037", "55"));
});
it("拒绝无效参数、过期草稿及重叠定位", () => {
const { text, row } = fixture();
for (const [column, value] of [[0, 101], [1, 0], [2, 32], [3, -1], [4, 1.5], [4, 2147483648]]) {
expect(() => editUpgrade(text, [row], column!, value!)).toThrow();
}
expect(() => editUpgrade(text.replace("3037", "1234"), [row], 3, 55)).toThrow("草稿已变化");
expect(() => editUpgrade(text, [row, row], 3, 55)).toThrow("重叠");
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import type { VendingGroup, VendingReward, SkillNumber } from "../bindings/pvfine/services/models";
import { addVendingGroup, addVendingReward, editVendingNumbers, filterVendingMachines, removeVendingGroup, removeVendingReward, vendingInteger, vendingProbability, vendingTotal } from "../src/gui/vendingMachine";
function fixture(eol = "\n", compact = false) {
const text = ("# 😀 保留注释\n[name]`抽奖机`\n[item group]\n[group num]1\n[material]42 2\n[output]" + (compact ? "" : "\n\t") + "7740\t3000\t2\t0" + (compact ? "" : "\n\t") + "[/output]\n[other]`保留参数`\n[/item group]\n[tail]99\n").replaceAll("\n", eol);
const n = text.replace(/\r\n|\r/g,"\n"), start = n.indexOf("7740");
const fields = [[7740,0],[3000,5],[2,10],[0,12]].map(([value,offset]) => ({value:value!,start:start+offset!,end:start+offset!+String(value).length,tokenType:0} as SkillNumber));
const reward: VendingReward = {fields,name:"测试奖品"};
const group = {start:n.indexOf("[item group]"),end:n.indexOf("[/item group]")+13,rewards:[reward],outputClose:n.indexOf("[/output]")} as VendingGroup;
return {text,group,reward};
}
describe("抽奖机编辑", () => {
for (const eol of ["\n","\r\n","\r"]) {
it(`保留 ${JSON.stringify(eol)}、emoji、注释和未知参数`, () => {
const {text,group,reward}=fixture(eol);
expect(editVendingNumbers(text,reward.fields!,[7740,20,4,1])).toBe(text.replace("7740\t3000\t2\t0","7740\t20\t4\t1"));
const added=addVendingReward(text,group,[7740,10,1,0]);
expect(added).toContain("7740\t3000\t2\t0"); expect(added).toContain("7740\t10\t1\t0"); expect(added).toContain("[other]`保留参数`");
expect(removeVendingReward(text,reward)).toBe(text.replace("7740\t3000\t2\t0",""));
expect(removeVendingGroup(text,group)).toBe(text.slice(0,text.indexOf("[item group]"))+text.slice(text.indexOf("[/item group]")+13));
expect(addVendingGroup(text,2,42,1)).toContain(`[group num]${eol}\t\t2`);
if(eol==="\r\n") expect(added.replaceAll("\r\n","")).not.toContain("\n");
});
}
it("紧凑格式新增奖品不会拼接原数值", () => {
const {text,group}=fixture("\n",true);
expect(addVendingReward(text,group,[42,1,2,0])).toContain("7740\t3000\t2\t0\n\t\t42\t1\t2\t0\n\t[/output]");
});
it("拒绝失效位置和无效整数", () => {
const {text,group,reward}=fixture();
expect(()=>editVendingNumbers(text.replace("3000","2000"),reward.fields!,[1,2,3,0])).toThrow();
expect(()=>addVendingReward(text,{...group,outputClose:0} as VendingGroup,[1,2,3,0])).toThrow();
expect(()=>addVendingReward(text,group,[1,2,3,2])).toThrow();
for(const value of [null,NaN,-1,0.1,2147483648]) expect(()=>vendingInteger(value)).toThrow();
});
it("独立计算重复物品记录,零权重不显示 NaN", () => {
const {reward}=fixture();
expect(vendingTotal([reward,reward])).toBe(6000);
expect(vendingProbability(3000,6000)).toBe("50%"); expect(vendingProbability(0,0)).toBe("—"); expect(vendingProbability(20,100000000)).toBe("0.00002%");
});
it("按 LST ID、名称、路径和通配符筛选", () => {
const entries=[{name:"抽奖机",id:"12",path:"etc/vending/a.vm"},{name:"特殊",id:"99",path:"etc/vending/b.vm"}];
expect(filterVendingMachines(entries,"12")).toEqual([entries[0]]); expect(filterVendingMachines(entries,"*.vm")).toEqual(entries);
expect(filterVendingMachines(entries,"特殊")).toEqual([entries[1]]); expect(filterVendingMachines(entries,"A.?m")).toEqual([entries[0]]); expect(filterVendingMachines(entries,"[")).toEqual([]);
});
});
+454
View File
@@ -0,0 +1,454 @@
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();
});
});
+442
View File
@@ -0,0 +1,442 @@
import { describe, expect, it } from "vitest";
import {
areLevelsEqual,
batchAddItems,
batchDeleteItems,
calcItemPercentage,
calcLevelTotalWeight,
countLevelMatches,
countMatchingItems,
filterItems,
findFirstLevelWithMatch,
getLevelsInInterval,
isFilterCriteriaActive,
isNonNegativeInt32,
isPositiveInt32,
moveItem,
toEditableLevels,
toWorldDropLevels,
validateAllLevels,
validateItem,
validateLevelInterval,
validateLevelNumber,
type WorldDropFilterCriteria,
} from "../src/gui/worldDropForm";
import type { WorldDropLevel } from "../bindings/pvfine/services/models";
describe("worldDropForm validation & calculation", () => {
it("整数范围校验正整数与非负整数边界", () => {
expect(isPositiveInt32(1)).toBe(true);
expect(isPositiveInt32(2147483647)).toBe(true);
expect(isPositiveInt32(0)).toBe(false);
expect(isPositiveInt32(-1)).toBe(false);
expect(isPositiveInt32(2147483648)).toBe(false);
expect(isPositiveInt32(1.5)).toBe(false);
expect(isPositiveInt32("10")).toBe(false);
expect(isNonNegativeInt32(0)).toBe(true);
expect(isNonNegativeInt32(100)).toBe(true);
expect(isNonNegativeInt32(2147483647)).toBe(true);
expect(isNonNegativeInt32(-1)).toBe(false);
expect(isNonNegativeInt32(2147483648)).toBe(false);
expect(isNonNegativeInt32(0.1)).toBe(false);
});
it("等级校验:正整数且禁止重复等级,但允许当前等级保留原数值", () => {
const existing = [1, 10, 85];
expect(validateLevelNumber(0, existing)).toContain("正整数");
expect(validateLevelNumber(-5, existing)).toContain("正整数");
expect(validateLevelNumber(2147483648, existing)).toContain("正整数");
// 重复新增禁止
expect(validateLevelNumber(10, existing)).toContain("已存在");
// 编辑当前等级,数值保持 10 不算重复
expect(validateLevelNumber(10, existing, 10)).toBe("");
// 正常新等级
expect(validateLevelNumber(20, existing)).toBe("");
});
it("道具校验:ID 必须为正整数,权重必须为非负整数", () => {
expect(validateItem(1001, 50)).toBe("");
expect(validateItem(1001, 0)).toBe(""); // 权重允许为 0
expect(validateItem(0, 50)).toContain("道具 ID");
expect(validateItem(-1, 50)).toContain("道具 ID");
expect(validateItem(0, 50, true)).toBe("");
expect(validateItem(-2, 50, true)).toBe("");
expect(validateItem(-1, 50, true)).toContain("道具 ID");
expect(validateItem(1001, -1)).toContain("权重");
expect(validateItem(1001, 2147483648)).toContain("权重");
});
it("全量等级列表校验:允许空等级,禁止重复等级与非法数据", () => {
const validWithEmpty: WorldDropLevel[] = [
{ level: 1, items: [{ id: 100, weight: 50, name: "物品A" }] },
{ level: 10, items: [] }, // 允许空等级
{ level: 85, items: [{ id: 200, weight: 0, name: "物品B" }] },
];
expect(validateAllLevels(validWithEmpty)).toBe("");
expect(validateAllLevels([{ level: 1, items: [{ id: 0, weight: 5, name: "未知物品 #0" }, { id: -2, weight: 0, name: "未知物品 #-2" }] }])).toBe("");
const duplicateLevels: WorldDropLevel[] = [
{ level: 1, items: [] },
{ level: 1, items: [] },
];
expect(validateAllLevels(duplicateLevels)).toContain("等级 1 重复");
const invalidItemLevel: WorldDropLevel[] = [
{ level: 1, items: [{ id: -1, weight: 10, name: "" }] },
];
expect(validateAllLevels(invalidItemLevel)).toContain("道具错误");
});
it("计算等级总权重与百分比占比:总权重为 0 时显示中立 0.00% 而不是 NaN", () => {
const items = [
{ id: 1, weight: 100, name: "" },
{ id: 2, weight: 300, name: "" },
];
const total = calcLevelTotalWeight(items);
expect(total).toBe(400);
expect(calcItemPercentage(100, total)).toBe("25.00%");
expect(calcItemPercentage(300, total)).toBe("75.00%");
// 总权重为 0 时:绝不能返回 NaN
const zeroTotal = calcLevelTotalWeight([{ id: 1, weight: 0, name: "" }]);
expect(zeroTotal).toBe(0);
expect(calcItemPercentage(0, zeroTotal)).toBe("0.00%");
expect(calcItemPercentage(0, 0)).toBe("0.00%");
expect(calcItemPercentage(-10, 0)).toBe("0.00%");
});
it("数据转换与对比:保持顺序且正确判定脏状态", () => {
const original: WorldDropLevel[] = [
{ level: 5, items: [{ id: 10, weight: 50, name: "药水" }] },
{ level: 1, items: [{ id: 20, weight: 80, name: "长剑" }] },
];
const editable = toEditableLevels(original);
expect(editable.length).toBe(2);
expect(editable[0].level).toBe(5);
expect(editable[1].level).toBe(1);
const converted = toWorldDropLevels(editable);
expect(areLevelsEqual(original, converted)).toBe(true);
// 修改权重
editable[0].items[0].weight = 99;
expect(areLevelsEqual(original, toWorldDropLevels(editable))).toBe(false);
// 恢复
editable[0].items[0].weight = 50;
expect(areLevelsEqual(original, toWorldDropLevels(editable))).toBe(true);
// 调整顺序应视为不同(保留顺序不自动排序)
const reordered: WorldDropLevel[] = [original[1], original[0]];
expect(areLevelsEqual(original, reordered)).toBe(false);
});
it("道具排序移动操作", () => {
const items = [
{ id: 1, weight: 10, name: "A", key: 1 },
{ id: 2, weight: 20, name: "B", key: 2 },
{ id: 3, weight: 30, name: "C", key: 3 },
];
// 下移第 0 项
expect(moveItem(items, 0, 1)).toBe(true);
expect(items.map((i) => i.id)).toEqual([2, 1, 3]);
// 越界保护
expect(moveItem(items, -1, 1)).toBe(false);
expect(moveItem(items, 0, 5)).toBe(false);
expect(moveItem(items, 1, 1)).toBe(false);
});
describe("等级区间校验与筛选 (validateLevelInterval & getLevelsInInterval)", () => {
const existing = [1, 10, 20, 85];
it("空选择或不在已有等级范围内的区间校验失败", () => {
expect(validateLevelInterval(null, 10, existing)).toContain("请选择");
expect(validateLevelInterval(1, null, existing)).toContain("请选择");
expect(validateLevelInterval(5, 10, existing)).toContain("Lv. 5 不存在");
expect(validateLevelInterval(1, 99, existing)).toContain("Lv. 99 不存在");
});
it("起始等级大于结束等级校验失败", () => {
expect(validateLevelInterval(85, 1, existing)).toContain("不能大于");
});
it("合法的已有等级闭区间校验成功", () => {
expect(validateLevelInterval(1, 1, existing)).toBe("");
expect(validateLevelInterval(1, 10, existing)).toBe("");
expect(validateLevelInterval(10, 85, existing)).toBe("");
});
it("getLevelsInInterval 保留原有顺序且仅包含存在的配置等级", () => {
const levels = toEditableLevels([
{ level: 50, items: [] },
{ level: 10, items: [] },
{ level: 85, items: [] },
{ level: 1, items: [] },
]);
const sub = getLevelsInInterval(levels, 10, 50);
expect(sub.map((l) => l.level)).toEqual([50, 10]);
const single = getLevelsInInterval(levels, 85, 85);
expect(single.map((l) => l.level)).toEqual([85]);
expect(getLevelsInInterval(levels, 85, 10)).toEqual([]);
expect(getLevelsInInterval(levels, null, 10)).toEqual([]);
});
});
describe("批量添加道具 (batchAddItems)", () => {
it("向区间内已有等级添加道具;若已存在相同道具则追加新行而不覆盖", () => {
const levels = toEditableLevels([
{ level: 1, items: [{ id: 100, weight: 10, name: "药水" }] },
{ level: 10, items: [] }, // 空等级
{ level: 20, items: [{ id: 200, weight: 50, name: "金币" }] },
{ level: 85, items: [] },
]);
// 向 [1, 20] 区间批量添加道具 100
const res = batchAddItems(levels, 1, 20, {
id: 100,
weight: 99,
name: "新药水",
});
expect(res.addedCount).toBe(3);
expect(res.affectedLevels).toEqual([1, 10, 20]);
// 等级 1 原有道具 100,现在应该有 2 条记录(追加而非覆盖)
expect(levels[0].items.length).toBe(2);
expect(levels[0].items[0]).toMatchObject({ id: 100, weight: 10, name: "药水" });
expect(levels[0].items[1]).toMatchObject({ id: 100, weight: 99, name: "新药水" });
// 等级 10 原为空,现在添加 1 项
expect(levels[1].items.length).toBe(1);
expect(levels[1].items[0]).toMatchObject({ id: 100, weight: 99 });
// 等级 20 原有金币,现在追加新药水
expect(levels[2].items.length).toBe(2);
expect(levels[2].items[1]).toMatchObject({ id: 100, weight: 99 });
// 等级 85 不在区间内,保持为空
expect(levels[3].items.length).toBe(0);
});
});
describe("批量删除道具 (countMatchingItems & batchDeleteItems)", () => {
it("匹配统计准确反映区间内匹配等级数及全部重复行", () => {
const levels = toEditableLevels([
{
level: 1,
items: [
{ id: 100, weight: 10, name: "A" },
{ id: 100, weight: 20, name: "A duplicate" },
{ id: 200, weight: 5, name: "B" },
],
},
{ level: 10, items: [{ id: 100, weight: 30, name: "A" }] },
{ level: 20, items: [{ id: 300, weight: 1, name: "C" }] },
{ level: 85, items: [{ id: 100, weight: 99, name: "A outside" }] },
]);
// 统计 [1, 20] 区间内道具 100
const match = countMatchingItems(levels, 1, 20, 100);
expect(match.matchingLevels).toEqual([1, 10]);
expect(match.matchingRows).toBe(3); // 等级 1 有 2 行 + 等级 10 有 1 行
// 统计未出现的道具
const noMatch = countMatchingItems(levels, 1, 20, 9999);
expect(noMatch.matchingLevels).toEqual([]);
expect(noMatch.matchingRows).toBe(0);
});
it("批量删除移除匹配等级内的全部该道具记录,保留空等级与其他道具", () => {
const levels = toEditableLevels([
{
level: 1,
items: [
{ id: 100, weight: 10, name: "A" },
{ id: 100, weight: 20, name: "A duplicate" },
{ id: 200, weight: 5, name: "B" },
],
},
{ level: 10, items: [{ id: 100, weight: 30, name: "A" }] },
{ level: 20, items: [{ id: 300, weight: 1, name: "C" }] },
{ level: 85, items: [{ id: 100, weight: 99, name: "A outside" }] },
]);
const res = batchDeleteItems(levels, 1, 20, 100);
expect(res.removedCount).toBe(3);
expect(res.affectedLevels).toEqual([1, 10]);
// 等级 1: 道具 100 全部删除,保留道具 200
expect(levels[0].items.length).toBe(1);
expect(levels[0].items[0].id).toBe(200);
// 等级 10: 道具 100 删除后变为空等级,但等级结构仍保留
expect(levels[1].items.length).toBe(0);
expect(levels[1].level).toBe(10);
// 等级 20: 无变动
expect(levels[2].items.length).toBe(1);
expect(levels[2].items[0].id).toBe(300);
// 等级 85: 在区间外,未受影响
expect(levels[3].items.length).toBe(1);
expect(levels[3].items[0].id).toBe(100);
});
it("0 项匹配时 batchDeleteItems 无影响并返回空结果", () => {
const levels = toEditableLevels([
{ level: 1, items: [{ id: 50, weight: 10, name: "" }] },
]);
const res = batchDeleteItems(levels, 1, 1, 999);
expect(res.removedCount).toBe(0);
expect(res.affectedLevels).toEqual([]);
expect(levels[0].items.length).toBe(1);
});
});
describe("道具视图筛选 (filterItems & countLevelMatches)", () => {
const items = [
{ id: 1001, weight: 10, name: "泰拉石光剑", key: 1 },
{ id: 1002, weight: 20, name: "泰拉石巨剑", key: 2 },
{ id: 2001, weight: 30, name: "生命药水", key: 3 },
{ id: 3000, weight: 5, name: "无影剑", key: 4 },
];
it("空查询返回完整列表", () => {
expect(filterItems(items, "")).toEqual(items);
expect(filterItems(items, " ")).toEqual(items);
});
it("按道具名称(大小写不敏感包含)筛选", () => {
const matched = filterItems(items, "泰拉");
expect(matched.map((i) => i.id)).toEqual([1001, 1002]);
const single = filterItems(items, "药水");
expect(single.map((i) => i.id)).toEqual([2001]);
});
it("按道具 ID 完全或前缀筛选", () => {
const byId = filterItems(items, "2001");
expect(byId.map((i) => i.id)).toEqual([2001]);
const byPrefix = filterItems(items, "100");
expect(byPrefix.map((i) => i.id)).toEqual([1001, 1002]);
});
it("无匹配项返回空数组", () => {
expect(filterItems(items, "不存在的武器")).toEqual([]);
});
it("countLevelMatches 准确统计等级内匹配数", () => {
const lvl = {
level: 1,
key: 1,
items,
};
expect(countLevelMatches(lvl, "泰拉")).toBe(2);
expect(countLevelMatches(lvl, "药水")).toBe(1);
expect(countLevelMatches(lvl, "9999")).toBe(0);
expect(countLevelMatches(lvl, "")).toBe(4);
expect(countLevelMatches(null, "泰拉")).toBe(0);
});
it("隐藏行在底层数据和 toWorldDropLevels 快照中完好保留", () => {
const level = {
level: 85,
key: 1,
items,
};
// 视图中只展示 "药水"
const displayed = filterItems(level.items, "药水");
expect(displayed.length).toBe(1);
// 底层 level.items 依然是 4 项
expect(level.items.length).toBe(4);
// toWorldDropLevels 导出的全量快照包含全部 4 项
const exported = toWorldDropLevels([level]);
expect(exported[0].items.length).toBe(4);
expect(exported[0].items.map((i) => i.id)).toEqual([1001, 1002, 2001, 3000]);
});
it("同名异 ID 精准过滤:选择指定物品仅匹配该 item.id,同名不同 ID 绝不出现", () => {
const collisionItems = [
{ id: 1001, weight: 10, name: "神秘宝箱", key: 1 },
{ id: 2002, weight: 20, name: "神秘宝箱", key: 2 },
{ id: 3003, weight: 30, name: "金币", key: 3 },
];
// 自由文本按名称检索时,两者均会命中
const textMatches = filterItems(collisionItems, "神秘宝箱");
expect(textMatches.length).toBe(2);
// 精准选取 ID 为 1001 的物品:仅出现 1001,同名的 2002 绝不出现
const exact1001 = filterItems(collisionItems, { exactItemId: 1001 });
expect(exact1001.length).toBe(1);
expect(exact1001[0].id).toBe(1001);
// 精准选取 ID 为 2002 的物品:仅出现 2002,同名的 1001 绝不出现
const exact2002 = filterItems(collisionItems, { exactItemId: 2002 });
expect(exact2002.length).toBe(1);
expect(exact2002[0].id).toBe(2002);
});
it("数字子串冲突精准过滤:选取 ID 10 仅匹配 10,不匹配 100、210、9105", () => {
const numberCollisionItems = [
{ id: 10, weight: 10, name: "短剑", key: 1 },
{ id: 100, weight: 20, name: "长剑", key: 2 },
{ id: 210, weight: 30, name: "重剑", key: 3 },
{ id: 9105, weight: 40, name: "巨剑", key: 4 },
{ id: 777, weight: 50, name: "钝器", key: 5 },
];
// 自由文本检索 "10" 时,包含子串 10 的所有 4 项均会命中
const subStringMatches = filterItems(numberCollisionItems, "10");
expect(subStringMatches.map((i) => i.id)).toEqual([10, 100, 210, 9105]);
// 精准选取 ID: 10 时,严格只匹配 10
const exactTen = filterItems(numberCollisionItems, { exactItemId: 10 });
expect(exactTen.length).toBe(1);
expect(exactTen[0].id).toBe(10);
});
it("精准筛选清除与重置行为:重置或清除后恢复全量列表", () => {
const testItems = [
{ id: 10, weight: 10, name: "A", key: 1 },
{ id: 20, weight: 20, name: "B", key: 2 },
];
// 激活精准筛选
expect(isFilterCriteriaActive({ exactItemId: 10 })).toBe(true);
expect(filterItems(testItems, { exactItemId: 10 }).length).toBe(1);
// 清除筛选:exactItemId 为 null 且 query 为空
expect(isFilterCriteriaActive({ exactItemId: null, query: "" })).toBe(false);
expect(isFilterCriteriaActive("")).toBe(false);
expect(filterItems(testItems, { exactItemId: null, query: "" })).toEqual(testItems);
expect(filterItems(testItems, "")).toEqual(testItems);
});
it("findFirstLevelWithMatch 准确找到首个包含匹配掉落的等级", () => {
const levels = toEditableLevels([
{ level: 1, items: [{ id: 100, weight: 10, name: "A" }] },
{ level: 10, items: [] }, // 空
{ level: 20, items: [{ id: 500, weight: 20, name: "目标物品" }] },
{ level: 85, items: [{ id: 500, weight: 30, name: "目标物品" }] },
]);
// 查找 ID 500:首个匹配等级应为 20
const firstLevel = findFirstLevelWithMatch(levels, { exactItemId: 500 });
expect(firstLevel).toBe(20);
// 查找不存在的物品 ID 9999:返回 null
const nonExistent = findFirstLevelWithMatch(levels, { exactItemId: 9999 });
expect(nonExistent).toBeNull();
});
});
});
+631
View File
@@ -0,0 +1,631 @@
import { describe, expect, it } from "vitest";
import { getGUIProvider } from "../src/gui/registry";
import { createGUIModes, createWorldDropDraftState, createWorldDropSession } from "../src/gui/state";
import type { WorldDropDocument } from "../bindings/pvfine/services/models";
import {
batchAddItems,
batchDeleteItems,
calcItemPercentage,
calcLevelTotalWeight,
countLevelMatches,
countMatchingItems,
filterItems,
findFirstLevelWithMatch,
getLevelsInInterval,
isFilterCriteriaActive,
toEditableLevels,
toWorldDropLevels,
validateLevelInterval,
type WorldDropFilterCriteria,
} from "../src/gui/worldDropForm";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
describe("world drop GUI registry & session", () => {
it("首次从工具栏读取时,文档先返回不应被误判为表单修改", async () => {
const file = { index: 1, path: "etc/worlddrop.etc", text: "[world drop]\n1 0 100 5 -1", editable: true };
const session = createWorldDropSession(async () => ({
revision: 1,
levels: [{ level: 1, items: [{ id: 100, weight: 5, name: "测试道具" }] }],
}));
const draft = createWorldDropDraftState();
expect(draft.isDirty.value).toBe(false);
await session.load(file, "epoch1");
// 后端文档已到达,表单尚未同步;这不是用户编辑。
expect(session.document.value?.levels?.length).toBe(1);
expect(draft.isDirty.value).toBe(false);
draft.sync(session.document.value);
expect(draft.isDirty.value).toBe(false);
draft.editableLevels.value[0].items[0].weight = 9;
expect(draft.isDirty.value).toBe(true);
await session.load(file, "epoch1");
// 后续读取也不能擅自重置已编辑表单的比较基线。
expect(draft.isDirty.value).toBe(true);
});
it("GUI Provider 注册匹配 etc/worlddrop.etc 及其路径变体,排除其他文件", () => {
const file = { index: 1, path: "etc/worlddrop.etc", text: "", editable: true };
const provider = getGUIProvider(file);
expect(provider).not.toBeNull();
expect(provider?.id).toBe("world-drop");
expect(provider?.label).toBe("全局掉率");
expect(provider?.readOnly).toBe(false);
// 大小写与斜杠变体
expect(getGUIProvider({ ...file, path: "ETC/WORLDDROP.ETC" })?.id).toBe("world-drop");
expect(getGUIProvider({ ...file, path: "etc\\worlddrop.etc" })?.id).toBe("world-drop");
expect(getGUIProvider({ ...file, path: "/etc/worlddrop.etc" })?.id).toBe("world-drop");
// 不匹配其他文件
expect(getGUIProvider({ ...file, path: "etc/itemdropinfo_monster.etc" })).toBeNull();
expect(getGUIProvider({ ...file, path: "etc/worlddrop.txt" })).toBeNull();
});
it("Session 加载与生成代次保护:慢请求被新请求丢弃,不覆盖最新结果", async () => {
const file = { index: 1, path: "etc/worlddrop.etc", text: "text1", editable: true };
const firstDeferred = deferred<WorldDropDocument>();
const secondDeferred = deferred<WorldDropDocument>();
let callCount = 0;
const session = createWorldDropSession(async () => {
callCount++;
return callCount === 1 ? firstDeferred.promise : secondDeferred.promise;
});
const firstLoad = session.load(file, "epoch1");
expect(session.loading.value).toBe(true);
// 发起第二次加载
const secondLoad = session.load(file, "epoch1");
// 第二次先返回
secondDeferred.resolve({
revision: 2,
levels: [{ level: 85, items: [] }],
});
await secondLoad;
expect(session.document.value?.revision).toBe(2);
expect(session.selectedLevel.value).toBe(85);
// 第一次才返回,应当被忽略
firstDeferred.resolve({
revision: 1,
levels: [{ level: 1, items: [] }],
});
await firstLoad;
expect(session.document.value?.revision).toBe(2);
expect(session.selectedLevel.value).toBe(85);
});
it("Session 异常与空数据处理:保留上次成功状态,重试可恢复", async () => {
const file = { index: 1, path: "etc/worlddrop.etc", text: "", editable: true };
let shouldFail = false;
const session = createWorldDropSession(async () => {
if (shouldFail) throw new Error("解析失败:第 3 行缺少结束标记");
return {
revision: 1,
levels: [{ level: 10, items: [] }],
};
});
await session.load(file, "epoch1");
expect(session.document.value?.revision).toBe(1);
expect(session.error.value).toBe("");
// 下次加载失败
shouldFail = true;
await session.load(file, "epoch1");
expect(session.loading.value).toBe(false);
expect(session.error.value).toContain("缺少结束标记");
// 保留上一次成功加载的数据文档
expect(session.document.value?.revision).toBe(1);
// 恢复重试
shouldFail = false;
await session.load(file, "epoch1");
expect(session.error.value).toBe("");
expect(session.document.value?.revision).toBe(1);
});
it("Session.load 返回值:成功返回 true,被覆盖或异常返回 false", async () => {
const file = { index: 1, path: "etc/worlddrop.etc", text: "text1", editable: true };
const firstDeferred = deferred<WorldDropDocument>();
const secondDeferred = deferred<WorldDropDocument>();
let count = 0;
const session = createWorldDropSession(async () => {
count++;
if (count === 1) return firstDeferred.promise;
if (count === 2) return secondDeferred.promise;
throw new Error("异常测试");
});
const firstLoad = session.load(file, "epoch1");
const secondLoad = session.load(file, "epoch1");
secondDeferred.resolve({ revision: 2, levels: [] });
const secondResult = await secondLoad;
expect(secondResult).toBe(true);
firstDeferred.resolve({ revision: 1, levels: [] });
const firstResult = await firstLoad;
expect(firstResult).toBe(false);
// 失败情况
const thirdResult = await session.load(file, "epoch1");
expect(thirdResult).toBe(false);
});
it("GUI 模式守卫:阻止未保存修改的模式切换;确认放弃修改时重置表单并清除脏状态", async () => {
const modes = createGUIModes();
modes.set(1, "gui");
expect(modes.entries.get(1)?.mode).toBe("gui");
let isDirty = true;
let formResetCalled = false;
// 模拟确认框逻辑:用户确认放弃时重置表单并清除 dirty
const unregister = modes.registerGuard(1, async () => {
if (!isDirty) return true;
// 模拟用户点击【取消】
return false;
});
// 尝试切换到 text -> 被守卫拦截
const allowedFirst = await modes.requestMode(1, "text");
expect(allowedFirst).toBe(false);
expect(modes.entries.get(1)?.mode).toBe("gui");
expect(formResetCalled).toBe(false);
// 替换为用户点击【放弃修改】
unregister();
modes.registerGuard(1, async () => {
if (!isDirty) return true;
// 模拟用户点击【放弃修改并离开】
formResetCalled = true;
isDirty = false;
return true;
});
const allowedSecond = await modes.requestMode(1, "text");
expect(allowedSecond).toBe(true);
expect(modes.entries.get(1)?.mode).toBe("text");
expect(formResetCalled).toBe(true);
expect(isDirty).toBe(false);
});
it("不同窗格的 GUI 模式与守卫独立隔离", async () => {
const pane1Modes = createGUIModes();
const pane2Modes = createGUIModes();
pane1Modes.set(1, "gui");
pane2Modes.set(1, "text");
expect(pane1Modes.entries.get(1)?.mode).toBe("gui");
expect(pane2Modes.entries.get(1)?.mode).toBe("text");
// 在 pane1 上注册拦截守卫
pane1Modes.registerGuard(1, () => false);
// pane1 无法切回 text
expect(await pane1Modes.requestMode(1, "text")).toBe(false);
expect(pane1Modes.entries.get(1)?.mode).toBe("gui");
// pane2 没有守卫,可自由切换
expect(await pane2Modes.requestMode(1, "gui")).toBe(true);
expect(pane2Modes.entries.get(1)?.mode).toBe("gui");
expect(await pane2Modes.requestMode(1, "text")).toBe(true);
expect(pane2Modes.entries.get(1)?.mode).toBe("text");
});
it("标签切换与外部文本变动状态转移逻辑:切出再切回保留脏表单,文本冲突安全标示", () => {
// 模拟 WorldDropViewer 中的核心 watch 状态机行为
let loadedText = "version 1 text";
let loadedArchiveId = "1\u0000etc/worlddrop.etc\u0000epoch1";
let formDirty = true;
let staleConflict = false;
let reloadCount = 0;
function handleFileChange(active: boolean, index: number, path: string, text: string, epoch: string) {
if (!active) return;
const archiveId = `${index}\u0000${path}\u0000${epoch}`;
const identityChanged = loadedArchiveId !== "" && loadedArchiveId !== archiveId;
const textChanged = loadedText !== "" && loadedText !== text;
if (identityChanged) {
reloadCount++;
loadedArchiveId = archiveId;
loadedText = text;
formDirty = false;
staleConflict = false;
return;
}
if (textChanged) {
if (formDirty) {
staleConflict = true;
} else {
reloadCount++;
loadedText = text;
}
}
}
// 1. 用户编辑后成为 dirty,随后切到其他标签(active = false)
handleFileChange(false, 1, "etc/worlddrop.etc", "version 1 text", "epoch1");
expect(formDirty).toBe(true);
expect(reloadCount).toBe(0);
// 2. 切回该标签(active = true),文本与归档未变 -> 必须保留 dirty,绝不 reload
handleFileChange(true, 1, "etc/worlddrop.etc", "version 1 text", "epoch1");
expect(formDirty).toBe(true);
expect(reloadCount).toBe(0);
expect(staleConflict).toBe(false);
// 3. 此时外部在分屏中修改了文本(DSL 变动)
handleFileChange(true, 1, "etc/worlddrop.etc", "version 2 external text", "epoch1");
// 因存在未应用修改,必须标示 conflict 且保留 dirty 表单,禁止隐式 reload 冲掉用户编辑
expect(formDirty).toBe(true);
expect(reloadCount).toBe(0);
expect(staleConflict).toBe(true);
// 4. 用户手动点击重新加载并确认放弃后
reloadCount++;
loadedText = "version 2 external text";
formDirty = false;
staleConflict = false;
expect(formDirty).toBe(false);
expect(staleConflict).toBe(false);
// 5. 在非 dirty 状态下,外部文本更新会自动重新加载
handleFileChange(true, 1, "etc/worlddrop.etc", "version 3 text", "epoch1");
expect(reloadCount).toBe(2);
expect(staleConflict).toBe(false);
});
it("Reload 异步竞态防御:读取期间文本发生漂移时拒绝覆盖并标示冲突,且不丢弃脏表单", async () => {
// 模拟 WorldDropViewer.reload 内部防御机制
const readDeferred = deferred<WorldDropDocument>();
const file = { index: 1, path: "etc/worlddrop.etc", text: "text v1", editable: true };
const guiEpoch = 1;
let loadedText = "";
let loadedArchiveIdentity = "";
let staleConflict = false;
let syncCount = 0;
let isDirty = false;
async function reload(force = false): Promise<void> {
const userConfirmedDiscard = force;
if (isDirty && !userConfirmedDiscard) {
return;
}
// 请求发起时快照
const requestText = file.text;
const requestArchiveIdentity = `${file.index}\u0000${file.path}\u0000${guiEpoch}`;
await readDeferred.promise;
// 检查变动
const currentArchiveIdentity = `${file.index}\u0000${file.path}\u0000${guiEpoch}`;
const textChangedDuringLoad = file.text !== requestText;
const identityChangedDuringLoad = currentArchiveIdentity !== requestArchiveIdentity;
if (textChangedDuringLoad || identityChangedDuringLoad) {
staleConflict = true;
return;
}
if (isDirty && !userConfirmedDiscard) {
staleConflict = true;
return;
}
loadedText = requestText;
loadedArchiveIdentity = requestArchiveIdentity;
syncCount++;
staleConflict = false;
}
// 场景 A: 异步读取期间,外部 DSL 文本被修改为 text v2
const reloadPromiseA = reload(false);
// 模拟读取中途,用户在分屏编辑 DSL 改变了 file.text
file.text = "text v2 in split pane";
// 异步读取完成
readDeferred.resolve({ revision: 1, levels: [] });
await reloadPromiseA;
// 验证:检测到竞态漂移,绝不同步旧文档,绝不将 v2 标为已加载基线,标示冲突
expect(staleConflict).toBe(true);
expect(syncCount).toBe(0);
expect(loadedText).toBe(""); // 没有被错误更新为 text v2
// 场景 B: 异步读取期间,用户在界面中进行了编辑产生了 dirty 表单
const secondDeferred = deferred<WorldDropDocument>();
file.text = "text v2";
staleConflict = false;
isDirty = false;
async function secondReload(): Promise<void> {
const userConfirmedDiscard = false;
const requestText = file.text;
const requestArchiveIdentity = `${file.index}\u0000${file.path}\u0000${guiEpoch}`;
await secondDeferred.promise;
const currentArchiveIdentity = `${file.index}\u0000${file.path}\u0000${guiEpoch}`;
if (file.text !== requestText || currentArchiveIdentity !== requestArchiveIdentity) {
staleConflict = true;
return;
}
if (isDirty && !userConfirmedDiscard) {
// 保护脏表单不被静默覆盖
staleConflict = true;
return;
}
loadedText = requestText;
syncCount++;
}
const reloadPromiseB = secondReload();
// 异步在途期间,用户点击了添加道具/修改等级,表单变为 dirty
isDirty = true;
secondDeferred.resolve({ revision: 2, levels: [] });
await reloadPromiseB;
// 验证:保护 dirty 表单,未静默覆盖,且标示冲突
expect(isDirty).toBe(true);
expect(syncCount).toBe(0);
expect(staleConflict).toBe(true);
});
describe("全局掉率 GUI 后续特性集成测试 (等级只读、区间添加、批量删除、道具筛选)", () => {
it("区间选择必须限制在已有配置等级中,且仅对区间内已有等级生效", () => {
const docLevels = [
{ level: 1, items: [] },
{ level: 15, items: [] },
{ level: 85, items: [] },
];
const editable = toEditableLevels(docLevels);
const existingLevels = editable.map((l) => l.level);
// 非法等级值校验
expect(validateLevelInterval(5, 20, existingLevels)).toContain("Lv. 5 不存在");
expect(validateLevelInterval(15, 99, existingLevels)).toContain("Lv. 99 不存在");
expect(validateLevelInterval(85, 15, existingLevels)).toContain("不能大于");
// 合法区间
expect(validateLevelInterval(1, 85, existingLevels)).toBe("");
// 区间内已有等级
const targets = getLevelsInInterval(editable, 1, 15);
expect(targets.map((t) => t.level)).toEqual([1, 15]);
});
it("添加道具时区间批量追加:已存在同 ID 道具时直接追加新行", () => {
const docLevels = [
{ level: 1, items: [{ id: 1001, weight: 50, name: "原物品" }] },
{ level: 10, items: [] },
];
const editable = toEditableLevels(docLevels);
// 批量添加 1001,权重 80
const res = batchAddItems(editable, 1, 10, {
id: 1001,
weight: 80,
name: "新添加物品",
});
expect(res.addedCount).toBe(2);
expect(res.affectedLevels).toEqual([1, 10]);
// 等级 1 现在有 2 条记录,第一条为旧物品,第二条为追加行
expect(editable[0].items.length).toBe(2);
expect(editable[0].items[0]).toMatchObject({ id: 1001, weight: 50 });
expect(editable[0].items[1]).toMatchObject({ id: 1001, weight: 80 });
// 等级 10 现在有 1 条记录
expect(editable[1].items.length).toBe(1);
expect(editable[1].items[0]).toMatchObject({ id: 1001, weight: 80 });
});
it("批量删除:选定区间内完全移除匹配道具,保留空等级,0 匹配无变更", () => {
const docLevels = [
{
level: 1,
items: [
{ id: 2001, weight: 10, name: "X" },
{ id: 2001, weight: 20, name: "X2" },
{ id: 3001, weight: 30, name: "Y" },
],
},
{ level: 20, items: [{ id: 2001, weight: 40, name: "X" }] },
{ level: 85, items: [{ id: 2001, weight: 90, name: "X-outside" }] },
];
const editable = toEditableLevels(docLevels);
// 先查询 [1, 20] 内的匹配情况
const preview = countMatchingItems(editable, 1, 20, 2001);
expect(preview.matchingLevels).toEqual([1, 20]);
expect(preview.matchingRows).toBe(3);
// 执行批量删除
const result = batchDeleteItems(editable, 1, 20, 2001);
expect(result.removedCount).toBe(3);
expect(result.affectedLevels).toEqual([1, 20]);
// 等级 1 仅剩下 3001
expect(editable[0].items.map((i) => i.id)).toEqual([3001]);
// 等级 20 变为空等级(空等级结构保留,未被删除)
expect(editable[1].items.length).toBe(0);
expect(editable[1].level).toBe(20);
// 等级 85 在区间外,依然保留 2001
expect(editable[2].items.map((i) => i.id)).toEqual([2001]);
// 再次对不存在的道具批量删除:0 匹配,无变动
const zeroResult = batchDeleteItems(editable, 1, 20, 9999);
expect(zeroResult.removedCount).toBe(0);
expect(zeroResult.affectedLevels).toEqual([]);
});
it("视图筛选:仅展示匹配道具,但总权重与参考百分比始终基于等级内全部道具真实计算", () => {
const level = {
level: 1,
key: 1,
items: [
{ id: 10, weight: 100, name: "红药水", key: 1 },
{ id: 20, weight: 300, name: "蓝药水", key: 2 },
{ id: 30, weight: 600, name: "圣水", key: 3 },
],
};
// 全量总权重为 1000
const totalWeight = calcLevelTotalWeight(level.items);
expect(totalWeight).toBe(1000);
// 用户筛选 "红药水"
const filtered = filterItems(level.items, "红药水");
expect(filtered.length).toBe(1);
expect(filtered[0].id).toBe(10);
// 关键正确性:虽然视图只展示了红药水一条,但概率计算必须使用等级真实总权重 1000(占比 10.00%),而不是 100/100 = 100.00%
const percentage = calcItemPercentage(filtered[0].weight, totalWeight);
expect(percentage).toBe("10.00%");
// 若总权重为 0,展示 0.00% 而不是 NaN
const zeroLevelTotal = 0;
expect(calcItemPercentage(filtered[0].weight, zeroLevelTotal)).toBe("0.00%");
});
it("提交应用时即使界面处于筛选状态,快照仍完整保留所有未展示的道具行", () => {
const editable = toEditableLevels([
{
level: 10,
items: [
{ id: 101, weight: 50, name: "武器A" },
{ id: 102, weight: 60, name: "防具B" },
],
},
]);
// 界面处于筛选状态,只看到 "武器A"
const displayed = filterItems(editable[0].items, "武器");
expect(displayed.length).toBe(1);
// 导出提交快照(toWorldDropLevels)
const snapshot = toWorldDropLevels(editable);
expect(snapshot[0].items.length).toBe(2);
expect(snapshot[0].items.map((i) => i.id)).toEqual([101, 102]);
});
it("从物品库精准选取物品时:仅按 item.id 全等匹配,排除同名异 ID 和数字子串冲突", () => {
const docLevels = [
{
level: 1,
items: [
{ id: 10, weight: 100, name: "强化券" },
{ id: 100, weight: 200, name: "高级强化券" }, // 数字包含 10
{ id: 210, weight: 300, name: "特级强化券" }, // 数字包含 10
{ id: 999, weight: 400, name: "强化券" }, // 同名为 "强化券",但 ID 为 999
],
},
];
const editable = toEditableLevels(docLevels);
// 1. 精准选取 hit.id = 10(无论名称为何)
const exactFilter: WorldDropFilterCriteria = { exactItemId: 10 };
const matched = filterItems(editable[0].items, exactFilter);
// 必须且仅有一项:id 严格为 10
expect(matched.length).toBe(1);
expect(matched[0].id).toBe(10);
expect(matched[0].name).toBe("强化券");
// 确认同名异 ID (999) 和包含 10 的 (100, 210) 绝不出现
expect(matched.some((i) => i.id === 999)).toBe(false);
expect(matched.some((i) => i.id === 100)).toBe(false);
expect(matched.some((i) => i.id === 210)).toBe(false);
// 2. 确认全量提交快照依然不受精准筛选影响,保持全量行完整
const snapshot = toWorldDropLevels(editable);
expect(snapshot[0].items.length).toBe(4);
expect(snapshot[0].items.map((i) => i.id)).toEqual([10, 100, 210, 999]);
});
it("精准筛选与自由文本筛选独立建模且支持清除", () => {
const items = [
{ id: 50, weight: 10, name: "A", key: 1 },
{ id: 500, weight: 20, name: "A-500", key: 2 },
];
// 精准筛选状态
const exact: WorldDropFilterCriteria = { exactItemId: 50 };
expect(isFilterCriteriaActive(exact)).toBe(true);
expect(filterItems(items, exact).map((i) => i.id)).toEqual([50]);
// 清除精准筛选
const cleared: WorldDropFilterCriteria = { exactItemId: null, query: "" };
expect(isFilterCriteriaActive(cleared)).toBe(false);
expect(filterItems(items, cleared).map((i) => i.id)).toEqual([50, 500]);
// 自由文本筛选状态
const textSearch: WorldDropFilterCriteria = { query: "50" };
expect(isFilterCriteriaActive(textSearch)).toBe(true);
expect(filterItems(items, textSearch).map((i) => i.id)).toEqual([50, 500]);
});
it("精准选择物品时,若当前等级无匹配,导航优先跳转到首个有匹配的等级", () => {
const docLevels = [
{ level: 1, items: [{ id: 10, weight: 50, name: "药水" }] },
{ level: 10, items: [] }, // 空等级
{ level: 20, items: [{ id: 8888, weight: 100, name: "史诗装备" }] },
{ level: 85, items: [{ id: 8888, weight: 200, name: "史诗装备" }] },
];
const editable = toEditableLevels(docLevels);
// 模拟当前选中 Level 1
let selectedLevel: number = 1;
const targetExactId = 8888;
const criteria: WorldDropFilterCriteria = { exactItemId: targetExactId };
// 当前 Level 1 匹配数为 0
const currentLevelMatches = countLevelMatches(editable[0], criteria);
expect(currentLevelMatches).toBe(0);
// 执行导航偏好判定:找到首个有匹配的等级 (应该为 Level 20,而不是 Level 10 或 Level 85)
if (currentLevelMatches === 0) {
const firstMatchLevel = findFirstLevelWithMatch(editable, criteria);
if (firstMatchLevel !== null) {
selectedLevel = firstMatchLevel;
}
}
expect(selectedLevel).toBe(20);
// 若再次选择 8888,因当前等级 Level 20 已有匹配,保持当前 Level 20
const newLevelMatches = countLevelMatches(editable[2], criteria);
expect(newLevelMatches).toBe(1);
// 无需跳转
expect(selectedLevel).toBe(20);
});
});
});