add tests

This commit is contained in:
kae 2026-06-15 19:47:45 +09:00
parent 4ba4dc98bf
commit 70532dda3a
28 changed files with 1621 additions and 80 deletions

52
__mocks__/@umijs/max.ts Normal file
View File

@ -0,0 +1,52 @@
import { vi } from 'vitest';
// Generic API envelope used by the real backend: `{ code, msg, data }`.
// `data` carries the object-shaped fields pages read off `request(...)`
// directly (`list` for the login tenant search, `nodes`/`edges` for the CRM
// graph), all empty so access never throws.
function makeData() {
return { list: [], total: 0, records: [], nodes: [], edges: [] };
}
export const request = vi.fn(() =>
Promise.resolve({ code: 0, msg: 'ok', success: true, data: makeData() }),
);
export const history = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
go: vi.fn(),
location: { pathname: '/', search: '', hash: '', state: null },
};
export const useModel = vi.fn(() => ({
initialState: { currentUser: { name: 'tester', menuPaths: [] }, menuPaths: [] },
setInitialState: vi.fn(),
refresh: vi.fn(),
loading: false,
}));
export const useParams = vi.fn(() => ({ id: '1' }));
export const useSearchParams = vi.fn(() => [new URLSearchParams(), vi.fn()]);
export const useNavigate = vi.fn(() => vi.fn());
export const useLocation = vi.fn(() => history.location);
export const useAccess = vi.fn(() => ({}));
export const useIntl = vi.fn(() => ({ formatMessage: ({ defaultMessage }: any) => defaultMessage }));
export const Link = ({ children }: any) => children;
export const NavLink = ({ children }: any) => children;
export const Access = ({ children }: any) => children;
export const Outlet = () => null;
export const getLocale = vi.fn(() => 'zh-CN');
export const setLocale = vi.fn();
export default {
request,
history,
useModel,
useParams,
useNavigate,
Link,
};

View File

@ -8,20 +8,30 @@
"build": "max build", "build": "max build",
"postinstall": "max setup", "postinstall": "max setup",
"lint": "max lint", "lint": "max lint",
"format": "prettier --write ." "format": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1", "@ant-design/icons": "^5.6.1",
"@ant-design/pro-components": "^2.8.7", "@ant-design/pro-components": "^2.8.7",
"@umijs/max": "^4.4.8", "@umijs/max": "^4.4.8",
"antd": "^5.24.2", "antd": "^5.24.2",
"axios": "^1.8.4" "axios": "^1.8.4",
"react": "18.3.1",
"react-dom": "18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"typescript": "^5.7.3" "typescript": "^5.7.3",
"vitest": "^2.1.9"
}, },
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184" "packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184"
} }

