import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { test, beforeEach } from 'node:test'; import vm from 'node:vm'; import ts from 'typescript'; import { apiConfig, resolveApiConfig } from '../miniprogram/config/api.ts'; import { apiRequest, ApiRequestError, } from '../miniprogram/services/api-request.ts'; import * as contentApi from '../miniprogram/services/content-api.ts'; import * as model from '../miniprogram/pages/category/category.model.ts'; const categories = [ { id: 'math', name: '数感启蒙', icon: '📐', parentId: null, status: 'active', }, ]; const worksheet = (id, extra = {}) => ({ id, title: id, subtitle: '', category: 'math', path: `/mathPages/mathDraw/mathDraw?id=${id}`, ageMin: 3, ageMax: 8, difficulty: 1, previewImg: null, likes: '2', downloads: '10', createdAt: '2025-01-01T00:00:00Z', updatedAt: '2025-01-01T00:00:00Z', contentUpdatedAt: null, ...extra, }); const pageOf = (items, page = 1, total = items.length) => ({ items, page, pageSize: 100, total, hasNext: page * 100 < total, }); let requests; let navigations; let handler; beforeEach(() => { requests = []; navigations = []; apiConfig.developmentEnvironment = 'local'; apiConfig.productionBaseUrl = 'http://127.0.0.1:8800/api/v1'; handler = () => ({ data: [] }); globalThis.wx = { getAccountInfoSync: () => ({ miniProgram: { envVersion: 'develop' } }), request(options) { requests.push(options); Promise.resolve() .then(() => handler(options)) .then((result) => { options.success({ statusCode: result.statusCode ?? 200, data: result.raw ?? { code: 0, message: 'ok', data: result.data, }, }); }) .catch(() => options.fail({ errMsg: 'request:fail' })); }, showToast() {}, navigateTo(options) { navigations.push(options.url); }, switchTab(options) { navigations.push(options.url); }, }; }); /** 仅隔离埋点与绘图分享依赖;分类页、请求层及数据模型均使用真实源码。 */ function createCategoryPage(api = contentApi) { let options; const source = readFileSync( new URL('../miniprogram/pages/category/category.ts', import.meta.url), 'utf8', ); const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, }, }).outputText; const modules = { '../../base/pageMixin': { appSharePageMethods: {} }, '../../utils/index': { takePendingCategoryTabId: () => null }, '../../utils/tracker': { default: { reportPageView() {}, report() {}, reportWorksheetClick() {}, }, }, '../../services/api-request': { ApiRequestError }, '../../services/content-api': api, './category.model': model, }; vm.runInNewContext(code, { exports: {}, wx: globalThis.wx, Error, require(path) { assert.ok(path in modules, path); return modules[path]; }, Page(value) { options = value; }, }); const instance = { ...options, data: structuredClone(options.data), setData(value) { Object.assign(this.data, value); }, }; return instance; } function useRows(rows, selectedCategories = categories) { handler = ({ url, data }) => { const path = new URL(url).pathname; if (path.endsWith('/categories')) return { data: selectedCategories }; if (path.endsWith('/worksheets')) { const page = data.page; return { data: pageOf( rows.slice((page - 1) * 100, page * 100), page, rows.length, ), }; } const row = rows.find((item) => path.endsWith(`/${encodeURIComponent(item.id)}`), ); return row ? { data: row } : { statusCode: 404 }; }; } test('环境配置:体验/正式/未知包固定 production,暂允许指定的本机占位地址', () => { assert.equal(resolveApiConfig('develop').environment, 'local'); for (const version of ['release', 'trial', 'unknown']) { assert.equal(resolveApiConfig(version).environment, 'production'); assert.equal( resolveApiConfig(version).baseUrl, 'http://127.0.0.1:8800/api/v1', ); } apiConfig.productionBaseUrl = ''; assert.throws(() => resolveApiConfig('release'), /production/); apiConfig.productionBaseUrl = 'https://api.example.test/api/v1/'; assert.equal( resolveApiConfig('release').baseUrl, 'https://api.example.test/api/v1', ); apiConfig.productionBaseUrl = 'http://api.example.test/api/v1'; assert.throws(() => resolveApiConfig('release'), /配置/); }); test('请求层:HTTP、业务错误、畸形响应、网络错误均拒绝,不当作空成功', async () => { useRows([]); assert.deepEqual( await apiRequest('/categories', { auth: 'none' }), categories, ); assert.equal(requests[0].url, 'http://127.0.0.1:8800/api/v1/categories'); for (const result of [ { statusCode: 404 }, { statusCode: 503 }, { raw: { code: 1, data: [] } }, { raw: { code: 0 } }, { raw: 'error' }, ]) { handler = () => result; await assert.rejects( apiRequest('/categories', { auth: 'none' }), ApiRequestError, ); } handler = () => { throw new Error('offline'); }; await assert.rejects( apiRequest('/categories', { auth: 'none' }), /连接失败/, ); }); test('完整分页:121 条无遗漏,计数字符串保留精度,空数据正常结束', async () => { const rows = Array.from({ length: 121 }, (_, index) => worksheet(`item-${index}`, { downloads: '9223372036854775906' }), ); useRows(rows); const actual = await contentApi.listAllWorksheets(); assert.deepEqual(actual, rows); assert.equal(requests.length, 2); assert.equal(actual[120].downloads, '9223372036854775906'); useRows([]); assert.deepEqual(await contentApi.listAllWorksheets(), []); }); test('分页变化、重复记录、缺页及非法计数拒绝返回部分内容', async () => { for (const result of [ pageOf([worksheet('one'), worksheet('one')]), pageOf([worksheet('one')], 1, 2), pageOf([worksheet('one', { likes: 9007199254740992 })]), { ...pageOf([]), hasNext: true }, ]) { handler = () => ({ data: result }); await assert.rejects(contentApi.listAllWorksheets()); } const rows = Array.from({ length: 100 }, (_, index) => worksheet(`item-${index}`), ); handler = ({ data }) => ({ data: data.page === 1 ? pageOf(rows, 1, 101) : pageOf([worksheet('last')], 2, 102), }); await assert.rejects(contentApi.listAllWorksheets()); }); test('展示模型:采用服务端分类、空图有图标、不恢复失联分类,榜单精确比较 64 位计数', () => { const rows = [ worksheet('one', { likes: '9223372036854775806', downloads: '5' }), worksheet('two', { likes: '9223372036854775807', downloads: '9223372036854775906', }), worksheet('new', { likes: '1', updatedAt: '2026-10-01T00:00:00Z' }), worksheet('hidden-category', { category: 'papers' }), ]; const dataset = model.buildCategoryDataset( [...categories, { ...categories[0], id: 'craft', name: '创意手工' }], rows, ); assert.deepEqual( dataset.sidebarTabs.map((row) => row.id), ['all', 'hot', 'top-downloads', 'math', 'craft'], ); const all = model.getCategoryItems(dataset, 'all'); assert.equal(all.length, 3); assert.equal(all[0].id, 'new'); assert.equal(all[0].icon, '📐'); assert.equal(all[0].previewImg, ''); assert.equal(model.getCategoryItems(dataset, 'hot')[0].id, 'two'); assert.equal(model.getCategoryItems(dataset, 'top-downloads')[0].id, 'two'); assert.equal(model.getCategoryItems(dataset, 'math', 'new').length, 1); assert.equal(model.compareCountDescending('0005', '5'), 0); }); test('页面:接口成功展示真实数据,失败清空旧目录/内容,重试可恢复,失效分类回到全部', async () => { const page = createCategoryPage(); page.onLoad({ id: 'papers' }); useRows([worksheet('real')]); await page.loadWorksheets(); assert.equal(page.data.activeCategoryId, 'all'); assert.equal(page.data.displayItems[0].id, 'real'); assert.ok(!page.data.sidebarTabs.some((tab) => tab.id === 'papers')); handler = () => { throw new Error('offline'); }; await page.loadWorksheets(); assert.equal(page.data.displayItems.length, 0); assert.equal(page.data.sidebarTabs.length, 3); assert.ok(page.data.errorMessage); useRows([worksheet('retry')]); await page.loadWorksheets(); assert.equal(page.data.errorMessage, ''); assert.equal(page.data.displayItems[0].id, 'retry'); }); test('页面:旧请求/离开页面后的响应不会覆盖新请求', async () => { let finishOld; let calls = 0; const page = createCategoryPage({ ...contentApi, listCategories: () => ++calls === 1 ? new Promise((resolve) => { finishOld = resolve; }) : Promise.resolve(categories), listAllWorksheets: async () => [worksheet(calls === 1 ? 'old' : 'new')], }); const first = page.loadWorksheets(); await page.loadWorksheets(); assert.equal(page.data.displayItems[0].id, 'new'); finishOld(categories); await first; assert.equal(page.data.displayItems[0].id, 'new'); let finish; const hiddenPage = createCategoryPage({ ...contentApi, listCategories: () => new Promise((resolve) => { finish = resolve; }), listAllWorksheets: async () => [], }); const pending = hiddenPage.loadWorksheets(); hiddenPage.onHide(); finish(categories); await pending; assert.equal(hiddenPage.data.loading, true); }); test('页面:点击前检查详情;正常跳转,404 下架刷新并阻止进入本地页面', async () => { const page = createCategoryPage(); const row = worksheet('live'); useRows([row]); await page.loadWorksheets(); await page.onTapItem({ currentTarget: { dataset: { id: 'live' } } }); assert.deepEqual(navigations, [row.path]); navigations.length = 0; useRows([]); await page.onTapItem({ currentTarget: { dataset: { id: 'live' } } }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(navigations.length, 0); assert.equal(page.data.displayItems.length, 0); }); test( '本地联调:真实 8800 分类、全部题型、页面展示与点击详情一致', { skip: process.env.TEST_LOCAL_API !== '1' }, async () => { const realRequest = globalThis.wx.request; globalThis.wx.request = (options) => { const url = new URL(options.url); for (const [key, value] of Object.entries(options.data || {})) url.searchParams.set(key, String(value)); fetch(url, { signal: AbortSignal.timeout(5000) }) .then(async (response) => options.success({ statusCode: response.status, data: await response.json(), }), ) .catch(() => options.fail({})); }; try { const page = createCategoryPage(); await page.loadWorksheets(); assert.equal(page.data.errorMessage, ''); const rows = await contentApi.listAllWorksheets(); assert.ok(rows.length > 0); assert.equal(page.data.displayItems.length, rows.length); assert.ok(page.data.displayItems.every((row) => row.icon)); await page.onTapItem({ currentTarget: { dataset: { id: page.data.displayItems[0].id }, }, }); assert.equal(navigations.length, 1); console.log( `本地联调:${page.data.sidebarTabs.length - 3} 个分类、${rows.length} 个题型。`, ); } finally { globalThis.wx.request = realRequest; } }, );