Files

144 lines
11 KiB
JavaScript
Raw Permalink Normal View History

// 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; });