mirror of
https://gitee.com/pnoker/iot-dc3.git
synced 2026-10-31 18:21:58 +08:00
Land the v7 three-terminal UX decision: drop the width/pointer-based 44px touch enlargement so one 32px density holds across desktop, tablet, and mobile, and rebuild entity pages on a responsive record list (ResponsiveRecordList + EntityMobileCard + per-entity mobile summaries). Tighten the nginx security headers (CSP frame-ancestors 'none', X-Frame-Options DENY) to match the static e2e server. Complete the dc3-web audit remediation, including the remaining LOW items from docs/dc3-web-low-defects-handoff.md: streaming-safe message loads in the agentic store, mock menu reparent cycle rejection, paged-list page clamping, fetch/axios mock request normalization, per-table attribute catalogs, post-flush alarm watchers, rowKey-agnostic delete state, localized card text fallbacks, and e2e-server port fail-fast. Gate: vue-tsc, eslint, vitest 687 passed, responsive e2e 64 passed. Co-Authored-By: Claude Code <noreply@anthropic.com>
165 lines
5.2 KiB
TypeScript
165 lines
5.2 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/>.
|
|
*/
|
|
|
|
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
|
import {createPinia, setActivePinia} from 'pinia';
|
|
|
|
import {useMenuStore} from '@/store';
|
|
|
|
import {sampleMenuTree} from '../fixtures/menu';
|
|
|
|
const menuMocks = vi.hoisted(() => ({
|
|
listMenuTree: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/api/menu', () => menuMocks);
|
|
|
|
describe('menu store', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createPinia());
|
|
vi.resetAllMocks();
|
|
menuMocks.listMenuTree.mockResolvedValue(sampleMenuTree);
|
|
});
|
|
|
|
describe('fetchTree', () => {
|
|
it('shares an in-flight request across concurrent callers', async () => {
|
|
let resolveRequest!: (value: typeof sampleMenuTree) => void;
|
|
menuMocks.listMenuTree.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveRequest = resolve;
|
|
})
|
|
);
|
|
const store = useMenuStore();
|
|
|
|
const first = store.fetchTree();
|
|
const second = store.fetchTree();
|
|
|
|
expect(menuMocks.listMenuTree).toHaveBeenCalledTimes(1);
|
|
expect(store.loading).toBe(true);
|
|
resolveRequest(sampleMenuTree);
|
|
await Promise.all([first, second]);
|
|
|
|
expect(store.tree).toEqual(sampleMenuTree);
|
|
expect(store.loaded).toBe(true);
|
|
expect(store.loading).toBe(false);
|
|
});
|
|
|
|
it('loads the tree once and skips subsequent calls without force', async () => {
|
|
const store = useMenuStore();
|
|
|
|
await store.fetchTree();
|
|
await store.fetchTree();
|
|
await store.fetchTree();
|
|
|
|
expect(menuMocks.listMenuTree).toHaveBeenCalledTimes(1);
|
|
expect(store.tree).toEqual(sampleMenuTree);
|
|
expect(store.loaded).toBe(true);
|
|
expect(store.loading).toBe(false);
|
|
});
|
|
|
|
it('refetches when force=true', async () => {
|
|
const store = useMenuStore();
|
|
|
|
await store.fetchTree();
|
|
await store.fetchTree(true);
|
|
|
|
expect(menuMocks.listMenuTree).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('leaves the tree empty when the API rejects', async () => {
|
|
menuMocks.listMenuTree.mockRejectedValueOnce(new Error('boom'));
|
|
const store = useMenuStore();
|
|
|
|
await store.fetchTree();
|
|
|
|
// Network failures must not throw — UI falls back to a static Home.
|
|
expect(store.tree).toEqual([]);
|
|
expect(store.loaded).toBe(false);
|
|
expect(store.loading).toBe(false);
|
|
});
|
|
|
|
it('ignores a response that finishes after reset', async () => {
|
|
let resolveRequest!: (value: typeof sampleMenuTree) => void;
|
|
menuMocks.listMenuTree.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveRequest = resolve;
|
|
})
|
|
);
|
|
const store = useMenuStore();
|
|
|
|
const pending = store.fetchTree();
|
|
store.reset();
|
|
resolveRequest(sampleMenuTree);
|
|
await pending;
|
|
|
|
expect(store.tree).toEqual([]);
|
|
expect(store.loaded).toBe(false);
|
|
expect(store.loading).toBe(false);
|
|
});
|
|
|
|
it('coerces non-array payloads to an empty tree', async () => {
|
|
menuMocks.listMenuTree.mockResolvedValueOnce({not: 'an array'});
|
|
const store = useMenuStore();
|
|
|
|
await store.fetchTree();
|
|
|
|
expect(store.tree).toEqual([]);
|
|
expect(store.loaded).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getters', () => {
|
|
it('finds a node by code at any depth', async () => {
|
|
const store = useMenuStore();
|
|
await store.fetchTree();
|
|
|
|
expect(store.findByCode('home')?.id).toBe(1);
|
|
expect(store.findByCode('settings.user')?.id).toBe(21);
|
|
expect(store.findByCode('settings.user.detail')?.id).toBe(211);
|
|
expect(store.findByCode('does-not-exist')).toBeUndefined();
|
|
});
|
|
|
|
it('finds a node by id (string or number) at any depth', async () => {
|
|
const store = useMenuStore();
|
|
await store.fetchTree();
|
|
|
|
expect(store.findById(2)?.menuCode).toBe('settings');
|
|
expect(store.findById('21')?.menuCode).toBe('settings.user');
|
|
expect(store.findById(211)?.menuCode).toBe('settings.user.detail');
|
|
expect(store.findById(999)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('reset', () => {
|
|
it('drops the cached tree so the next fetch hits the API again', async () => {
|
|
const store = useMenuStore();
|
|
await store.fetchTree();
|
|
expect(store.loaded).toBe(true);
|
|
|
|
store.reset();
|
|
|
|
expect(store.tree).toEqual([]);
|
|
expect(store.loaded).toBe(false);
|
|
|
|
await store.fetchTree();
|
|
expect(menuMocks.listMenuTree).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
});
|
|
menuMocks.listMenuTree.mockResolvedValue(sampleMenuTree);
|
|
menuMocks.listMenuTree.mockResolvedValueOnce({not: 'an array'});
|