Files
doodle-mini/tests/category-api.test.mjs
2026-10-19 17:49:19 +08:00

379 lines
13 KiB
JavaScript

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: '<html>error</html>' },
]) {
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;
}
},
);