/* eslint-disable vue/one-component-per-file */
|
import { flushPromises, mount } from '@vue/test-utils';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import FormModal from '../FormModal.vue';
|
import { CUSTOM_BARCODE_TEMPLATE, defaultPosition, defaultWatermark } from '../model';
|
|
const mocks = vi.hoisted(() => ({
|
create: vi.fn(),
|
detail: vi.fn(),
|
error: vi.fn(),
|
fonts: vi.fn(),
|
businessTypes: vi.fn(),
|
formModel: {} as Record<string, any>,
|
formOptions: {} as Record<string, any>,
|
init: undefined as any,
|
positionTableOptions: {} as Record<string, any>,
|
schemas: [] as any[],
|
update: vi.fn(),
|
warning: vi.fn(),
|
}));
|
|
vi.mock('#/api/x/dms/watermark', () => ({
|
getWatermarkDetail: mocks.detail,
|
getWatermarkFonts: mocks.fonts,
|
createWatermark: mocks.create,
|
updateWatermark: mocks.update,
|
}));
|
vi.mock('#/api/systemData/dictionary', () => ({ getDictionaryDataSelector: mocks.businessTypes }));
|
vi.mock('../PreviewModal.vue', async () => {
|
const { defineComponent, h } = await import('vue');
|
return {
|
default: defineComponent({
|
name: 'DmsWatermarkPreviewModal',
|
props: ['open', 'config'],
|
setup: () => () => h('div'),
|
}),
|
};
|
});
|
vi.mock('@jnpf/hooks', () => ({ useMessage: () => ({ createMessage: { error: mocks.error, success: vi.fn(), warning: mocks.warning } }) }));
|
vi.mock('@jnpf/ui/form', async () => {
|
const { defineComponent, h, reactive } = await import('vue');
|
return {
|
BasicForm: defineComponent({
|
name: 'BasicForm',
|
props: { disabled: Boolean },
|
setup:
|
(props, { slots }) =>
|
() =>
|
h('form', { 'data-disabled': props.disabled }, [
|
mocks.formModel.watermarkType === 'BARCODE' ? slots.barcodeSource?.({ field: 'barcodeSource', model: mocks.formModel }) : undefined,
|
['BARCODE', 'TEXT'].includes(mocks.formModel.watermarkType) && mocks.formModel.barcodeSource !== 'CUSTOM'
|
? slots.contentTemplate?.({ field: 'contentTemplate', model: mocks.formModel })
|
: undefined,
|
slots.positions?.(),
|
]),
|
}),
|
useForm: (options: any) => {
|
mocks.formOptions = options;
|
mocks.schemas = options.schemas;
|
mocks.formModel = reactive({});
|
const actions = {
|
clearValidate: vi.fn(),
|
getFieldsValue: () => ({ ...mocks.formModel }),
|
resetFields: async () => {
|
for (const key of Object.keys(mocks.formModel)) delete mocks.formModel[key];
|
},
|
setFieldsValue: async (values: Record<string, any>) => Object.assign(mocks.formModel, values),
|
updateSchema: vi.fn(async (schema: any) => {
|
const current = mocks.schemas.find((item) => item.field === schema.field);
|
if (current) Object.assign(current, { ...schema, componentProps: { ...current.componentProps, ...schema.componentProps } });
|
}),
|
validate: async () => ({ ...mocks.formModel }),
|
};
|
return [vi.fn(), actions];
|
},
|
};
|
});
|
vi.mock('@jnpf/ui/modal', async () => {
|
const { defineComponent, h } = await import('vue');
|
return {
|
BasicModal: defineComponent({
|
name: 'BasicModal',
|
emits: ['ok'],
|
setup:
|
(_, { slots }) =>
|
() =>
|
h('section', [slots.default?.(), slots.insertFooter?.()]),
|
}),
|
useModalInner: (init: any) => {
|
mocks.init = init;
|
return [vi.fn(), { changeOkLoading: vi.fn(), closeModal: vi.fn(), setModalProps: vi.fn() }];
|
},
|
};
|
});
|
vi.mock('@jnpf/ui/vxeTable', async () => {
|
const { defineComponent, h } = await import('vue');
|
return {
|
BasicVxeTable: defineComponent({
|
name: 'BasicVxeTable',
|
props: { dataSource: { type: Array, default: () => [] } },
|
setup:
|
(props, { slots }) =>
|
() =>
|
h('div', [
|
slots.tableTitle?.(),
|
...(props.dataSource || []).flatMap((record: any) => [
|
slots.paperType?.({ record }),
|
slots.paperWidthMm?.({ record }),
|
slots.paperHeightMm?.({ record }),
|
]),
|
]),
|
}),
|
TableAction: defineComponent({ name: 'TableAction', setup: () => () => h('div') }),
|
useVxeTable: (tableOptions: Record<string, any>) => {
|
mocks.positionTableOptions = tableOptions;
|
return [vi.fn(), {}];
|
},
|
};
|
});
|
|
const options = {
|
global: {
|
stubs: {
|
'a-button': { template: '<button><slot /></button>' },
|
JnpfRadio: {
|
name: 'JnpfRadio',
|
props: ['disabled', 'value'],
|
emits: ['change', 'update:value'],
|
template: '<div />',
|
},
|
JnpfInput: true,
|
JnpfInputNumber: {
|
name: 'JnpfInputNumber',
|
props: { value: Number, disabled: Boolean },
|
template: '<input :value="value" :disabled="disabled" />',
|
},
|
JnpfSelect: {
|
name: 'JnpfSelect',
|
props: ['value', 'disabled', 'options'],
|
emits: ['change', 'update:value'],
|
template:
|
'<select :value="value" :disabled="disabled"><option v-for="option in options" :key="option.id" :value="option.id" :disabled="option.disabled">{{ option.fullName }}</option></select>',
|
},
|
JnpfTextarea: {
|
name: 'JnpfTextarea',
|
props: ['disabled', 'value'],
|
emits: ['update:value'],
|
template: '<textarea />',
|
},
|
},
|
},
|
};
|
|
beforeEach(() => {
|
vi.clearAllMocks();
|
mocks.fonts.mockResolvedValue({ data: [{ family: 'SimHei', displayName: '黑体' }] });
|
mocks.businessTypes.mockResolvedValue({ data: { list: [{ id: 'dictionary-1', enCode: 'RECORD', fullName: '记录' }] } });
|
});
|
|
describe('watermark standard form', () => {
|
it('loads business type codes as required multi-select options', async () => {
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ mode: 'create' });
|
expect(mocks.businessTypes).toHaveBeenCalledWith('879384732964547653');
|
const schema = mocks.schemas.find((item) => item.field === 'watermarkBusinessTypes');
|
expect(schema.componentProps).toMatchObject({ multiple: true, options: [{ id: 'RECORD', fullName: '记录' }] });
|
expect(schema.rules[0]).toMatchObject({ required: true, min: 1 });
|
expect(mocks.formModel.watermarkBusinessTypes).toEqual([]);
|
wrapper.unmount();
|
});
|
|
it('loads directory fonts and defaults new configurations to an available family', async () => {
|
mocks.fonts.mockResolvedValue({ data: [{ family: 'Configured Serif', displayName: '配置字体' }] });
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ mode: 'create' });
|
expect(mocks.schemas.find((schema) => schema.field === 'fontFamily').componentProps.options).toEqual([{ id: 'Configured Serif', fullName: '配置字体' }]);
|
expect(mocks.formModel.fontFamily).toBe('Configured Serif');
|
wrapper.unmount();
|
});
|
|
it('retains an existing font without silently replacing it', async () => {
|
mocks.fonts.mockResolvedValue({ data: [{ family: 'Configured Serif', displayName: '配置字体' }] });
|
mocks.detail.mockResolvedValue({ data: { ...defaultWatermark(), id: 'old-font', fontFamily: 'SimSun' } });
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ id: 'old-font', mode: 'edit' });
|
expect(mocks.formModel.fontFamily).toBe('SimSun');
|
wrapper.unmount();
|
});
|
|
it('shows an empty directory without offering hardcoded fonts', async () => {
|
mocks.fonts.mockResolvedValue({ data: [] });
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ mode: 'create' });
|
expect(mocks.schemas.find((schema) => schema.field === 'fontFamily').componentProps.options).toEqual([]);
|
expect(mocks.formModel.fontFamily).toBeUndefined();
|
expect(mocks.warning).toHaveBeenCalledWith(expect.stringContaining('字体目录没有可用字体'));
|
wrapper.unmount();
|
});
|
|
it('saves alignment, includes it in draft previews and retains it when switching layouts', async () => {
|
mocks.detail.mockResolvedValue({
|
data: { ...defaultWatermark(), id: 'aligned-config', horizontalAlign: 'RIGHT', verticalAlign: 'BOTTOM', watermarkBusinessTypes: ['RECORD'] },
|
});
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ id: 'aligned-config', mode: 'edit' });
|
expect(mocks.formModel).toMatchObject({ horizontalAlign: 'RIGHT', verticalAlign: 'BOTTOM', watermarkBusinessTypes: ['RECORD'] });
|
const alignment = mocks.schemas.filter((schema) => ['horizontalAlign', 'verticalAlign'].includes(schema.field));
|
expect(alignment.every((schema) => schema.ifShow({ values: mocks.formModel }))).toBe(true);
|
mocks.formModel.layoutMode = 'TILED';
|
expect(alignment.every((schema) => !schema.ifShow({ values: mocks.formModel }))).toBe(true);
|
mocks.formModel.layoutMode = 'POSITION';
|
mocks.formModel.horizontalAlign = 'CENTER';
|
await wrapper
|
.findAll('button')
|
.find((button) => button.text() === '预览')!
|
.trigger('click');
|
expect(wrapper.getComponent({ name: 'DmsWatermarkPreviewModal' }).props('config')).toMatchObject({
|
horizontalAlign: 'CENTER',
|
verticalAlign: 'BOTTOM',
|
});
|
wrapper.findComponent({ name: 'BasicModal' }).vm.$emit('ok');
|
await flushPromises();
|
expect(mocks.update).toHaveBeenCalledWith('aligned-config', expect.objectContaining({ horizontalAlign: 'CENTER', verticalAlign: 'BOTTOM', watermarkBusinessTypes: ['RECORD'] }));
|
wrapper.unmount();
|
});
|
|
it.each(['edit', 'view'])('shows left/top alignment for a legacy position configuration in %s mode', async (mode) => {
|
const legacy = { ...defaultWatermark(), id: 'legacy', rotationAngle: -30 } as Record<string, any>;
|
delete legacy.horizontalAlign;
|
delete legacy.verticalAlign;
|
legacy.watermarkType = 'text';
|
legacy.layoutMode = 'position';
|
mocks.detail.mockResolvedValue({ data: legacy });
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ id: 'legacy', mode });
|
expect(mocks.formModel).toMatchObject({ horizontalAlign: 'LEFT', verticalAlign: 'TOP', rotationAngle: -30 });
|
const alignment = mocks.schemas.filter((schema) => ['horizontalAlign', 'verticalAlign'].includes(schema.field));
|
expect(alignment.every((schema) => schema.ifShow({ values: mocks.formModel }))).toBe(true);
|
expect(wrapper.findComponent({ name: 'BasicForm' }).props('disabled')).toBe(mode === 'view');
|
wrapper.unmount();
|
});
|
|
it('previews an unsaved new configuration without creating it', async () => {
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ mode: 'create' });
|
await flushPromises();
|
mocks.formModel.name = 'New watermark';
|
mocks.formModel.contentTemplate = 'Draft {/FILENO}';
|
|
await wrapper
|
.findAll('button')
|
.find((button) => button.text() === '预览')!
|
.trigger('click');
|
|
const preview = wrapper.getComponent({ name: 'DmsWatermarkPreviewModal' });
|
expect(preview.props('open')).toBe(true);
|
expect(preview.props('config')).toMatchObject({
|
name: 'New watermark',
|
contentTemplate: 'Draft {/FILENO}',
|
rotationAngle: 0,
|
positions: [{ paperType: 'A4' }],
|
});
|
expect(mocks.create).not.toHaveBeenCalled();
|
wrapper.unmount();
|
});
|
|
it('shows an edit preview with unsaved paper coordinates without saving the configuration', async () => {
|
mocks.detail.mockResolvedValue({ data: { ...defaultWatermark(), id: 'config-4', name: 'Test watermark' } });
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ id: 'config-4', mode: 'edit' });
|
await flushPromises();
|
mocks.formModel.contentTemplate = 'Draft {/FILENO}';
|
const position = wrapper
|
.findAllComponents({ name: 'BasicVxeTable' })
|
.find((table) => table.props('dataSource')?.length)!
|
.props('dataSource')[0];
|
position.landscapeXMm = 75;
|
|
await wrapper
|
.findAll('button')
|
.find((button) => button.text() === '预览')!
|
.trigger('click');
|
|
const preview = wrapper.getComponent({ name: 'DmsWatermarkPreviewModal' });
|
expect(preview.props('open')).toBe(true);
|
expect(preview.props('config')).toMatchObject({
|
contentTemplate: 'Draft {/FILENO}',
|
positions: [{ landscapeXMm: 75 }],
|
});
|
expect(mocks.update).not.toHaveBeenCalled();
|
wrapper.unmount();
|
});
|
|
it('updates paper dimensions when A4 or A3 is selected', async () => {
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ mode: 'create' });
|
await flushPromises();
|
|
const paperSelect = wrapper.getComponent({ name: 'JnpfSelect' });
|
expect(paperSelect.props('value')).toBe('A4');
|
expect(paperSelect.props('options').map(({ id }: { id: string }) => id)).toEqual(['A4', 'A3']);
|
|
paperSelect.vm.$emit('update:value', 'A3');
|
paperSelect.vm.$emit('change', 'A3');
|
await flushPromises();
|
const positionTable = wrapper.findAllComponents({ name: 'BasicVxeTable' }).find((table) => table.props('dataSource')?.length);
|
expect(positionTable?.props('dataSource')[0]).toMatchObject({
|
paperType: 'A3',
|
portraitXMm: 108,
|
portraitYMm: 200,
|
landscapeXMm: 170,
|
landscapeYMm: 138,
|
});
|
expect(wrapper.findAllComponents({ name: 'JnpfInputNumber' }).map((input) => input.props('value'))).toEqual([297, 420]);
|
expect(wrapper.findAllComponents({ name: 'JnpfInputNumber' }).every((input) => input.props('disabled'))).toBe(true);
|
|
const addButton = wrapper.findAll('button').find((button) => button.text() === '新增位置');
|
await addButton!.trigger('click');
|
expect(wrapper.findAllComponents({ name: 'JnpfSelect' }).map((select) => select.props('value'))).toEqual(['A3', 'A4']);
|
expect(wrapper.findAllComponents({ name: 'JnpfInputNumber' }).map((input) => input.props('value'))).toEqual([297, 420, 210, 297]);
|
wrapper.unmount();
|
});
|
|
it('keeps manually entered coordinates when changing paper type', async () => {
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ mode: 'create' });
|
const record = wrapper
|
.findAllComponents({ name: 'BasicVxeTable' })
|
.find((table) => table.props('dataSource')?.length)!
|
.props('dataSource')[0];
|
record.portraitXMm = 30;
|
|
wrapper.getComponent({ name: 'JnpfSelect' }).vm.$emit('change', 'A3');
|
await flushPromises();
|
expect(record).toMatchObject({ paperType: 'A3', paperWidthMm: 297, paperHeightMm: 420, portraitXMm: 30, portraitYMm: 138 });
|
wrapper.unmount();
|
});
|
|
it('groups portrait and landscape coordinates and rejects positions that would be moved', async () => {
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ mode: 'create' });
|
expect(mocks.positionTableOptions.columns.slice(3).map(({ title }: { title: string }) => title)).toEqual(['水印位置(横向,mm)', '水印位置(纵向,mm)']);
|
const record = wrapper
|
.findAllComponents({ name: 'BasicVxeTable' })
|
.find((table) => table.props('dataSource')?.length)!
|
.props('dataSource')[0];
|
record.portraitYMm = 290;
|
|
wrapper.findComponent({ name: 'BasicModal' }).vm.$emit('ok');
|
await flushPromises();
|
expect(mocks.warning).toHaveBeenCalledWith(expect.stringContaining('A4 纵向位置会使水印超出纸张'));
|
expect(mocks.create).not.toHaveBeenCalled();
|
wrapper.unmount();
|
});
|
|
it('keeps existing coordinates unchanged when saving an edited configuration', async () => {
|
const position = { ...defaultPosition('A3'), portraitXMm: 24, landscapeYMm: 36 };
|
mocks.detail.mockResolvedValue({ data: { ...defaultWatermark(), id: 'config-3', positions: [position] } });
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ id: 'config-3', mode: 'edit' });
|
await flushPromises();
|
|
wrapper.findComponent({ name: 'BasicModal' }).vm.$emit('ok');
|
await flushPromises();
|
expect(mocks.update).toHaveBeenCalledWith('config-3', expect.objectContaining({ positions: [position] }));
|
wrapper.unmount();
|
});
|
|
it('shows the duplicate-name message when creating a watermark', async () => {
|
mocks.create.mockRejectedValueOnce(new Error('已存在同名水印,请更换名称'));
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ mode: 'create' });
|
mocks.formModel.name = '预览水印';
|
mocks.formModel.enterpriseName = '玻思韬';
|
mocks.formModel.contentTemplate = '预览';
|
|
wrapper.findComponent({ name: 'BasicModal' }).vm.$emit('ok');
|
await flushPromises();
|
|
expect(mocks.error).toHaveBeenCalledWith('已存在同名水印,请更换名称');
|
wrapper.unmount();
|
});
|
|
it('shows the duplicate-name message when editing a watermark', async () => {
|
mocks.detail.mockResolvedValueOnce({ data: { ...defaultWatermark(), id: 'config-3', name: '预览水印', enterpriseName: '玻思韬' } });
|
mocks.update.mockRejectedValueOnce(new Error('已存在同名水印,请更换名称'));
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ id: 'config-3', mode: 'edit' });
|
|
wrapper.findComponent({ name: 'BasicModal' }).vm.$emit('ok');
|
await flushPromises();
|
|
expect(mocks.error).toHaveBeenCalledWith('已存在同名水印,请更换名称');
|
wrapper.unmount();
|
});
|
|
it('uses JNPF standard form and table components for the configuration fields', () => {
|
const wrapper = mount(FormModal, options);
|
const componentByField = Object.fromEntries(mocks.schemas.map((schema) => [schema.field, schema.component]));
|
|
expect(componentByField).toMatchObject({
|
baseGroup: 'GroupTitle',
|
enabled: 'Switch',
|
fontColor: 'ColorPicker',
|
imageUrl: 'UploadImgSingle',
|
loadScenes: 'Checkbox',
|
opacity: 'Slider',
|
showBarcodeText: 'JnpfCheckboxSingle',
|
watermarkType: 'Select',
|
});
|
expect(mocks.formOptions.baseColProps).toEqual({ span: 8 });
|
expect(mocks.schemas.find((schema) => schema.field === 'baseGroup').colProps).toEqual({ span: 24 });
|
expect(mocks.schemas.find((schema) => schema.field === 'name').colProps).toEqual({ span: 12 });
|
expect(mocks.schemas.find((schema) => schema.field === 'contentTemplate').colProps).toEqual({ span: 24 });
|
expect(mocks.schemas.find((schema) => schema.field === 'positions').colProps).toEqual({ span: 24 });
|
expect(wrapper.findComponent({ name: 'BasicForm' }).exists()).toBe(true);
|
expect(wrapper.findComponent({ name: 'BasicVxeTable' }).exists()).toBe(true);
|
wrapper.unmount();
|
});
|
|
it('persists the custom source with visible text and restores the previous template when switching back', async () => {
|
mocks.detail.mockResolvedValue({
|
data: {
|
...defaultWatermark(),
|
id: 'config-1',
|
name: 'Barcode',
|
enterpriseName: 'Enterprise',
|
watermarkType: 'BARCODE',
|
contentTemplate: 'SOP-{/FILENO}',
|
},
|
});
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ id: 'config-1', mode: 'edit' });
|
await flushPromises();
|
|
const source = wrapper.findComponent({ name: 'JnpfRadio' });
|
source.vm.$emit('update:value', 'CUSTOM');
|
source.vm.$emit('change', 'CUSTOM');
|
await flushPromises();
|
expect(wrapper.findComponent({ name: 'JnpfTextarea' }).exists()).toBe(false);
|
expect(mocks.formModel).toMatchObject({ contentTemplate: CUSTOM_BARCODE_TEMPLATE, showBarcodeText: true, barcodeFormat: 'QR_CODE' });
|
|
wrapper.findComponent({ name: 'BasicModal' }).vm.$emit('ok');
|
await flushPromises();
|
expect(mocks.update).toHaveBeenCalledWith(
|
'config-1',
|
expect.objectContaining({ contentTemplate: CUSTOM_BARCODE_TEMPLATE, showBarcodeText: true, barcodeFormat: 'QR_CODE' }),
|
);
|
|
source.vm.$emit('update:value', 'TEMPLATE');
|
source.vm.$emit('change', 'TEMPLATE');
|
await flushPromises();
|
expect(wrapper.findComponent({ name: 'JnpfTextarea' }).props('value')).toBe('SOP-{/FILENO}');
|
wrapper.unmount();
|
});
|
|
it('shows a saved custom source in readonly details', async () => {
|
mocks.detail.mockResolvedValue({
|
data: { ...defaultWatermark(), id: 'config-2', watermarkType: 'BARCODE', contentTemplate: CUSTOM_BARCODE_TEMPLATE, showBarcodeText: true },
|
});
|
const wrapper = mount(FormModal, options);
|
await mocks.init({ id: 'config-2', mode: 'view' });
|
await flushPromises();
|
|
expect(wrapper.findComponent({ name: 'JnpfRadio' }).props()).toMatchObject({ disabled: true, value: 'CUSTOM' });
|
expect(wrapper.findComponent({ name: 'JnpfTextarea' }).exists()).toBe(false);
|
expect(wrapper.findComponent({ name: 'BasicForm' }).props('disabled')).toBe(true);
|
expect(wrapper.text()).toContain('由业务调用逐项提供条码内容和显示文字');
|
expect(mocks.create).not.toHaveBeenCalled();
|
expect(mocks.update).not.toHaveBeenCalled();
|
wrapper.unmount();
|
});
|
});
|