import { flushPromises, mount } from '@vue/test-utils';
|
import { defineComponent, h, provide, reactive, ref } from 'vue';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { workflowFileSubmitPendingKey } from './filePermissionContext';
|
import { uploadEditFormKey } from './uploadEditContext';
|
import { provideUploadEditLoading } from './uploadEditLoading';
|
import { useUploadOnlineEdit } from './useUploadOnlineEdit';
|
|
const mocks = vi.hoisted(() => ({
|
options: vi.fn(),
|
load: vi.fn(),
|
change: vi.fn(),
|
begin: vi.fn(),
|
cleanup: vi.fn(),
|
revisionResult: vi.fn(),
|
shared: vi.fn(),
|
lease: vi.fn(),
|
open: vi.fn(),
|
confirm: vi.fn(),
|
error: vi.fn(),
|
}));
|
vi.mock('#/api/onlineDev/uploadEdit', () => ({
|
getUploadOfficeOptions: mocks.options,
|
loadUploadEdit: mocks.load,
|
loadSharedUploadEdit: mocks.shared,
|
updateUploadEditLease: mocks.lease,
|
changeUploadEdit: mocks.change,
|
beginTemporaryUploadEdit: mocks.begin,
|
cleanupTemporaryUploadEdit: mocks.cleanup,
|
loadRevisionTemporaryResult: mocks.revisionResult,
|
}));
|
vi.mock('#/components/OnlyOfficeEditor/src/loadDocsApi', () => ({ getDocumentServerUrl: () => 'http://office' }));
|
vi.mock('#/utils/jnpf', () => ({ onlineUtils: { openOfficeDocument: mocks.open } }));
|
vi.mock('ant-design-vue', () => ({ message: { error: mocks.error }, Modal: { confirm: mocks.confirm } }));
|
const file = (id: string) => ({
|
fileId: id,
|
name: `${id}.docx`,
|
url: `/file/${id}`,
|
thumbUrl: '',
|
fileSize: 1,
|
fileExtension: 'docx',
|
uploadReceipt: 'receipt',
|
});
|
const result = (files = [file('A')], revision = 0) => ({ data: { draftId: 'draft', files, revision, canDiscard: revision > 0 } });
|
const wrappers: ReturnType<typeof mount>[] = [];
|
function setup(overrides: any = {}, recordId = 'record', initialFiles = [file('A')]) {
|
const props = reactive({ onlineEdit: true, type: 'annex', editField: 'biz_files', ...overrides });
|
const context = reactive({ modelId: 'model', recordId, ...(overrides.dmsAudit ? { dmsAudit: true } : {}) });
|
const submission = reactive({ pending: false });
|
let participant: any;
|
const current = ref(initialFiles);
|
let edit!: ReturnType<typeof useUploadOnlineEdit>;
|
let windowLoading!: ReturnType<typeof provideUploadEditLoading>;
|
const child = defineComponent({
|
setup() {
|
edit = useUploadOnlineEdit(
|
props,
|
() => current.value,
|
(next) => {
|
current.value = next as typeof initialFiles;
|
},
|
);
|
return () => h('div');
|
},
|
});
|
const wrapper = mount(
|
defineComponent({
|
setup() {
|
windowLoading = provideUploadEditLoading();
|
provide(workflowFileSubmitPendingKey, () => submission.pending);
|
provide(uploadEditFormKey, {
|
context: () => context,
|
register: (item) => {
|
participant = item;
|
return vi.fn();
|
},
|
});
|
return () => h(child);
|
},
|
}),
|
);
|
wrappers.push(wrapper);
|
return {
|
edit,
|
props,
|
context,
|
submission,
|
windowLoading: () => windowLoading.value,
|
current: () => current.value,
|
prepare: () => participant.prepare(),
|
wrapper,
|
};
|
}
|
beforeEach(() => {
|
vi.resetAllMocks();
|
mocks.options.mockResolvedValue({ data: { previewEnabled: true, editEnabled: true } });
|
mocks.load.mockResolvedValue(result());
|
mocks.cleanup.mockResolvedValue({});
|
mocks.shared.mockImplementation((context) => mocks.load(context));
|
mocks.lease.mockResolvedValue({});
|
mocks.change.mockImplementation(async (_action, command) => result(command.files || [file('A')], command.revision + 1));
|
});
|
afterEach(() => wrappers.splice(0).forEach((wrapper) => wrapper.unmount()));
|
describe('upload control online editing', () => {
|
it('keeps template provenance with the selected file and removes it on delete then ordinary upload', async () => {
|
const state = setup({ editField: 'wenjian', limit: 1 }, '');
|
await flushPromises();
|
const template = { ...file('template'), laiyuan_muban_id: 'A' };
|
await state.edit.replaceFrom(async () => [template]);
|
expect(state.current()[0]).toMatchObject({ laiyuan_muban_id: 'A' });
|
await state.edit.replace([]);
|
await state.edit.add({ ...file('uploaded'), name: template.name });
|
expect(state.current()[0]).not.toHaveProperty('laiyuan_muban_id');
|
});
|
it('opens a review-only lease without granting attachment replacement', async () => {
|
mocks.change.mockResolvedValue({ data: { ...result().data, leaseId: 'review_lease', mode: 'review', source: file('A') } });
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: false, REVIEW: true } } });
|
await flushPromises();
|
expect(state.edit.reviewOnly.value).toBe(true);
|
expect(state.edit.canEdit({ ...file('A'), name: 'A.doc' })).toBe(false);
|
expect(state.edit.canEdit(file('A'))).toBe(true);
|
await state.edit.open(file('A'));
|
expect(mocks.change).toHaveBeenCalledWith('begin', expect.objectContaining({ mode: 'review' }));
|
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ mode: 'review' }));
|
await expect(state.edit.replace([file('C')])).rejects.toThrow();
|
mocks.open.mock.calls[0]?.[0].onClose();
|
});
|
it('leaves ordinary uploads usable when the field switch is off', async () => {
|
mocks.options.mockReturnValue(new Promise(() => {}));
|
const state = setup({ onlineEdit: false });
|
await state.edit.add(file('C'));
|
expect(state.current().map((item) => item.fileId)).toEqual(['A', 'C']);
|
expect(state.edit.canEdit(file('A'))).toBe(false);
|
expect(mocks.load).not.toHaveBeenCalled();
|
await expect(state.prepare()).resolves.toBeUndefined();
|
});
|
it('blocks modification and submission until configuration loads', async () => {
|
mocks.options.mockReturnValue(new Promise(() => {}));
|
const state = setup();
|
expect(state.edit.blocked.value).toBe(true);
|
await expect(state.prepare()).rejects.toThrow('配置尚未加载');
|
await expect(state.edit.replace([file('C')])).rejects.toThrow('不可修改');
|
});
|
it('restores the persisted work file and permits DOC and DOCX only', async () => {
|
mocks.load.mockResolvedValue(result([file('B')], 4));
|
const state = setup();
|
await flushPromises();
|
expect(state.current()[0]?.fileId).toBe('B');
|
expect(state.edit.canEdit(file('B'))).toBe(true);
|
for (const name of ['B.doc', 'B.DOC', 'B.DOCX']) expect(state.edit.canEdit({ ...file('B'), name })).toBe(true);
|
for (const name of ['B.pdf', 'B.docm', 'B.xlsx', 'B.doc.exe']) expect(state.edit.canEdit({ ...file('B'), name })).toBe(false);
|
expect(await state.prepare()).toEqual({ draftId: 'draft', revision: 4 });
|
});
|
it('opens DOC and refreshes the converted DOCX attachment after saving', async () => {
|
const source = { ...file('legacy'), name: 'legacy.doc', fileExtension: 'doc' };
|
mocks.load.mockResolvedValue(result([source]));
|
const state = setup();
|
await flushPromises();
|
mocks.change.mockResolvedValue({ data: { ...result([source]).data, leaseId: 'lease', source } });
|
await state.edit.open(source);
|
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ mode: 'edit', file: expect.objectContaining({ name: 'legacy.doc' }) }));
|
mocks.load.mockResolvedValue(result([{ ...file('converted'), name: 'legacy.docx' }], 2));
|
mocks.open.mock.calls[0]?.[0].onClose();
|
await flushPromises();
|
expect(state.current()[0]).toMatchObject({ fileId: 'converted', name: 'legacy.docx', fileExtension: 'docx' });
|
expect(await state.prepare()).toEqual({ draftId: 'draft', revision: 2 });
|
});
|
it('queues concurrent upload completions without losing files', async () => {
|
const state = setup();
|
await flushPromises();
|
const uploads = [state.edit.add(file('C')), state.edit.add(file('D'))];
|
await expect(state.prepare()).rejects.toThrow('等待保存');
|
await Promise.all(uploads);
|
expect(state.current().map((item) => item.fileId)).toEqual(['A', 'C', 'D']);
|
expect(mocks.change.mock.calls[1]?.[1].revision).toBe(1);
|
});
|
it('fences form save while editing and refreshes the saved result on close', async () => {
|
const state = setup();
|
await flushPromises();
|
mocks.change.mockResolvedValue({ data: { ...result().data, leaseId: 'lease', source: file('A') } });
|
await state.edit.open(file('A'));
|
await expect(state.prepare()).rejects.toThrow('关闭附件编辑器');
|
expect(mocks.open.mock.calls[0]?.[0].file.fileId).toBe('lease');
|
mocks.load.mockResolvedValue(result([file('B')], 2));
|
mocks.open.mock.calls[0]?.[0].onClose();
|
await flushPromises();
|
expect(state.current()[0]?.fileId).toBe('B');
|
expect(await state.prepare()).toEqual({ draftId: 'draft', revision: 2 });
|
});
|
it('supplies the saved size after editing an initially zero-byte attachment', async () => {
|
const empty = { ...file('A'), fileSize: 0 };
|
mocks.load.mockResolvedValue(result([empty]));
|
const state = setup();
|
await flushPromises();
|
expect(state.current()[0]?.fileSize).toBe(0);
|
mocks.change.mockResolvedValue({ data: { ...result([empty]).data, leaseId: 'lease', source: empty } });
|
await state.edit.open(empty);
|
await expect(state.prepare()).rejects.toThrow('关闭附件编辑器');
|
mocks.load.mockResolvedValue(result([{ ...file('B'), fileSize: 2048 }], 2));
|
mocks.open.mock.calls[0]?.[0].onClose();
|
await flushPromises();
|
expect(await state.prepare()).toEqual({ draftId: 'draft', revision: 2 });
|
expect(state.current()[0]).toMatchObject({ fileId: 'B', fileSize: 2048 });
|
});
|
it('ignores an old editor close after changing records', async () => {
|
const state = setup();
|
await flushPromises();
|
mocks.change.mockResolvedValue({ data: { ...result().data, leaseId: 'lease', source: file('A') } });
|
await state.edit.open(file('A'));
|
const close = mocks.open.mock.calls[0]?.[0].onClose;
|
mocks.load.mockResolvedValue(result([file('C')], 5));
|
state.context.recordId = 'other-record';
|
await flushPromises();
|
const calls = mocks.load.mock.calls.length;
|
close();
|
await flushPromises();
|
expect(mocks.load).toHaveBeenCalledTimes(calls);
|
expect(state.current()[0]?.fileId).toBe('C');
|
});
|
it('unlocks uploads and form save when the document window cannot open', async () => {
|
const state = setup();
|
await flushPromises();
|
mocks.change.mockResolvedValue({ data: { ...result().data, leaseId: 'lease', source: file('A') } });
|
mocks.open.mockImplementation((config) => config.onOpenError(new Error('window unavailable')));
|
await state.edit.open(file('A'));
|
expect(state.edit.blocked.value).toBe(false);
|
await expect(state.prepare()).resolves.toEqual({ draftId: 'draft', revision: 0 });
|
});
|
it('unlocks uploads after a synchronous document open failure', async () => {
|
const state = setup();
|
await flushPromises();
|
mocks.change.mockResolvedValue({ data: { ...result().data, leaseId: 'lease', source: file('A') } });
|
mocks.open.mockImplementation(() => {
|
throw new Error('open failed');
|
});
|
await state.edit.open(file('A'));
|
expect(state.edit.blocked.value).toBe(false);
|
await expect(state.prepare()).resolves.toBeDefined();
|
});
|
it('discards untracked edits and cleans the temporary session', async () => {
|
const state = setup({}, '');
|
await flushPromises();
|
mocks.begin.mockResolvedValue({ data: { ...result().data, leaseId: 'temporary', source: file('A') } });
|
await state.edit.open(file('A'));
|
expect(state.edit.canDiscard.value).toBe(false);
|
mocks.open.mock.calls[0]?.[0].onClose({ file: file('B') });
|
await flushPromises();
|
expect(state.current()[0]?.fileId).toBe('A');
|
expect(mocks.cleanup).toHaveBeenCalledWith('temporary');
|
expect(mocks.load).not.toHaveBeenCalled();
|
});
|
it('restores the server baseline on discard', async () => {
|
mocks.load.mockResolvedValue(result([file('B')], 4));
|
const state = setup();
|
await flushPromises();
|
state.edit.discard();
|
mocks.change.mockResolvedValue(result([file('A')], 5));
|
await mocks.confirm.mock.calls[0]?.[0].onOk();
|
expect(mocks.change).toHaveBeenCalledWith('discard', { draftId: 'draft', revision: 4 });
|
expect(state.current()[0]?.fileId).toBe('A');
|
});
|
it('uses the draft restore decision for a controlled attachment', async () => {
|
mocks.load.mockResolvedValue(result([file('B')], 4));
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true } } });
|
await flushPromises();
|
expect(state.edit.canDiscard.value).toBe(true);
|
state.edit.discard();
|
mocks.change.mockResolvedValue({ data: { ...result([file('A')], 5).data, canDiscard: false } });
|
await mocks.confirm.mock.calls[0]?.[0].onOk();
|
expect(state.current()[0]?.fileId).toBe('A');
|
expect(state.edit.canDiscard.value).toBe(false);
|
});
|
it('refreshes restoration after replacement permission changes without overwriting form files', async () => {
|
mocks.load.mockResolvedValue(result([file('B')], 4));
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true, REPLACE: true } } });
|
await flushPromises();
|
state.edit.discard();
|
const confirm = mocks.confirm.mock.calls[0]?.[0].onOk;
|
mocks.load.mockResolvedValue({ data: { ...result([file('C')], 4).data, canDiscard: false } });
|
state.props.fileAccess.actions.REPLACE = false;
|
expect(state.edit.canDiscard.value).toBe(false);
|
await flushPromises();
|
expect(mocks.load).toHaveBeenCalledTimes(2);
|
expect(state.edit.canDiscard.value).toBe(false);
|
expect(state.current()[0]?.fileId).toBe('B');
|
mocks.load.mockResolvedValue(result([file('B')], 4));
|
state.props.fileAccess.actions.REPLACE = true;
|
await flushPromises();
|
expect(state.edit.canDiscard.value).toBe(true);
|
await confirm();
|
expect(mocks.change).not.toHaveBeenCalled();
|
});
|
it('ignores an old restoration decision after switching child rows', async () => {
|
mocks.load.mockResolvedValue(result([file('B')], 4));
|
const state = setup({ editParentField: 'rows', editRowId: 'row-1', fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true, ADD: true } } });
|
await flushPromises();
|
let resolve!: (value: any) => void;
|
mocks.load.mockReturnValueOnce(
|
new Promise((done) => {
|
resolve = done;
|
}),
|
);
|
state.props.fileAccess.actions.ADD = false;
|
await flushPromises();
|
mocks.load.mockResolvedValue({ data: { ...result([file('C')], 2).data, draftId: 'other', canDiscard: false } });
|
state.props.editRowId = 'row-2';
|
await flushPromises();
|
resolve(result([file('B')], 4));
|
await flushPromises();
|
expect(state.edit.canDiscard.value).toBe(false);
|
expect(state.current()[0]?.fileId).toBe('C');
|
});
|
it('closes restoration on a failed permission refresh', async () => {
|
mocks.load.mockResolvedValue(result([file('B')], 4));
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true, REMOVE: true } } });
|
await flushPromises();
|
mocks.load.mockRejectedValue(new Error('offline'));
|
state.props.fileAccess.actions.REMOVE = false;
|
await flushPromises();
|
expect(state.edit.canDiscard.value).toBe(false);
|
expect(state.current()[0]?.fileId).toBe('B');
|
expect(await state.prepare()).toEqual({ draftId: 'draft', revision: 4 });
|
});
|
it('keeps editor completion when only restoration permissions change', async () => {
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true, REPLACE: true } } });
|
await flushPromises();
|
mocks.change.mockResolvedValue({ data: { ...result().data, leaseId: 'lease', source: file('A') } });
|
await state.edit.open(file('A'));
|
state.props.fileAccess.actions.REPLACE = false;
|
await flushPromises();
|
expect(mocks.load).toHaveBeenCalledTimes(1);
|
await expect(state.prepare()).rejects.toThrow('关闭附件编辑器');
|
mocks.load.mockResolvedValue(result([file('B')], 2));
|
mocks.open.mock.calls[0]?.[0].onClose();
|
await flushPromises();
|
expect(state.current()[0]?.fileId).toBe('B');
|
expect(state.edit.canDiscard.value).toBe(true);
|
expect(await state.prepare()).toEqual({ draftId: 'draft', revision: 2 });
|
});
|
it('rejects a restore confirmation for an earlier draft revision', async () => {
|
mocks.load.mockResolvedValue(result([file('B')], 4));
|
const state = setup();
|
await flushPromises();
|
state.edit.discard();
|
await state.edit.replace([file('C')]);
|
mocks.change.mockClear();
|
await mocks.confirm.mock.calls[0]?.[0].onOk();
|
expect(mocks.change).not.toHaveBeenCalled();
|
expect(state.current()[0]?.fileId).toBe('C');
|
});
|
it('honors remove and replacement separately even without online editing', async () => {
|
const state = setup({ onlineEdit: false, fileAccess: { status: 'ready', actions: { ADD: true } } });
|
await flushPromises();
|
await state.edit.add(file('C'));
|
expect(state.current().map((item) => item.fileId)).toEqual(['A', 'C']);
|
await expect(state.edit.replace([])).rejects.toThrow('没有附件操作权限');
|
await expect(state.edit.replace([file('D')])).rejects.toThrow('没有附件操作权限');
|
expect(mocks.change).not.toHaveBeenCalled();
|
});
|
it('rechecks permission after a pending open request', async () => {
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true } } });
|
await flushPromises();
|
let resolve!: (value: any) => void;
|
mocks.change.mockReturnValue(
|
new Promise((done) => {
|
resolve = done;
|
}),
|
);
|
const opening = state.edit.open(file('A'));
|
state.props.fileAccess = { status: 'ready', actions: { EDIT: false } };
|
resolve({ data: { ...result([file('B')]).data, leaseId: 'lease', source: file('A') } });
|
await opening;
|
expect(mocks.open).not.toHaveBeenCalled();
|
expect(state.current()[0]?.fileId).toBe('A');
|
});
|
it('rechecks discard permission after confirmation is opened', async () => {
|
mocks.load.mockResolvedValue(result([file('B')], 4));
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true } } });
|
await flushPromises();
|
state.edit.discard();
|
expect(mocks.confirm).toHaveBeenCalledTimes(1);
|
state.props.fileAccess = { status: 'loading' };
|
await mocks.confirm.mock.calls[0]?.[0].onOk();
|
expect(mocks.change).not.toHaveBeenCalled();
|
});
|
it('permits native upload and save when editing is denied', async () => {
|
const state = setup({ fileAccess: { status: 'ready', actions: { ADD: true, REMOVE: true } } });
|
await flushPromises();
|
expect(mocks.load).not.toHaveBeenCalled();
|
await state.edit.add(file('C'));
|
await state.edit.replace([file('C')]);
|
expect(state.current().map((item) => item.fileId)).toEqual(['C']);
|
await expect(state.prepare()).resolves.toBeUndefined();
|
expect(mocks.change).not.toHaveBeenCalled();
|
});
|
it('loads a work draft when editing is granted but not on equivalent refreshes', async () => {
|
const state = setup({ fileAccess: { status: 'ready', actions: { EDIT: false } } });
|
await flushPromises();
|
state.props.fileAccess = { status: 'ready', actions: { VIEW: true, EDIT: true } };
|
await flushPromises();
|
expect(mocks.load).toHaveBeenCalledTimes(1);
|
expect(state.edit.canEdit(file('A'))).toBe(true);
|
state.props.fileAccess = { status: 'ready', actions: { VIEW: true, EDIT: true, DOWNLOAD: false } };
|
await flushPromises();
|
expect(mocks.load).toHaveBeenCalledTimes(1);
|
});
|
it('activates child editing at an allowed node and blocks reopening after revocation', async () => {
|
const state = setup({ editParentField: 'rows', editRowId: 'row-1', fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: false } } });
|
await flushPromises();
|
expect(state.edit.canEdit(file('A'))).toBe(false);
|
state.props.fileAccess = { status: 'ready', actions: { VIEW: true, EDIT: true, DOWNLOAD: false } };
|
await flushPromises();
|
expect(state.edit.canEdit(file('A'))).toBe(true);
|
expect(mocks.load).toHaveBeenCalledWith(expect.objectContaining({ parentField: 'rows', rowId: 'row-1' }));
|
mocks.change.mockResolvedValue({ data: { ...result().data, leaseId: 'lease', source: file('A') } });
|
await state.edit.open(file('A'));
|
expect(mocks.open).toHaveBeenCalledOnce();
|
mocks.open.mock.calls[0]?.[0].onClose();
|
await flushPromises();
|
state.props.fileAccess = { status: 'ready', actions: { VIEW: true, EDIT: false, DOWNLOAD: true } };
|
await flushPromises();
|
expect(state.edit.canEdit(file('A'))).toBe(false);
|
await state.edit.open(file('A'));
|
expect(mocks.open).toHaveBeenCalledOnce();
|
});
|
it('ignores a load crossing revocation even when permission is restored', async () => {
|
let resolve!: (value: any) => void;
|
mocks.load.mockReturnValueOnce(
|
new Promise((done) => {
|
resolve = done;
|
}),
|
);
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true } } });
|
await flushPromises();
|
state.props.fileAccess = { status: 'ready', actions: { EDIT: false } };
|
await flushPromises();
|
mocks.load.mockResolvedValue(result([file('C')], 3));
|
state.props.fileAccess = { status: 'ready', actions: { VIEW: true, EDIT: true } };
|
await flushPromises();
|
resolve(result([file('B')], 2));
|
await flushPromises();
|
expect(state.current()[0]?.fileId).toBe('C');
|
});
|
it('keeps the editor close guard during revocation and releases it on close', async () => {
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true } } });
|
await flushPromises();
|
mocks.change.mockResolvedValue({ data: { ...result().data, leaseId: 'lease', source: file('A') } });
|
await state.edit.open(file('A'));
|
state.props.fileAccess = { status: 'ready', actions: { EDIT: false } };
|
await flushPromises();
|
await expect(state.prepare()).rejects.toThrow('关闭附件编辑器');
|
const calls = mocks.load.mock.calls.length;
|
mocks.open.mock.calls[0]?.[0].onClose();
|
await flushPromises();
|
expect(mocks.load).toHaveBeenCalledTimes(calls);
|
expect(state.edit.blocked.value).toBe(false);
|
});
|
|
it('does not overwrite native unsaved uploads when edit permission becomes available', async () => {
|
const state = setup({ fileAccess: { status: 'ready', actions: { ADD: true } } });
|
await flushPromises();
|
await state.edit.add(file('C'));
|
state.props.fileAccess = { status: 'ready', actions: { ADD: true, VIEW: true, EDIT: true } };
|
await flushPromises();
|
expect(mocks.load).not.toHaveBeenCalled();
|
expect(state.current().map((item) => item.fileId)).toEqual(['A', 'C']);
|
expect(state.edit.canEdit(file('C'))).toBe(false);
|
await expect(state.prepare()).resolves.toBeUndefined();
|
});
|
it('requires view permission as well as edit permission to load an editor draft', async () => {
|
const state = setup({ fileAccess: { status: 'ready', actions: { EDIT: true, VIEW: false } } });
|
await flushPromises();
|
expect(state.edit.canEdit(file('A'))).toBe(false);
|
expect(mocks.load).not.toHaveBeenCalled();
|
});
|
});
|
|
describe('workflow shared working files', () => {
|
const viewOnly = { status: 'ready', actions: { VIEW: true, EDIT: false } };
|
it('keeps ordinary preview on the last adopted attachment', async () => {
|
mocks.shared.mockResolvedValue({
|
data: { ...result([file('B')], 2).data, baselineFiles: [file('A')], editOrigins: { B: 'A' }, shared: true },
|
});
|
const state = setup({ disabled: true, detailed: true, fileAccess: viewOnly });
|
await flushPromises();
|
expect(state.current()[0]?.fileId).toBe('B');
|
expect(state.edit.formalFile(file('B'))?.fileId).toBe('A');
|
expect(state.edit.formalFile(file('C'))).toBeUndefined();
|
});
|
it('loads the shared result for a read-only viewer and carries its version into native save', async () => {
|
mocks.shared.mockResolvedValue({ data: { ...result([file('B')], 7).data, shared: true } });
|
const state = setup({ disabled: true, detailed: true, fileAccess: viewOnly });
|
await flushPromises();
|
expect(state.current()[0]?.fileId).toBe('B');
|
expect(state.edit.canEdit(file('B'))).toBe(false);
|
expect(await state.prepare()).toEqual({ draftId: 'draft', revision: 7 });
|
expect(mocks.load).not.toHaveBeenCalled();
|
});
|
it('allows explicitly granted editing in a read-only field without allowing upload replacement', async () => {
|
const state = setup({ disabled: true, detailed: true, fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true } } });
|
await flushPromises();
|
expect(state.edit.canEdit(file('A'))).toBe(true);
|
await expect(state.edit.replace([file('C')])).rejects.toThrow();
|
});
|
it('releases a shared lease before loading its saved result', async () => {
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true } } });
|
await flushPromises();
|
mocks.change.mockResolvedValue({ data: { ...result().data, shared: true, leaseId: 'lease', source: file('A') } });
|
await state.edit.open(file('A'));
|
let finish!: () => void;
|
mocks.lease.mockImplementation(
|
() =>
|
new Promise<void>((resolve) => {
|
finish = resolve;
|
}),
|
);
|
mocks.shared.mockClear();
|
mocks.open.mock.calls[0]?.[0].onClose();
|
await flushPromises();
|
expect(mocks.lease).toHaveBeenCalledWith('close', 'draft', 'lease');
|
expect(mocks.shared).not.toHaveBeenCalled();
|
await expect(state.prepare()).rejects.toThrow('关闭附件编辑器');
|
finish();
|
await flushPromises();
|
expect(mocks.shared).toHaveBeenCalledOnce();
|
});
|
it('does not load shared content without VIEW', async () => {
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: false, EDIT: true } } });
|
await flushPromises();
|
expect(mocks.shared).not.toHaveBeenCalled();
|
expect(state.edit.canEdit(file('A'))).toBe(false);
|
});
|
it('refreshes shared content while idle and renews an open edit lease', async () => {
|
vi.useFakeTimers();
|
try {
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true } } });
|
await flushPromises();
|
mocks.shared.mockResolvedValue({ data: { ...result([file('B')], 2).data, shared: true } });
|
await vi.advanceTimersByTimeAsync(15_000);
|
expect(state.current()[0]?.fileId).toBe('B');
|
mocks.change.mockResolvedValue({ data: { ...result([file('B')], 3).data, shared: true, leaseId: 'lease', source: file('B') } });
|
await state.edit.open(file('B'));
|
mocks.shared.mockClear();
|
await vi.advanceTimersByTimeAsync(30_000);
|
expect(mocks.lease).toHaveBeenCalledWith('heartbeat', 'draft', 'lease');
|
expect(mocks.shared).not.toHaveBeenCalled();
|
state.wrapper.unmount();
|
} finally {
|
vi.useRealTimers();
|
}
|
});
|
it('ignores an in-flight heartbeat failure after the editor releases its lease', async () => {
|
vi.useFakeTimers();
|
try {
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true } } });
|
await flushPromises();
|
mocks.change.mockResolvedValue({ data: { ...result().data, shared: true, leaseId: 'lease', source: file('A') } });
|
await state.edit.open(file('A'));
|
let rejectHeartbeat!: (error: Error) => void;
|
mocks.lease.mockImplementation((action) => {
|
if (action !== 'heartbeat') return Promise.resolve({});
|
return new Promise((_resolve, reject) => {
|
rejectHeartbeat = reject;
|
});
|
});
|
await vi.advanceTimersByTimeAsync(30_000);
|
expect(mocks.lease).toHaveBeenCalledWith('heartbeat', 'draft', 'lease');
|
mocks.open.mock.calls[0]?.[0].onClose();
|
await flushPromises();
|
expect(mocks.lease).toHaveBeenCalledWith('close', 'draft', 'lease');
|
rejectHeartbeat(new Error('lease already released'));
|
await flushPromises();
|
expect(state.edit.failure.value).toBe('');
|
expect(mocks.error).not.toHaveBeenCalled();
|
} finally {
|
vi.useRealTimers();
|
}
|
});
|
it('pauses shared polling during approval and ignores a failure from before submission', async () => {
|
vi.useFakeTimers();
|
try {
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true } } });
|
await flushPromises();
|
let reject!: (error: Error) => void;
|
mocks.shared.mockReturnValueOnce(
|
new Promise((_resolve, fail) => {
|
reject = fail;
|
}),
|
);
|
await vi.advanceTimersByTimeAsync(15_000);
|
expect(mocks.shared).toHaveBeenLastCalledWith(expect.any(Object), true);
|
state.submission.pending = true;
|
mocks.shared.mockClear();
|
await vi.advanceTimersByTimeAsync(30_000);
|
expect(mocks.shared).not.toHaveBeenCalled();
|
state.submission.pending = false;
|
reject(new Error('permission transitioning'));
|
await flushPromises();
|
expect(state.edit.failure.value).toBe('');
|
expect(state.current()[0]?.fileId).toBe('A');
|
mocks.shared.mockResolvedValue({ data: { ...result([file('B')], 2).data, shared: true } });
|
await vi.advanceTimersByTimeAsync(15_000);
|
expect(state.current()[0]?.fileId).toBe('B');
|
} finally {
|
vi.useRealTimers();
|
}
|
});
|
it('still blocks edits on a genuine idle synchronization failure', async () => {
|
vi.useFakeTimers();
|
try {
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true } } });
|
await flushPromises();
|
mocks.shared.mockRejectedValueOnce(new Error('permission unavailable'));
|
await vi.advanceTimersByTimeAsync(15_000);
|
expect(state.edit.failure.value).toContain('共享附件同步失败');
|
expect(state.edit.blocked.value).toBe(true);
|
await expect(state.prepare()).rejects.toThrow('共享附件同步失败');
|
} finally {
|
vi.useRealTimers();
|
}
|
});
|
});
|
|
describe('template reference replacement', () => {
|
it('does not request a template when online editing is unavailable', async () => {
|
const state = setup({ onlineEdit: false });
|
await flushPromises();
|
const load = vi.fn();
|
await expect(state.edit.replaceFrom(load)).rejects.toThrow('不可修改');
|
expect(load).not.toHaveBeenCalled();
|
expect(state.current().map((f) => f.fileId)).toEqual(['A']);
|
});
|
it('replaces the full attachment set and blocks submission during authorization', async () => {
|
const state = setup();
|
await flushPromises();
|
let resolve!: (files: ReturnType<typeof file>[]) => void;
|
const selected = state.edit.replaceFrom(
|
() =>
|
new Promise((done) => {
|
resolve = done;
|
}),
|
);
|
expect(state.edit.blocked.value).toBe(true);
|
await expect(state.prepare()).rejects.toThrow('等待保存');
|
resolve([file('template')]);
|
await selected;
|
expect(state.current().map((f) => f.fileId)).toEqual(['template']);
|
expect(await state.prepare()).toEqual({ draftId: 'draft', revision: 1 });
|
});
|
it('ignores authorization completed after switching records', async () => {
|
const state = setup();
|
await flushPromises();
|
let resolve!: (files: ReturnType<typeof file>[]) => void;
|
const selected = state.edit.replaceFrom(
|
() =>
|
new Promise((done) => {
|
resolve = done;
|
}),
|
);
|
state.context.recordId = 'other';
|
await flushPromises();
|
resolve([file('template')]);
|
await expect(selected).rejects.toThrow('已变化');
|
expect(mocks.change).not.toHaveBeenCalled();
|
});
|
it('preserves existing attachments and releases the barrier when authorization fails', async () => {
|
const state = setup();
|
await flushPromises();
|
await expect(
|
state.edit.replaceFrom(async () => {
|
throw new Error('denied');
|
}),
|
).rejects.toThrow('denied');
|
expect(state.current().map((f) => f.fileId)).toEqual(['A']);
|
expect(state.edit.blocked.value).toBe(false);
|
expect(mocks.change).not.toHaveBeenCalled();
|
});
|
});
|
|
describe('unsaved DMS revision copies', () => {
|
const source = () => ({ ...file('A'), uploadReceipt: undefined, revisionEditReceipt: 'source-receipt' });
|
function lease() {
|
mocks.begin.mockResolvedValue({ data: { ...result().data, revisionTemporary: true, leaseId: 'revision-lease', source: source() } });
|
}
|
const saved = () => ({ data: { revisionTemporary: true, leaseId: 'revision-lease', files: [{ ...file('copy'), revisionEditReceipt: 'copy-receipt' }] } });
|
it('shows opening immediately and isolates late failures after a context switch', async () => {
|
let rejectOld!: (error: Error) => void;
|
let rejectNew!: (error: Error) => void;
|
mocks.begin
|
.mockReturnValueOnce(
|
new Promise((_done, fail) => {
|
rejectOld = fail;
|
}),
|
)
|
.mockReturnValueOnce(
|
new Promise((_done, fail) => {
|
rejectNew = fail;
|
}),
|
);
|
const state = setup({}, '');
|
expect(state.edit.editStatus.value).toBe('');
|
await flushPromises();
|
const oldOpening = state.edit.open(source());
|
expect(state.edit.editStatus.value).toBe('正在打开文档…');
|
expect(mocks.open).not.toHaveBeenCalled();
|
state.context.modelId = 'other-model';
|
expect(state.edit.editStatus.value).toBe('');
|
await flushPromises();
|
const newOpening = state.edit.open(source());
|
rejectOld(new Error('old request'));
|
await oldOpening;
|
expect(state.edit.editStatus.value).toBe('正在打开文档…');
|
rejectNew(new Error('new request'));
|
await newOpening;
|
expect(state.edit.editStatus.value).toBe('');
|
expect(state.edit.busy.value).toBe(false);
|
});
|
it('keeps synchronization visible through result retrieval and cleanup', async () => {
|
lease();
|
let resolveResult!: (value: any) => void;
|
let resolveCleanup!: (value: any) => void;
|
mocks.revisionResult.mockReturnValue(
|
new Promise((done) => {
|
resolveResult = done;
|
}),
|
);
|
mocks.cleanup.mockReturnValue(
|
new Promise((done) => {
|
resolveCleanup = done;
|
}),
|
);
|
const state = setup({}, '');
|
await flushPromises();
|
await state.edit.open(source());
|
expect(state.edit.editStatus.value).toBe('');
|
mocks.open.mock.calls[0][0].onClose();
|
expect(state.edit.editStatus.value).toBe('正在同步编辑结果…');
|
resolveResult(saved());
|
await flushPromises();
|
expect(state.current()[0].fileId).toBe('copy');
|
expect(state.edit.editStatus.value).toBe('正在同步编辑结果…');
|
resolveCleanup({});
|
await flushPromises();
|
expect(state.edit.editStatus.value).toBe('');
|
await expect(state.prepare()).resolves.toBeUndefined();
|
});
|
it('offers imported files editing and adopts saved copy before native submit', async () => {
|
lease();
|
mocks.revisionResult.mockResolvedValue(saved());
|
const state = setup({}, '');
|
await flushPromises();
|
expect(state.edit.canEdit(source())).toBe(true);
|
await state.edit.open(source());
|
await expect(state.prepare()).rejects.toThrow();
|
mocks.open.mock.calls[0][0].onClose();
|
await flushPromises();
|
expect(state.current()[0].fileId).toBe('copy');
|
expect(state.current()[0]).toHaveProperty('revisionEditReceipt', 'copy-receipt');
|
await expect(state.prepare()).resolves.toBeUndefined();
|
expect(mocks.cleanup).toHaveBeenCalledWith('revision-lease');
|
});
|
it('keeps failed result synchronization retryable and blocks native save', async () => {
|
lease();
|
mocks.revisionResult.mockRejectedValueOnce(new Error('network'));
|
const state = setup({}, '');
|
await flushPromises();
|
await state.edit.open(source());
|
mocks.open.mock.calls[0][0].onClose();
|
await flushPromises();
|
expect(state.current()[0].fileId).toBe('A');
|
expect(state.edit.pendingRevisionResult.value).toBeDefined();
|
expect(state.edit.editStatus.value).toBe('');
|
await expect(state.prepare()).rejects.toThrow('同步');
|
mocks.revisionResult.mockResolvedValue(saved());
|
const retry = state.edit.retryRevisionResult();
|
expect(state.edit.editStatus.value).toBe('正在同步编辑结果…');
|
await retry;
|
expect(state.edit.editStatus.value).toBe('');
|
expect(state.current()[0].fileId).toBe('copy');
|
await expect(state.prepare()).resolves.toBeUndefined();
|
});
|
it('does not apply a late result after source files changed', async () => {
|
lease();
|
let resolve!: (value: any) => void;
|
mocks.revisionResult.mockReturnValue(
|
new Promise((done) => {
|
resolve = done;
|
}),
|
);
|
const state = setup({}, '');
|
await flushPromises();
|
await state.edit.open(source());
|
mocks.open.mock.calls[0][0].onClose();
|
state.current().splice(0, 1, file('new-source'));
|
resolve(saved());
|
await flushPromises();
|
expect(state.current()[0].fileId).toBe('new-source');
|
expect(state.edit.pendingRevisionResult.value).toBeUndefined();
|
});
|
it('does not create a pending result when an old editor closes after context switch', async () => {
|
lease();
|
const state = setup({}, '');
|
await flushPromises();
|
await state.edit.open(source());
|
state.context.modelId = 'other-model';
|
await flushPromises();
|
mocks.open.mock.calls[0][0].onClose();
|
await flushPromises();
|
expect(mocks.revisionResult).not.toHaveBeenCalled();
|
expect(state.edit.pendingRevisionResult.value).toBeUndefined();
|
});
|
it('cleans its revision lease on unmount', async () => {
|
lease();
|
const state = setup({}, '');
|
await flushPromises();
|
await state.edit.open(source());
|
state.wrapper.unmount();
|
expect(mocks.cleanup).toHaveBeenCalledWith('revision-lease');
|
});
|
});
|
|
describe('upload submit guard diagnostics', () => {
|
afterEach(() => vi.restoreAllMocks());
|
|
it('identifies initial shared loading without editing and records the field and request stage', async () => {
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
let complete!: (value: ReturnType<typeof result>) => void;
|
mocks.shared.mockImplementation(
|
() =>
|
new Promise((resolve) => {
|
complete = resolve;
|
}),
|
);
|
const state = setup({ editField: 'wenjian', editParentField: 'records', editRowId: 'row-1', fileAccess: { status: 'ready', actions: { VIEW: true } } });
|
await flushPromises();
|
await expect(state.prepare()).rejects.toThrow('请关闭附件编辑器并等待保存完成后再提交表单');
|
expect(warn).toHaveBeenCalledTimes(1);
|
const [prefix, serialized] = warn.mock.calls[0]!;
|
expect(prefix).toBe('[UploadEditSubmitGuard]');
|
const diagnostic = JSON.parse(serialized as string);
|
expect(diagnostic).toMatchObject({ version: 1, reason: 'busy', prepareAttempt: 1 });
|
expect(diagnostic.history.at(-1)).toMatchObject({
|
event: 'prepare:blocked',
|
context: { field: 'wenjian', parentField: 'records', rowId: 'row-1', recordId: 'record' },
|
state: { editorOpen: false, busy: true, sharedReadable: true, pendingUploads: 0 },
|
});
|
expect(diagnostic.history.some((item: any) => item.event === 'load:draft-request')).toBe(true);
|
expect(diagnostic.history.some((item: any) => item.event === 'editor:open-start')).toBe(false);
|
// 快照不包含原始附件及其上传凭据,且不会随后续状态变化改变。
|
expect(serialized).not.toContain('uploadReceipt');
|
expect(serialized).not.toContain('/file/A');
|
complete(result());
|
await flushPromises();
|
await expect(state.prepare()).resolves.toEqual({ draftId: 'draft', revision: 0 });
|
expect(JSON.parse(serialized as string).history.at(-1).state.busy).toBe(true);
|
expect(warn).toHaveBeenCalledTimes(1);
|
});
|
|
it('distinguishes an open editor and preserves the close callback in the bounded history', async () => {
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true } } });
|
await flushPromises();
|
mocks.change.mockResolvedValue({ data: { ...result().data, shared: true, leaseId: 'private-lease', source: file('A') } });
|
await state.edit.open(file('A'));
|
await expect(state.prepare()).rejects.toThrow('关闭附件编辑器');
|
expect(JSON.parse(warn.mock.calls[0]![1] as string).reason).toBe('editor-open');
|
let close!: () => void;
|
mocks.lease.mockImplementation(
|
() =>
|
new Promise<void>((resolve) => {
|
close = resolve;
|
}),
|
);
|
mocks.open.mock.calls[0]![0].onClose();
|
await flushPromises();
|
await expect(state.prepare()).rejects.toThrow('关闭附件编辑器');
|
const serialized = warn.mock.calls[1]![1] as string;
|
const diagnostic = JSON.parse(serialized);
|
expect(diagnostic.history.some((item: any) => item.event === 'editor:close-callback')).toBe(true);
|
expect(diagnostic.history.at(-1).state).toMatchObject({ editorOpen: true, busy: true, editPhase: 'syncing' });
|
expect(serialized).not.toContain('private-lease');
|
for (let i = 0; i < 25; i++) await expect(state.prepare()).rejects.toThrow('关闭附件编辑器');
|
expect(JSON.parse(warn.mock.calls.at(-1)![1] as string).history).toHaveLength(40);
|
close();
|
await flushPromises();
|
await expect(state.prepare()).resolves.toEqual({ draftId: 'draft', revision: 0 });
|
});
|
|
it('distinguishes queued uploads from a busy operation without opening an editor', async () => {
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
const state = setup();
|
await flushPromises();
|
const upload = state.edit.add(file('B'));
|
await expect(state.prepare()).rejects.toThrow('关闭附件编辑器');
|
const diagnostic = JSON.parse(warn.mock.calls[0]![1] as string);
|
expect(diagnostic.reason).toBe('pending-uploads');
|
expect(diagnostic.history.at(-1).state).toMatchObject({ editorOpen: false, busy: false, pendingUploads: 1 });
|
expect(diagnostic.history.some((item: any) => item.event === 'upload:queued')).toBe(true);
|
await upload;
|
await expect(state.prepare()).resolves.toBeDefined();
|
});
|
|
it('preserves the original guard error even when console logging fails', async () => {
|
vi.spyOn(console, 'warn').mockImplementation(() => {
|
throw new Error('console unavailable');
|
});
|
const state = setup();
|
await flushPromises();
|
mocks.change.mockResolvedValue({ data: { ...result().data, leaseId: 'lease', source: file('A') } });
|
await state.edit.open(file('A'));
|
await expect(state.prepare()).rejects.toThrow('请关闭附件编辑器并等待保存完成后再提交表单');
|
});
|
});
|
|
describe('shared attachment window loading', () => {
|
const shared = { fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true } } };
|
|
it('covers configuration and initial shared loading, then releases on failure', async () => {
|
let configured!: (value: any) => void;
|
mocks.options.mockReturnValue(new Promise((resolve) => (configured = resolve)));
|
const state = setup(shared);
|
expect(state.windowLoading()).toBe(true);
|
mocks.shared.mockRejectedValueOnce(new Error('unavailable'));
|
configured({ data: { previewEnabled: true, editEnabled: true } });
|
await flushPromises();
|
expect(state.windowLoading()).toBe(false);
|
expect(state.edit.failure.value).toBe('工作附件加载失败,请重新打开表单');
|
});
|
|
it('does not let an obsolete load hide a newer load and releases when the field unmounts', async () => {
|
const completions: ((value: any) => void)[] = [];
|
mocks.shared.mockImplementation(() => new Promise((resolve) => completions.push(resolve)));
|
const state = setup(shared);
|
await flushPromises();
|
state.context.recordId = 'other-record';
|
await flushPromises();
|
expect(completions).toHaveLength(2);
|
completions[0]!(result());
|
await flushPromises();
|
expect(state.windowLoading()).toBe(true);
|
state.wrapper.unmount();
|
expect(state.windowLoading()).toBe(false);
|
completions[1]!(result());
|
await flushPromises();
|
expect(state.windowLoading()).toBe(false);
|
});
|
|
it('keeps background polling quiet and shows loading only while closing the shared editor', async () => {
|
vi.useFakeTimers();
|
try {
|
const state = setup(shared);
|
await flushPromises();
|
expect(state.windowLoading()).toBe(false);
|
let polled!: (value: any) => void;
|
mocks.shared.mockReturnValueOnce(new Promise((resolve) => (polled = resolve)));
|
await vi.advanceTimersByTimeAsync(15_000);
|
expect(state.windowLoading()).toBe(false);
|
polled(result());
|
await flushPromises();
|
mocks.change.mockResolvedValue({ data: { ...result().data, shared: true, leaseId: 'lease', source: file('A') } });
|
await state.edit.open(file('A'));
|
expect(state.windowLoading()).toBe(false);
|
let closed!: () => void;
|
mocks.lease.mockReturnValueOnce(new Promise<void>((resolve) => (closed = resolve)));
|
mocks.open.mock.calls[0]![0].onClose();
|
expect(state.windowLoading()).toBe(true);
|
closed();
|
await flushPromises();
|
expect(state.windowLoading()).toBe(false);
|
} finally {
|
vi.useRealTimers();
|
}
|
});
|
|
it('does not show the shared window overlay for an ordinary attachment load', async () => {
|
let complete!: (value: any) => void;
|
mocks.load.mockReturnValueOnce(new Promise((resolve) => (complete = resolve)));
|
const state = setup();
|
await flushPromises();
|
expect(state.edit.busy.value).toBe(true);
|
expect(state.windowLoading()).toBe(false);
|
complete(result());
|
await flushPromises();
|
});
|
});
|
|
describe('online edit preparation entry conditions', () => {
|
const ordinary = { ...file('text'), name: 'text.txt', fileExtension: 'txt' };
|
|
it.each(['txt', 'pdf', 'xlsx'])('does not block an unedited %s attachment during configuration or draft recovery', async (extension) => {
|
const files = [{ ...ordinary, name: `ordinary.${extension}`, fileExtension: extension }];
|
let configure!: (value: any) => void;
|
let recover!: (value: any) => void;
|
mocks.options.mockReturnValue(new Promise((resolve) => (configure = resolve)));
|
mocks.load.mockReturnValue(new Promise((resolve) => (recover = resolve)));
|
const state = setup({}, 'record', files);
|
expect(state.edit.busy.value).toBe(false);
|
expect(state.edit.blocked.value).toBe(false);
|
await expect(state.prepare()).resolves.toBeUndefined();
|
configure({ data: { previewEnabled: true, editEnabled: true } });
|
await flushPromises();
|
expect(mocks.load).toHaveBeenCalledTimes(1);
|
expect(state.edit.busy.value).toBe(false);
|
expect(state.edit.blocked.value).toBe(false);
|
expect(state.windowLoading()).toBe(false);
|
await expect(state.prepare()).resolves.toBeUndefined();
|
recover(result(files));
|
await flushPromises();
|
expect(state.edit.canEdit(files[0]!)).toBe(false);
|
await expect(state.prepare()).resolves.toEqual({ draftId: 'draft', revision: 0 });
|
});
|
|
it('does not block an empty field or show an editing failure when its background recovery fails', async () => {
|
mocks.load.mockRejectedValue(new Error('unavailable'));
|
const state = setup({}, 'record', []);
|
expect(state.edit.busy.value).toBe(false);
|
await expect(state.prepare()).resolves.toBeUndefined();
|
await flushPromises();
|
expect(state.edit.failure.value).toBe('');
|
expect(state.edit.blocked.value).toBe(false);
|
await expect(state.prepare()).resolves.toBeUndefined();
|
});
|
|
it('does not enter editing busy or window loading for an ordinary shared attachment', async () => {
|
mocks.shared.mockReturnValue(new Promise(() => {}));
|
const state = setup({ fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true } } }, 'record', [ordinary]);
|
await flushPromises();
|
expect(state.edit.busy.value).toBe(false);
|
expect(state.edit.blocked.value).toBe(false);
|
expect(state.windowLoading()).toBe(false);
|
await expect(state.prepare()).resolves.toBeUndefined();
|
});
|
|
it('does not enter editing busy when the field editing switch is off', async () => {
|
mocks.options.mockReturnValue(new Promise(() => {}));
|
const state = setup({ onlineEdit: false });
|
expect(state.edit.busy.value).toBe(false);
|
expect(state.edit.blocked.value).toBe(false);
|
await expect(state.prepare()).resolves.toBeUndefined();
|
});
|
|
it('restores an existing Word draft even when the persisted attachment is ordinary', async () => {
|
mocks.load.mockResolvedValue(result([file('restored')], 4));
|
const state = setup({}, 'record', [ordinary]);
|
await flushPromises();
|
expect(state.current()[0]?.fileId).toBe('restored');
|
expect(state.edit.canEdit(state.current()[0]!)).toBe(true);
|
await expect(state.prepare()).resolves.toEqual({ draftId: 'draft', revision: 4 });
|
mocks.change.mockResolvedValue({ data: { ...result([file('restored')], 4).data, leaseId: 'lease', source: file('restored') } });
|
await state.edit.open(state.current()[0]!);
|
await expect(state.prepare()).rejects.toThrow('关闭附件编辑器');
|
});
|
|
it('loads the draft version before applying a real upload during background recovery', async () => {
|
let recover!: (value: any) => void;
|
mocks.load.mockReturnValue(new Promise((resolve) => (recover = resolve)));
|
const state = setup({}, 'record', [ordinary]);
|
await flushPromises();
|
const uploading = state.edit.add(file('new-word'));
|
await expect(state.prepare()).rejects.toThrow('附件正在上传或更新');
|
recover(result([ordinary]));
|
await uploading;
|
await flushPromises();
|
expect(state.current().map((item) => item.fileId)).toEqual(['text', 'new-word']);
|
expect(mocks.change).toHaveBeenCalledWith('files', expect.objectContaining({ draftId: 'draft', revision: 0 }));
|
expect(state.edit.blocked.value).toBe(false);
|
await expect(state.prepare()).resolves.toEqual({ draftId: 'draft', revision: 1 });
|
});
|
|
it('allows template selection from an empty field without losing the selection to draft recovery', async () => {
|
let recover!: (value: any) => void;
|
mocks.load.mockReturnValue(new Promise((resolve) => (recover = resolve)));
|
const state = setup({}, 'record', []);
|
await flushPromises();
|
expect(state.edit.enabled.value).toBe(true);
|
const selecting = state.edit.replaceFrom(async () => [file('template')]);
|
await flushPromises();
|
recover(result([]));
|
await selecting;
|
await flushPromises();
|
expect(state.current()[0]?.fileId).toBe('template');
|
await expect(state.prepare()).resolves.toEqual({ draftId: 'draft', revision: 1 });
|
});
|
|
it('does not overwrite a recovered edit with a replacement based on older ordinary files', async () => {
|
let recover!: (value: any) => void;
|
mocks.load.mockReturnValue(new Promise((resolve) => (recover = resolve)));
|
const state = setup({}, 'record', [ordinary]);
|
await flushPromises();
|
const replacing = state.edit.replace([file('replacement')]);
|
const rejected = expect(replacing).rejects.toThrow('表单或附件已变化');
|
recover(result([file('restored')], 4));
|
await rejected;
|
expect(mocks.change).not.toHaveBeenCalled();
|
expect(state.current()[0]?.fileId).toBe('restored');
|
await expect(state.prepare()).resolves.toEqual({ draftId: 'draft', revision: 4 });
|
});
|
|
it('cancels a pending ordinary attachment replacement when its field unmounts', async () => {
|
mocks.load.mockReturnValue(new Promise(() => {}));
|
const state = setup({}, 'record', [ordinary]);
|
await flushPromises();
|
const replacing = state.edit.replace([file('replacement')]);
|
const rejected = expect(replacing).rejects.toThrow('表单或附件已变化');
|
state.wrapper.unmount();
|
await rejected;
|
expect(mocks.change).not.toHaveBeenCalled();
|
});
|
|
it('retains draft version protection after replacing the last Word file with an ordinary attachment', async () => {
|
const state = setup();
|
await flushPromises();
|
await state.edit.replace([ordinary]);
|
expect(state.current()[0]?.name).toBe('text.txt');
|
await expect(state.prepare()).resolves.toEqual({ draftId: 'draft', revision: 1 });
|
});
|
});
|
|
describe('dms shared content confirmation', () => {
|
const snapshot = (id = 'A', revision = 0) => ({
|
data: {
|
...result([file(id)], revision).data,
|
shared: true,
|
dmsAudit: true,
|
contentToken: `content-${id}`,
|
baselineFiles: [file('A')],
|
editOrigins: id === 'A' ? {} : { [id]: 'A' },
|
},
|
});
|
const create = () =>
|
setup({
|
dmsAudit: true,
|
editField: 'wenjian',
|
fileAccess: {
|
status: 'ready',
|
actions: { VIEW: true, EDIT: true, DOWNLOAD: false },
|
},
|
});
|
it.each([{ editField: 'wenjian' }, { editField: 'jilu_wenjian', editParentField: 'jilu_mingxi', editRowId: 'row' }])(
|
'rejects incompatible saved DMS attachments without a private-draft fallback: %j',
|
async (field) => {
|
const state = setup({ dmsAudit: true, ...field });
|
await flushPromises();
|
expect(mocks.load).not.toHaveBeenCalled();
|
expect(mocks.shared).not.toHaveBeenCalled();
|
expect(state.edit.failure.value).toContain('不符合当前共享附件协议');
|
expect(state.edit.blocked.value).toBe(true);
|
expect(state.edit.canEdit(file('A'))).toBe(false);
|
await expect(state.prepare()).rejects.toThrow('不符合当前共享附件协议');
|
await expect(state.edit.replace([file('B')])).rejects.toThrow('不符合当前共享附件协议');
|
await state.edit.open(file('A'));
|
expect(mocks.change).not.toHaveBeenCalled();
|
},
|
);
|
it('updates a same-file template source without demanding another content preview', async () => {
|
const first = { data: { ...snapshot().data, metadataToken: 'template-A', files: [{ ...file('A'), laiyuan_muban_id: 'A', contentSavedAt: 100 }] } };
|
mocks.shared.mockResolvedValue(first);
|
const state = create();
|
await flushPromises();
|
mocks.shared.mockResolvedValue({ data: { ...first.data, metadataToken: 'template-B', files: [{ ...file('A'), laiyuan_muban_id: 'B', contentSavedAt: 100 }] } });
|
await expect(state.prepare()).resolves.toMatchObject({ contentToken: 'content-A', metadataToken: 'template-B' });
|
expect(state.current()[0]).toMatchObject({ laiyuan_muban_id: 'B', contentSavedAt: 100 });
|
});
|
it('recovers when current workflow permissions finish loading', async () => {
|
mocks.shared.mockResolvedValue(snapshot());
|
const state = setup({ dmsAudit: true, editField: 'wenjian' });
|
await flushPromises();
|
expect(state.edit.blocked.value).toBe(true);
|
state.props.fileAccess = { status: 'ready', actions: { VIEW: true, EDIT: true } };
|
await flushPromises();
|
expect(state.edit.failure.value).toBe('');
|
expect(state.edit.canEdit(file('A'))).toBe(true);
|
await expect(state.prepare()).resolves.toMatchObject({ contentToken: 'content-A' });
|
});
|
it('allows current temporary editing for new unsaved DMS forms', async () => {
|
const state = setup({ dmsAudit: true, editField: 'wenjian' }, '');
|
await flushPromises();
|
expect(state.edit.failure.value).toBe('');
|
expect(state.edit.blocked.value).toBe(false);
|
expect(state.edit.canEdit(file('A'))).toBe(true);
|
await expect(state.prepare()).resolves.toBeUndefined();
|
expect(mocks.load).not.toHaveBeenCalled();
|
});
|
it('allows untouched submission when editor options fail, after verifying shared content', async () => {
|
mocks.options.mockRejectedValue(new Error('office offline'));
|
mocks.shared.mockResolvedValue(snapshot());
|
const state = create();
|
await flushPromises();
|
expect(state.edit.blocked.value).toBe(false);
|
await expect(state.prepare()).resolves.toMatchObject({ contentToken: 'content-A' });
|
expect(mocks.begin).not.toHaveBeenCalled();
|
expect(mocks.change).not.toHaveBeenCalled();
|
});
|
it('does not overwrite a replacement with a delayed initial shared response', async () => {
|
let resolve!: (value: ReturnType<typeof snapshot>) => void;
|
mocks.shared.mockReturnValueOnce(new Promise((done) => (resolve = done)));
|
const state = setup({ dmsAudit: true, editField: 'wenjian', fileAccess: { status: 'ready', actions: { VIEW: true, EDIT: true, REPLACE: true } } });
|
const replacement = state.edit.replace([file('C')]);
|
expect(mocks.change).not.toHaveBeenCalled();
|
resolve(snapshot('B', 3));
|
await expect(replacement).rejects.toThrow('附件已变化');
|
expect(state.current()[0]?.fileId).toBe('B');
|
await state.edit.replace([file('C')]);
|
expect(state.current()[0]?.fileId).toBe('C');
|
});
|
it('uses shared B for access and exposes adopted A separately', async () => {
|
mocks.shared.mockResolvedValue(snapshot('B', 3));
|
const state = create();
|
await flushPromises();
|
expect(state.current()[0]?.fileId).toBe('B');
|
expect(state.edit.sharedSource(file('B'))).toEqual({ fileId: 'B', draftId: 'draft', contentToken: 'content-B' });
|
expect(state.edit.previousFile(file('B'))?.fileId).toBe('A');
|
await expect(state.prepare()).resolves.toMatchObject({ contentToken: 'content-B' });
|
});
|
it('exposes the adopted attachment after a single-file upload replacement without edit ancestry', async () => {
|
mocks.shared.mockResolvedValue({ data: { ...snapshot('B', 3).data, editOrigins: {} } });
|
const state = create();
|
await flushPromises();
|
expect(state.edit.previousFile(file('B'))?.fileId).toBe('A');
|
expect(state.edit.sharedSource(file('B'))).toMatchObject({ fileId: 'B', contentToken: 'content-B' });
|
});
|
it('does not guess an adopted attachment when multiple replaced files have no ancestry', async () => {
|
mocks.shared.mockResolvedValue({ data: { ...snapshot('B', 3).data, files: [file('B'), file('C')], editOrigins: {} } });
|
const state = create();
|
await flushPromises();
|
expect(state.edit.previousFile(file('B'))).toBeUndefined();
|
});
|
it('rejects submit when shared content changed and presents it for another confirmation', async () => {
|
mocks.shared.mockResolvedValueOnce(snapshot()).mockResolvedValue(snapshot('B', 3));
|
const state = create();
|
await flushPromises();
|
await expect(state.prepare()).rejects.toThrow('重新预览确认');
|
expect(state.current()[0]?.fileId).toBe('B');
|
await expect(state.prepare()).resolves.toMatchObject({ contentToken: 'content-B' });
|
});
|
it('requires confirmation after background polling has already displayed changed content', async () => {
|
vi.useFakeTimers();
|
try {
|
mocks.shared.mockResolvedValueOnce(snapshot()).mockResolvedValue(snapshot('B', 3));
|
const state = create();
|
await flushPromises();
|
await vi.advanceTimersByTimeAsync(15_000);
|
expect(state.current()[0]?.fileId).toBe('B');
|
await expect(state.prepare()).rejects.toThrow('重新预览确认');
|
await expect(state.prepare()).resolves.toMatchObject({ contentToken: 'content-B' });
|
} finally {
|
vi.useRealTimers();
|
}
|
});
|
it('does not require confirmation after a background session-only change', async () => {
|
vi.useFakeTimers();
|
try {
|
mocks.shared.mockResolvedValueOnce(snapshot()).mockResolvedValue(snapshot('A', 2));
|
const state = create();
|
await flushPromises();
|
await vi.advanceTimersByTimeAsync(15_000);
|
await expect(state.prepare()).resolves.toMatchObject({ revision: 2, contentToken: 'content-A' });
|
} finally {
|
vi.useRealTimers();
|
}
|
});
|
it('accepts a session-only revision change with the same content', async () => {
|
mocks.shared.mockResolvedValueOnce(snapshot('A', 0)).mockResolvedValue(snapshot('A', 2));
|
const state = create();
|
await flushPromises();
|
await expect(state.prepare()).resolves.toMatchObject({ revision: 2, contentToken: 'content-A' });
|
});
|
it('recovers a read failure on submit without adopting unseen changed content', async () => {
|
mocks.shared.mockRejectedValueOnce(new Error('offline')).mockResolvedValue(snapshot('B', 3));
|
const state = create();
|
await flushPromises();
|
expect(state.edit.blocked.value).toBe(false);
|
expect(state.edit.syncWarning.value).toContain('提交时');
|
await expect(state.prepare()).rejects.toThrow('重新预览确认');
|
});
|
it('fails closed if the final shared-content check is unavailable', async () => {
|
mocks.shared.mockResolvedValueOnce(snapshot()).mockRejectedValue(new Error('offline'));
|
const state = create();
|
await flushPromises();
|
await expect(state.prepare()).rejects.toThrow('无法核验');
|
});
|
it('binds begin to the content and full native attachment position', async () => {
|
mocks.shared.mockResolvedValue(snapshot());
|
mocks.change.mockResolvedValue({ data: { ...snapshot().data, leaseId: 'lease', source: file('A') } });
|
const state = create();
|
await flushPromises();
|
await state.edit.open(file('A'));
|
expect(mocks.change).toHaveBeenCalledWith(
|
'begin',
|
expect.objectContaining({ contentToken: 'content-A', context: expect.objectContaining({ modelId: 'model', recordId: 'record', field: 'wenjian' }) }),
|
);
|
});
|
});
|