144 lines
11 KiB
JavaScript
144 lines
11 KiB
JavaScript
// Pure UTS and page behavior checks; no real account, network or database.
|
|||
|
|
const assert = require('node:assert/strict');
|
||
|
|
const fs = require('node:fs');
|
||
|
|
const path = require('node:path');
|
||
|
|
const vm = require('node:vm');
|
||
|
|
const ts = require(process.env.AIRZ_TYPESCRIPT_PATH || 'D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
|
||
|
|
const root = path.resolve(__dirname, '..');
|
||
|
|
class JsonObject {
|
||
|
|
constructor(value = {}) { Object.assign(this, value); }
|
||
|
|
getString(key) { return typeof this[key] === 'string' ? this[key] : null; }
|
||
|
|
getNumber(key) { return typeof this[key] === 'number' ? this[key] : null; }
|
||
|
|
getBoolean(key) { return typeof this[key] === 'boolean' ? this[key] : null; }
|
||
|
|
}
|
||
|
|
const json = value => Array.isArray(value) ? value.map(json) : value && typeof value === 'object' ? new JsonObject(Object.fromEntries(Object.entries(value).map(([key, item]) => [key, json(item)]))) : value;
|
||
|
|
const plain = value => JSON.parse(JSON.stringify(value));
|
||
|
|
const policy = { dailyLimit: 1, makeUpAllowed: false, consecutiveBonusEnabled: false, profileRewardEnabled: false, invitationRewardEnabled: false, expiryPolicy: 'none', levelPolicy: 'unchanged', benefitsPolicy: 'none' };
|
||
|
|
const published = { status: 'published', ruleVersion: 'points-checkin-20261019-v1', checkInReward: 10, timezone: 'Asia/Shanghai', publishedRules: policy };
|
||
|
|
const draft = { status: 'draft', ruleVersion: null, checkInReward: null, timezone: 'Asia/Shanghai', publishedRules: null };
|
||
|
|
let ruleReply = published;
|
||
|
|
let signed = false;
|
||
|
|
let loseResponse = false;
|
||
|
|
let ledgerWrites = 0;
|
||
|
|
let calls = [];
|
||
|
|
let session = json({ userId: '1', token: 'synthetic-points-token' });
|
||
|
|
class ApiRequestError extends Error { constructor(message, status, errorCode) { super(message); this.status = status; this.errorCode = errorCode; } }
|
||
|
|
const summary = () => ({ balance: signed ? 30 : 20, serverDate: '2026-10-19', timezone: 'Asia/Shanghai', signedToday: signed, checkInEnabled: ruleReply.status === 'published', ruleVersion: ruleReply.ruleVersion, checkInReward: ruleReply.checkInReward, balanceState: 'normal' });
|
||
|
|
const api = { ApiRequestError, getSession: () => session, requestJson: async (url, method, body) => {
|
||
|
|
calls.push({ url, method, body: plain(body) });
|
||
|
|
if (url === '/points/rules') return json(ruleReply);
|
||
|
|
if (url === '/points') return json(summary());
|
||
|
|
if (url === '/points/tasks') return json({ serverDate: '2026-10-19', timezone: 'Asia/Shanghai', items: [
|
||
|
|
{ task: 'checkin', status: ruleReply.ruleVersion == null ? 'unavailable' : signed ? 'completed' : ruleReply.status === 'published' ? 'available' : 'unavailable', reward: ruleReply.checkInReward, ruleVersion: ruleReply.ruleVersion },
|
||
|
|
...['profile', 'invitation'].map(task => ({ task, status: 'unavailable', reward: null, ruleVersion: null }))
|
||
|
|
] });
|
||
|
|
if (url === '/points/ledger') return json({ items: [], snapshotId: '0', nextBeforeId: null });
|
||
|
|
if (url === '/points/check-in') {
|
||
|
|
const replayed = signed;
|
||
|
|
if (!signed) { signed = true; ledgerWrites++; }
|
||
|
|
if (loseResponse) { loseResponse = false; throw new Error('Synthetic response lost'); }
|
||
|
|
return json({ ledgerId: '91', delta: 10, balanceAfter: 30, signedDate: body.expectedDate, ruleVersion: body.expectedRuleVersion, replayed });
|
||
|
|
}
|
||
|
|
throw new Error('Unexpected points request: ' + url);
|
||
|
|
} };
|
||
|
|
function transpile(source) {
|
||
|
|
const result = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, reportDiagnostics: true });
|
||
|
|
assert.equal(result.diagnostics.filter(item => item.category === ts.DiagnosticCategory.Error).length, 0);
|
||
|
|
return result.outputText;
|
||
|
|
}
|
||
|
|
const service = {};
|
||
|
|
vm.runInNewContext(transpile(fs.readFileSync(path.join(root, 'services/points.uts'), 'utf8')), {
|
||
|
|
exports: service, UTSJSONObject: JsonObject, Date, Math, Number, Error, require: name => {
|
||
|
|
assert.equal(name, '@/services/api.uts'); return api;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
function page() {
|
||
|
|
let shown;
|
||
|
|
let share;
|
||
|
|
const storage = new Map();
|
||
|
|
const modals = [], toasts = [];
|
||
|
|
const source = fs.readFileSync(path.join(root, 'packages/account/points/points.uvue'), 'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
|
||
|
|
const exports = {};
|
||
|
|
vm.runInNewContext(transpile(source + '\ntaskDefinitions.forEach(definition => Object.setPrototypeOf(definition, UTSJSONObject.prototype));\nexports.page = { load, checkIn, showRules, pointsNote, pendingRequest, points, errorMessage, taskViews, taskCaption, doTask };'), {
|
||
|
|
exports, UTSJSONObject: JsonObject, Date, Math, Number, Error,
|
||
|
|
ref: value => ({ value }), computed: get => ({ get value() { return get(); } }), watch: () => {},
|
||
|
|
onShow: callback => { shown = callback; }, onHide: () => {}, onUnload: () => {},
|
||
|
|
onShareAppMessage: callback => { share = callback; },
|
||
|
|
uni: { showModal: value => modals.push(value), showToast: value => toasts.push(value), navigateTo: () => {} },
|
||
|
|
require: name => {
|
||
|
|
if (name === '@/services/api.uts') return api;
|
||
|
|
if (name === '@/services/points.uts') return service;
|
||
|
|
if (name === '@/services/storage.uts') return { getUserStorage: key => storage.get(key) ?? null, setUserStorage: (key, value) => storage.set(key, json(value)), removeUserStorage: key => storage.delete(key) };
|
||
|
|
if (name === '@/services/layout.uts') return { usePageLayout: () => ({ contentStyle: () => '', pageStyle: () => '' }) };
|
||
|
|
if (name.endsWith('.uvue')) return {};
|
||
|
|
throw new Error('Unexpected page dependency: ' + name);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return { ...exports.page, show: shown, share: () => share?.(), storage, modals, toasts };
|
||
|
|
}
|
||
|
|
const flush = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
|
||
|
|
let checks = 0, failures = 0;
|
||
|
|
async function test(name, action) {
|
||
|
|
ruleReply = plain(published); signed = false; loseResponse = false; ledgerWrites = 0; calls = [];
|
||
|
|
try { await action(); checks++; console.log('PASS ' + name); }
|
||
|
|
catch (error) { failures++; console.error('FAIL ' + name + ': ' + error.message); }
|
||
|
|
}
|
||
|
|
(async () => {
|
||
|
|
await test('published phase-one policy is parsed from the server', async () => assert.deepEqual(plain(await service.fetchPointsRules()), published));
|
||
|
|
await test('middle action shares the public home page without issuing points', async () => {
|
||
|
|
const view = page(); view.show(); await flush();
|
||
|
|
const task = view.taskViews.value[1];
|
||
|
|
assert.equal(task.id, 'share'); assert.equal(task.name, '分享');
|
||
|
|
assert.equal(view.taskCaption(task), '分享小程序');
|
||
|
|
view.doTask(task);
|
||
|
|
assert.deepEqual(plain(view.share()), { title: 'AI融资 · 智能融资分析与产品匹配', path: '/pages/home/home' });
|
||
|
|
assert.equal(calls.filter(item => item.method === 'POST').length, 0);
|
||
|
|
assert.equal(ledgerWrites, 0);
|
||
|
|
});
|
||
|
|
for (const [name, value] of Object.entries({ timezone: 'UTC', checkInReward: 0, ruleVersion: 'INVALID', publishedRules: null })) {
|
||
|
|
await test('reject inconsistent rule field ' + name, async () => { ruleReply[name] = value; await assert.rejects(service.fetchPointsRules()); });
|
||
|
|
}
|
||
|
|
for (const [name, value] of Object.entries({ dailyLimit: 2, makeUpAllowed: true, consecutiveBonusEnabled: true, profileRewardEnabled: true, invitationRewardEnabled: true, expiryPolicy: '30-days', levelPolicy: 'upgrade', benefitsPolicy: 'cash' })) {
|
||
|
|
await test('do not advertise unsupported policy ' + name, async () => { ruleReply.publishedRules[name] = value; await assert.rejects(service.fetchPointsRules()); });
|
||
|
|
}
|
||
|
|
await test('draft has no invented reward or policy', async () => { ruleReply = plain(draft); assert.deepEqual(plain(await service.fetchPointsRules()), draft); });
|
||
|
|
await test('draft cannot smuggle a published reward', async () => { ruleReply = { ...plain(draft), checkInReward: 10 }; await assert.rejects(service.fetchPointsRules()); });
|
||
|
|
await test('published rule dialog explains the complete approved policy', async () => {
|
||
|
|
const view = page(); view.show(); await flush(); view.showRules();
|
||
|
|
const content = view.modals.at(-1).content;
|
||
|
|
for (const text of ['10', '北京时间', '0点', '不支持补签', '连续签到', '不过期', '会员等级', '兑换', '提现', 'AI']) assert.ok(content.includes(text), 'Missing policy explanation: ' + text);
|
||
|
|
});
|
||
|
|
await test('reward wording follows the server instead of hardcoding ten', async () => {
|
||
|
|
ruleReply.checkInReward = 7;
|
||
|
|
const view = page(); view.show(); await flush(); view.showRules();
|
||
|
|
assert.match(view.modals.at(-1).content, /7 分/); assert.doesNotMatch(view.modals.at(-1).content, /10 分/);
|
||
|
|
});
|
||
|
|
await test('paused is distinct from unpublished and preserves rule information', async () => {
|
||
|
|
ruleReply.status = 'paused';
|
||
|
|
const view = page(); view.show(); await flush();
|
||
|
|
assert.match(view.pointsNote.value, /暂停/);
|
||
|
|
view.showRules(); assert.match(view.modals.at(-1).content, /暂停/); assert.match(view.modals.at(-1).content, /10 分/);
|
||
|
|
await view.checkIn(); assert.equal(calls.filter(item => item.method === 'POST').length, 0);
|
||
|
|
});
|
||
|
|
await test('draft dialog does not present ten points as an active reward', async () => {
|
||
|
|
ruleReply = plain(draft); const view = page(); view.show(); await flush(); view.showRules();
|
||
|
|
assert.match(view.modals.at(-1).content, /尚未|暂未/); assert.doesNotMatch(view.modals.at(-1).content, /10|不过期/);
|
||
|
|
});
|
||
|
|
await test('malformed policy blocks partial page data and check-in', async () => {
|
||
|
|
ruleReply.publishedRules.expiryPolicy = '30-days';
|
||
|
|
const view = page(); view.show(); await flush();
|
||
|
|
assert.equal(view.points.value, null); assert.notEqual(view.errorMessage.value, '');
|
||
|
|
await view.checkIn(); assert.equal(calls.filter(item => item.method === 'POST').length, 0);
|
||
|
|
});
|
||
|
|
await test('lost response retains the original request and confirms without a second reward', async () => {
|
||
|
|
const view = page(); view.show(); await flush(); loseResponse = true;
|
||
|
|
await view.checkIn(); const original = plain(view.pendingRequest.value);
|
||
|
|
assert.equal(ledgerWrites, 1); assert.equal(view.storage.size, 1);
|
||
|
|
await view.load(); assert.equal(view.points.value.signedToday, true);
|
||
|
|
await view.checkIn(); assert.equal(ledgerWrites, 1); assert.equal(view.pendingRequest.value, null); assert.equal(view.storage.size, 0);
|
||
|
|
const posts = calls.filter(item => item.method === 'POST'); assert.equal(posts.length, 2); assert.deepEqual(posts[0].body, original); assert.deepEqual(posts[1].body, original);
|
||
|
|
});
|
||
|
|
console.log('Points phase one: ' + checks + ' passed, ' + failures + ' failed; networkCalls=0.');
|
||
|
|
if (failures) process.exitCode = 1;
|
||
|
|
})().catch(error => { console.error(error); process.exitCode = 1; });
|