Files
iot-dc3/dc3-web/tests/unit/utils-services.test.ts
pnoker 769d1cb977 style: apply repository-wide formatting
- expand single-line Javadocs to multi-line blocks, rewrap Markdown prose, and reflow POM, YAML, and TypeScript formatting
- formatting only: no Java code lines changed, no build-file semantics changed
- verified: backend compile and check_documentation.py pass, pnpm check and pnpm lint:check pass, and the dc3 YAML files parse
2026-08-25 13:35:00 +08:00

259 lines
11 KiB
TypeScript

/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Service-flavoured utils — utilities that touch external systems
// (clipboard, navigator, router, ElMessage / ElNotification, store).
// Pure helpers without side effects live in `utils-misc.test.ts` and the
// `*-util.test.ts` files dedicated to a single source module.
import {beforeEach, describe, expect, it, vi} from 'vitest';
import type {Router} from 'vue-router';
const elementPlusSpies = vi.hoisted(() => ({
messageError: vi.fn(),
messageSuccess: vi.fn(),
notification: vi.fn(),
}));
const commonUtilSpies = vi.hoisted(() => ({
logout: vi.fn(),
push: vi.fn(() => Promise.resolve()),
}));
vi.mock('element-plus', async (importOriginal) => ({
...(await importOriginal<typeof import('element-plus')>()),
ElMessage: {
error: elementPlusSpies.messageError,
success: elementPlusSpies.messageSuccess,
},
ElNotification: elementPlusSpies.notification,
}));
vi.mock('@/config/router', () => ({
default: {
push: commonUtilSpies.push,
},
}));
vi.mock('@/store', () => ({
useAuthStore: () => ({
logout: commonUtilSpies.logout,
}),
}));
// Minimal Router stand-in for jumpUtil tests. Only `push` is exercised, but
// we keep the shape promotable to a real Router cast so call sites stay
// properly typed.
function makeRouterStub(): Pick<Router, 'push'> {
return {
push: vi.fn(() => Promise.resolve()) as Router['push'],
};
}
describe('utils (services)', () => {
beforeEach(() => {
// `clearMocks: true` is set globally in vitest.config.ts, but spies on
// hoisted shared objects need an explicit clear because they live outside
// each test's vi.clearAllMocks() reset surface.
elementPlusSpies.messageError.mockClear();
elementPlusSpies.messageSuccess.mockClear();
elementPlusSpies.notification.mockClear();
commonUtilSpies.logout.mockClear();
commonUtilSpies.push.mockClear();
});
describe('asyncLoaderUtil', () => {
it('tracks async loader success, swallowed errors, and rethrown errors', async () => {
const {useAsyncLoader} = await import('@/utils/asyncLoaderUtil');
const {loading, run} = useAsyncLoader();
await expect(run(async () => 'ok')).resolves.toBe('ok');
expect(loading.value).toBe(false);
await expect(run(async () => Promise.reject(new Error('swallowed')))).resolves.toBeUndefined();
expect(loading.value).toBe(false);
await expect(run(async () => Promise.reject(new Error('rethrown')), {rethrow: true})).rejects.toThrow('rethrown');
expect(loading.value).toBe(false);
});
});
describe('dateUtil', () => {
it('formats legacy dates and calculates date differences', async () => {
const {calcDate, dateFormat, timestamp, timestampColumn} = await import('@/utils/dateUtil');
const date = new Date(2026, 4, 13, 8, 9, 10, 11);
expect(dateFormat(date)).toBe('2026-05-13 08:09:10');
expect(timestamp('2026-05-13T08:09:10')).toContain('2026-05-13 08:09:10');
expect(timestampColumn({}, {}, '')).toBe('');
expect(timestampColumn({}, {}, '2026-05-13T08:09:10')).toContain('2026-05-13 08:09:10');
// seconds is the sub-minute remainder (3s), not the total elapsed seconds.
expect(calcDate(new Date('2026-05-13T00:00:00Z'), new Date('2026-05-14T01:02:03Z'))).toMatchObject({
days: 1,
hours: 1,
minutes: 2,
seconds: 3,
});
});
});
describe('formRuleUtil', () => {
it('exposes stable form validation rule contracts', async () => {
const {AUTH_NAME_PATTERN, DECIMAL_PATTERN, NAME_PATTERN, authNameRules, nameRules, remarkRules} =
await import('@/utils/formRuleUtil');
const t = vi.fn((key: string, args?: Record<string, unknown>) => `${key}:${args?.name ?? ''}`);
expect(NAME_PATTERN.test('设备-01')).toBe(true);
expect(NAME_PATTERN.test('_bad')).toBe(false);
expect(AUTH_NAME_PATTERN.test('role.admin')).toBe(true);
expect(AUTH_NAME_PATTERN.test('角色')).toBe(false);
expect(DECIMAL_PATTERN.test('-12.345')).toBe(true);
expect(DECIMAL_PATTERN.test('12.3456')).toBe(false);
expect(nameRules(t, 'Device')).toHaveLength(3);
expect(authNameRules(t, 'Role')).toHaveLength(3);
expect(remarkRules(t)[0]).toMatchObject({max: 300, trigger: 'blur'});
});
it('validates decimal, byte and positive-integer boundaries', async () => {
const {byteRules, decimalRules, positiveIntegerRules, requiredSelectRule, requiredStringRule} =
await import('@/utils/formRuleUtil');
const t = vi.fn((key: string, args?: Record<string, unknown>) =>
`${key}:${args?.min ?? ''}:${args?.max ?? ''}`
);
type RuleValidator = (rule: unknown, value: unknown, callback: (error?: Error) => void) => void;
const validate = (rule: { validator?: unknown }, value: unknown): Promise<void> =>
new Promise((resolve, reject) => {
const validator = rule.validator as RuleValidator;
validator(rule, value, error => (error ? reject(error) : resolve()));
});
const decimal = decimalRules('decimal only', 'value required');
expect(decimal[0]).toMatchObject({required: true, whitespace: true, message: 'value required'});
await expect(validate(decimal[1], '')).resolves.toBeUndefined();
await expect(validate(decimal[1], '-12.345')).resolves.toBeUndefined();
await expect(validate(decimal[1], '12.3456')).rejects.toThrow('decimal only');
const byte = byteRules(t, 'byte required')[1];
await expect(validate(byte, 0)).resolves.toBeUndefined();
await expect(validate(byte, 127)).resolves.toBeUndefined();
await expect(validate(byte, 128)).rejects.toThrow('common.byteRange:0:127');
await expect(validate(byte, 1.5)).rejects.toThrow('common.byteRange:0:127');
const positiveInteger = positiveIntegerRules(t, 'count required')[1];
await expect(validate(positiveInteger, '1')).resolves.toBeUndefined();
await expect(validate(positiveInteger, '0')).rejects.toThrow('common.positiveIntegerFormat');
await expect(validate(positiveInteger, '-1')).rejects.toThrow('common.positiveIntegerFormat');
await expect(validate(positiveInteger, '1.5')).rejects.toThrow('common.positiveIntegerFormat');
expect(requiredStringRule('name required', 'change')[0]).toMatchObject({
required: true,
whitespace: true,
trigger: 'change',
});
expect(requiredSelectRule('selection required')[0]).toMatchObject({required: true, trigger: 'change'});
});
});
describe('commonUtil', () => {
it('copies content and logs out through the shared helpers', async () => {
const {copy, logout} = await import('@/utils/commonUtil');
const writeText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {writeText},
});
copy('driver-1', 'Driver');
await logout();
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith('driver-1'));
expect(commonUtilSpies.logout).toHaveBeenCalledTimes(1);
expect(commonUtilSpies.push).toHaveBeenCalledWith({name: 'login'});
});
});
describe('jumpUtil', () => {
it('routes dashboard jumps to the expected named routes', async () => {
const {jumpToEntity, jumpToSourceEvents} = await import('@/utils/jumpUtil');
const router = makeRouterStub();
jumpToEntity(router as Router, 'driver', 1);
jumpToEntity(router as Router, 'device', 2);
jumpToEntity(router as Router, 'profile', 3);
jumpToEntity(router as Router, 'point', 4);
jumpToEntity(router as Router, 'driver', '');
jumpToSourceEvents(router as Router, 'device', 5);
jumpToSourceEvents(router as Router, 'driver', 6);
jumpToSourceEvents(router as Router, 'point', 7);
expect(router.push).toHaveBeenCalledWith({name: 'driverDetail', query: {id: '1', active: 'detail'}});
expect(router.push).toHaveBeenCalledWith({name: 'deviceDetail', query: {id: '2', active: 'detail'}});
expect(router.push).toHaveBeenCalledWith({name: 'profileDetail', query: {id: '3', active: 'detail'}});
expect(router.push).toHaveBeenCalledWith({name: 'pointValue', query: {pointId: '4'}});
expect(router.push).toHaveBeenCalledWith({name: 'settingsDeviceAlarm', query: {sourceId: '5'}});
expect(router.push).toHaveBeenCalledWith({name: 'settingsDriverAlarm', query: {sourceId: '6'}});
expect(router.push).toHaveBeenCalledWith({name: 'settingsPointAlarm', query: {sourceId: '7'}});
expect(router.push).toHaveBeenCalledTimes(7);
});
});
describe('clipboardUtil', () => {
it('copies with clipboard API and fallback DOM copy', async () => {
const {setCopyContent} = await import('@/utils/clipboardUtil');
const writeText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {writeText},
});
await expect(setCopyContent('abc', true, 'ID')).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith('abc');
expect(elementPlusSpies.messageSuccess).toHaveBeenCalledWith({message: 'Copied ID to clipboard!'});
writeText.mockRejectedValueOnce(new Error('denied'));
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: vi.fn(),
});
const execCommand = vi.spyOn(document, 'execCommand').mockReturnValue(true);
await expect(setCopyContent('fallback', false, '')).resolves.toBe(true);
expect(execCommand).toHaveBeenCalledWith('copy');
});
});
describe('notificationUtil', () => {
it('surfaces notification defaults and provided messages', async () => {
const {failMessage, successMessage, warnMessage} = await import('@/utils/notificationUtil');
successMessage();
warnMessage('Careful', 'Heads up');
failMessage('', 'Broken');
expect(elementPlusSpies.notification).toHaveBeenCalledWith(
expect.objectContaining({type: 'success', title: 'Success', message: 'Operation successful!'})
);
expect(elementPlusSpies.notification).toHaveBeenCalledWith(
expect.objectContaining({type: 'warning', title: 'Heads up', message: 'Careful'})
);
expect(elementPlusSpies.notification).toHaveBeenCalledWith(
expect.objectContaining({type: 'error', title: 'Broken', message: 'Operation failed!'})
);
});
});
});