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' }) }),
    );
  });
});
