379 lines
13 KiB
JavaScript
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;
|
|
}
|
|
},
|
|
);
|