1170
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('CRM / Graph page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('CRM / Relations page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../test/smoke';
import Page from './index';
describe('Dashboard page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('Inventory / Adjusts page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('Inventory / Bolts page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('Inventory / Checks page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('Inventory / Import page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('Inventory / Pans page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('Inventory / Products page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('Inventory / Stats page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('Inventory / Stocks page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../test/smoke';
import Page from './index';
describe('Login page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './detail';
describe('Purchase / Orders / detail page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('Purchase / Orders page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('Purchase / Receipts page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('Purchase / Stats page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('Purchase / Suppliers page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('System / Configs page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('System / Menus page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('System / Roles page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,11 @@
import { describe, vi } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('System / Users page', () => {
smokeTest(Page);
});

View File

@ -0,0 +1,105 @@
import { vi } from "vitest";
// Manual mock for the API layer. Every endpoint resolves with the standard
// `{ code: 0, data }` envelope so pages can render without a live backend.
// Object envelope covering the shapes pages read: `data.list` for paginated
// tables plus the nested arrays detail pages iterate (`details`, etc.). All
// default to empty so `.map`/`.length` access never throws.
function makeData() {
return {
list: [],
total: 0,
records: [],
nodes: [],
edges: [],
details: [],
payments: [],
receipts: [],
items: [],
children: [],
bolts: [],
pans: [],
colors: [],
};
}
const ok = () => Promise.resolve({ code: 0, msg: "ok", success: true, data: makeData() });
export const login = vi.fn(() =>
Promise.resolve({ code: 0, msg: "ok", data: { token: "test-token", userInfo: { tenantId: "t1", name: "tester" } } }),
);
export const getUserInfo = vi.fn(() =>
Promise.resolve({ code: 0, msg: "ok", data: { name: "tester", menuPaths: [], roles: [] } }),
);
export const logout = vi.fn(ok);
export const changePassword = vi.fn(ok);
export const listUsers = vi.fn(ok);
export const getUser = vi.fn(ok);
export const createUser = vi.fn(ok);
export const updateUser = vi.fn(ok);
export const deleteUser = vi.fn(ok);
export const updateUserStatus = vi.fn(ok);
export const listRoles = vi.fn(ok);
export const createRole = vi.fn(ok);
export const updateRole = vi.fn(ok);
export const deleteRole = vi.fn(ok);
export const setRolePermissions = vi.fn(ok);
export const listMenus = vi.fn(ok);
export const createMenu = vi.fn(ok);
export const updateMenu = vi.fn(ok);
export const deleteMenu = vi.fn(ok);
export const listLogs = vi.fn(ok);
export const listConfigs = vi.fn(ok);
export const updateConfig = vi.fn(ok);
export const listProducts = vi.fn(ok);
export const getProduct = vi.fn(ok);
export const createProduct = vi.fn(ok);
export const updateProduct = vi.fn(ok);
export const deleteProduct = vi.fn(ok);
export const exportProducts = vi.fn(ok);
export const getProductSummary = vi.fn(ok);
export const getColorDetail = vi.fn(ok);
export const getProductTree = vi.fn(ok);
export const listPans = vi.fn(ok);
export const savePans = vi.fn(ok);
export const listBolts = vi.fn(ok);
export const saveBolts = vi.fn(ok);
export const listPanView = vi.fn(ok);
export const listBoltView = vi.fn(ok);
export const getStockSummary = vi.fn(ok);
export const getStockByColor = vi.fn(ok);
export const getStockByLocation = vi.fn(ok);
export const listStockChecks = vi.fn(ok);
export const getStockCheck = vi.fn(ok);
export const createStockCheck = vi.fn(ok);
export const confirmStockCheck = vi.fn(ok);
export const listStockAdjusts = vi.fn(ok);
export const getStockAdjust = vi.fn(ok);
export const createStockAdjust = vi.fn(ok);
export const approveStockAdjust = vi.fn(ok);
export const listUpstream = vi.fn(ok);
export const listDownstream = vi.fn(ok);
export const createRelation = vi.fn(ok);
export const updateRelation = vi.fn(ok);
export const listRelationHistory = vi.fn(ok);
export const listSuppliers = vi.fn(ok);
export const getSupplier = vi.fn(ok);
export const createSupplier = vi.fn(ok);
export const updateSupplier = vi.fn(ok);
export const deleteSupplier = vi.fn(ok);
export const listPurchaseOrders = vi.fn(ok);
export const getPurchaseOrder = vi.fn(ok);
export const createPurchaseOrder = vi.fn(ok);
export const updatePurchaseOrder = vi.fn(ok);
export const confirmPurchaseOrder = vi.fn(ok);
export const cancelPurchaseOrder = vi.fn(ok);
export const listPurchaseReceipts = vi.fn(ok);
export const getPurchaseReceipt = vi.fn(ok);
export const createPurchaseReceipt = vi.fn(ok);
export const confirmPurchaseReceipt = vi.fn(ok);
export const listPurchasePayments = vi.fn(ok);
export const createPurchasePayment = vi.fn(ok);
export const getPurchaseStatsSummary = vi.fn(ok);
export const getPurchaseStatsBySupplier = vi.fn(ok);
export const getPurchaseStatsByProduct = vi.fn(ok);
export const listInventoryLogs = vi.fn(ok);

80
test/setup.ts Normal file
View File

@ -0,0 +1,80 @@
import '@testing-library/jest-dom/vitest';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
// jsdom is missing a handful of browser APIs that antd / pro-components touch on
// mount. Provide minimal stand-ins so component pages can render in tests.
if (!window.matchMedia) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
// @ts-expect-error -- assigning test stub
window.ResizeObserver = window.ResizeObserver || ResizeObserverStub;
class IntersectionObserverStub {
root = null;
rootMargin = '';
thresholds = [];
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
// @ts-expect-error -- assigning test stub
window.IntersectionObserver = window.IntersectionObserver || IntersectionObserverStub;
if (!window.scrollTo) {
// @ts-expect-error -- jsdom does not implement scrollTo
window.scrollTo = vi.fn();
}
// Some pages read auth tokens from localStorage during render; ensure a working
// Storage implementation is present regardless of the jsdom build.
if (typeof window.localStorage?.getItem !== 'function') {
const store = new Map<string, string>();
const storage: Storage = {
get length() {
return store.size;
},
clear: () => store.clear(),
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
key: (i: number) => Array.from(store.keys())[i] ?? null,
removeItem: (k: string) => void store.delete(k),
setItem: (k: string, v: string) => void store.set(k, String(v)),
};
Object.defineProperty(window, 'localStorage', { value: storage, configurable: true });
}
// jsdom's getContext is unimplemented; the CRM graph page guards against a null
// context, so returning null keeps it on its safe path.
if (!HTMLCanvasElement.prototype.getContext) {
HTMLCanvasElement.prototype.getContext = () => null;
}
// jsdom throws "Not implemented" when getComputedStyle is called with a
// pseudo-element (rc-table does this while measuring scrollbars). Drop the
// unsupported second argument so the call succeeds quietly.
const originalGetComputedStyle = window.getComputedStyle.bind(window);
window.getComputedStyle = ((elt: Element) =>
originalGetComputedStyle(elt)) as typeof window.getComputedStyle;
afterEach(() => {
cleanup();
});

25
test/smoke.tsx Normal file
View File

@ -0,0 +1,25 @@
import { it, expect } from 'vitest';
import { render, act, cleanup } from '@testing-library/react';
import type { ComponentType } from 'react';
/**
* Smoke test for a routed page entry: render it with its `@umijs/max` and
* `@/services/api` dependencies mocked, flush the async data fetches that
* pages kick off on mount, and assert it mounted without throwing. A render
* error (or an error thrown from a settled effect) fails the test.
*
* Each page test file calls this so every frontend entry has its own case.
*/
export function smokeTest(Page: ComponentType<any>) {
it('mounts without crashing', async () => {
const { container } = render(<Page />);
// Let the mocked API promises resolve and their state updates apply
// inside act(), so pages that fetch on mount settle cleanly.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(container).toBeInstanceOf(HTMLElement);
cleanup();
});
}

22
vitest.config.ts Normal file
View File

@ -0,0 +1,22 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@@': path.resolve(__dirname, 'src/.umi'),
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
css: false,
// Pages pull in antd + pro-components; allow a comfortable timeout.
testTimeout: 15000,
},
});