c1dd8def2c
新增与项目平级的协作台账,提供空白模板、独立账号授权和在线编辑。 复用共享库的访问权限与历史版本界面,拆分信息维护入口并统一工作台卡片样式。 补充在线文档字体构建支持、数据库表结构升级和回归测试。 不包含本地台账内容、账号授权记录或历史文件迁移数据。
105 lines
7.5 KiB
TypeScript
105 lines
7.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { flushPromises, mount } from "@vue/test-utils";
|
|
import HistoryDialog from "./CollaborationHistoryDialog.vue";
|
|
import type { CollaborationHistoryFile } from "../../api/collaborationHistory";
|
|
import { downloadLedgerRevision } from "../../api/ledgers";
|
|
import { prepareSaveFile } from "../../runtime";
|
|
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
|
|
|
|
const state = vi.hoisted(() => ({
|
|
api: { file: vi.fn(), revisions: vi.fn(), name: vi.fn(), restore: vi.fn() },
|
|
resolve: vi.fn(() => ({ href: "/ledger-preview" })), prompt: vi.fn(), confirm: vi.fn(),
|
|
}));
|
|
vi.mock("../../api/collaborationHistory", () => ({ createCollaborationHistoryApi: () => state.api }));
|
|
vi.mock("../../api/collaboration", () => ({ copyCollaborationRevision: vi.fn(), deleteCollaborationRevision: vi.fn() }));
|
|
vi.mock("../../api/ledgers", () => ({ downloadLedgerRevision: vi.fn(), importLedger: vi.fn() }));
|
|
vi.mock("../../runtime", () => ({ prepareSaveFile: vi.fn(), pickFiles: vi.fn() }));
|
|
vi.mock("../../utils/fileTaskFeedback", () => ({ saveFileWithFeedback: vi.fn() }));
|
|
vi.mock("../../store/auth", () => ({ useAuthStore: () => ({ user: { id: "u", full_name: "Owner" } }) }));
|
|
vi.mock("vue-router", () => ({ useRouter: () => ({ resolve: state.resolve }) }));
|
|
vi.mock("element-plus", () => ({ ElMessage: { error: vi.fn(), success: vi.fn() }, ElMessageBox: { prompt: state.prompt, confirm: state.confirm } }));
|
|
vi.mock("./CollaborationSaveAsDialog.vue", () => ({ default: { template: "<div />" } }));
|
|
|
|
const file: CollaborationHistoryFile = { id: "ledger-a", title: "台账.xlsx", extension: "xlsx", file_type: "cell", status: "ACTIVE", generation: 7, can_manage: true, can_edit: true, can_export: true, current_revision_id: "r2", owner_name: "Owner" };
|
|
const revisions = [
|
|
{ id: "r2", revision_no: 2, source: "FORCE_SAVE", created_at: "2026-09-03T01:00:00Z", file_size: 200, mime_type: "application/xlsx", change_summary: "", created_by_name: "Owner" },
|
|
{ id: "r1", revision_no: 1, source: "IMPORT", created_at: "2026-09-02T01:00:00Z", file_size: 100, mime_type: "application/xlsx", change_summary: "提交版", created_by_name: "Owner" },
|
|
];
|
|
let wrapper: ReturnType<typeof mount> | undefined;
|
|
const render = (overrides = {}, scope: "project" | "ledger" = "ledger") => {
|
|
const current = { ...file, ...overrides };
|
|
state.api.file.mockResolvedValue({ data: current });
|
|
wrapper = mount(HistoryDialog, { props: { file: current, target: scope === "ledger" ? { scope } : { scope, studyId: "study-a" }, canCreate: true }, global: {
|
|
directives: { loading: () => {} }, stubs: {
|
|
ElDialog: { props: ["modelValue"], template: '<div v-if="modelValue"><slot name="header"/><slot/></div>' },
|
|
ElButton: { template: '<button><slot/></button>' }, ElAvatar: { template: '<span><slot/></span>' },
|
|
ElTag: { template: '<span><slot/></span>' }, ElIcon: { template: '<span><slot/></span>' }, ElTooltip: { template: '<span><slot/></span>' }, ElEmpty: true,
|
|
ElDropdown: { name: "ElDropdown", emits: ["command"], template: '<div><slot/><slot name="dropdown"/></div>' },
|
|
ElDropdownMenu: { template: '<div><slot/></div>' }, ElDropdownItem: { template: '<span><slot/></span>' },
|
|
ElSelect: { props: ["modelValue"], emits: ["update:modelValue"], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"><slot/></select>' },
|
|
ElOption: { props: ["value", "label"], template: '<option :value="value">{{ label }}</option>' },
|
|
ElSwitch: { props: ["modelValue"], emits: ["update:modelValue"], template: '<input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', $event.target.checked)"/>' },
|
|
},
|
|
} });
|
|
return wrapper;
|
|
};
|
|
|
|
describe("shared project and ledger history", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks(); vi.useFakeTimers();
|
|
state.api.revisions.mockResolvedValue({ data: revisions.map(item => ({ ...item })) });
|
|
state.prompt.mockResolvedValue({ value: "复核版" }); state.confirm.mockResolvedValue("confirm");
|
|
});
|
|
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.useRealTimers(); vi.restoreAllMocks(); });
|
|
|
|
it.each(["project", "ledger"] as const)("filters and names versions in the same %s history UI", async (scope) => {
|
|
const page = render({}, scope); await flushPromises();
|
|
expect(page.findAll(".history-day-group")).toHaveLength(2);
|
|
await page.get(".history-described-filter input").setValue(true);
|
|
expect(page.findAll(".history-row")).toHaveLength(1);
|
|
await page.get(".history-source-filter").setValue("FORCE_SAVE");
|
|
expect(page.findAll(".history-row")).toHaveLength(0);
|
|
await page.get(".history-described-filter input").setValue(false);
|
|
state.api.name.mockResolvedValue({ data: { ...revisions[0], change_summary: "复核版" } });
|
|
await page.findAll(".history-row button").find(button => button.text() === "命名")!.trigger("click");
|
|
await flushPromises();
|
|
expect(state.api.name).toHaveBeenCalledWith("ledger-a", "r2", { change_summary: "复核版" });
|
|
expect(page.get(".history-description").text()).toContain("复核版");
|
|
});
|
|
|
|
it("preserves ledger no-delete and manager-only restore while using the current generation", async () => {
|
|
const editor = render({ can_manage: false, can_export: false }); await flushPromises();
|
|
expect(editor.text()).toContain("命名");
|
|
expect(editor.text()).not.toContain("恢复此版本"); expect(editor.text()).not.toContain("另存为");
|
|
editor.unmount();
|
|
const manager = render(); await flushPromises();
|
|
expect(manager.text()).not.toContain("删除版本");
|
|
const restored = { ...revisions[0], id: "r3", revision_no: 3, source: "RESTORE" };
|
|
state.api.restore.mockResolvedValue({ data: restored });
|
|
state.api.revisions.mockResolvedValue({ data: [restored, ...revisions] });
|
|
state.api.file.mockResolvedValue({ data: { ...file, generation: 8, current_revision_id: "r3" } });
|
|
manager.findComponent({ name: "ElDropdown" }).vm.$emit("command", "restore");
|
|
// Emit the same command as the real row menu with its bound revision.
|
|
await flushPromises();
|
|
expect(state.api.restore).toHaveBeenCalledWith(expect.objectContaining({ generation: 7 }), "r1");
|
|
expect(manager.findAll(".history-row")).toHaveLength(3);
|
|
expect(manager.get(".history-row.is-current").text()).toContain("历史恢复");
|
|
});
|
|
|
|
it("opens a global read-only preview and saves a historical XLSX without project context", async () => {
|
|
const open = vi.spyOn(window, "open").mockReturnValue(null);
|
|
const page = render(); await flushPromises();
|
|
await page.findAll(".history-row button").find(button => button.text() === "预览")!.trigger("click");
|
|
expect(state.resolve).toHaveBeenCalledWith({ name: "LedgerRevisionPreview", params: { fileId: "ledger-a", id: "r2" } });
|
|
expect(open).toHaveBeenCalledWith("/ledger-preview", "_blank", "noopener,noreferrer");
|
|
const destination = { kind: "web-download" };
|
|
vi.mocked(prepareSaveFile).mockResolvedValue(destination as any);
|
|
const blob = new Blob(["historical bytes"]);
|
|
vi.mocked(downloadLedgerRevision).mockResolvedValue({ data: blob } as any);
|
|
await page.findAll(".history-row button").find(button => button.text() === "另存为")!.trigger("click");
|
|
await flushPromises();
|
|
expect(downloadLedgerRevision).toHaveBeenCalledWith("ledger-a", "r2");
|
|
expect(saveFileWithFeedback).toHaveBeenCalledWith(expect.objectContaining({ data: blob, suggestedName: expect.stringMatching(/台账\[.*\]\.xlsx$/) }), expect.anything(), destination);
|
|
});
|
|
});
|