Files

215 lines
16 KiB
JavaScript
Raw Permalink Normal View History

// Exercise actual UTS adapters and detail-page bindings with frozen responses; no network or storage.
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const ts = require('D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
const root = path.resolve(__dirname, '..');
class Json {
constructor(value = {}) { for (const [key, item] of Object.entries(value)) this[key] = wrap(item); }
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; }
}
function wrap(value) { return Array.isArray(value) ? value.map(wrap) : value && typeof value === 'object' ? new Json(value) : value; }
let response;
const modules = {};
function compile(source, filename, extras = {}) {
const exports = {};
const compiled = ts.transpileModule(source, { reportDiagnostics: true, compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } });
assert.equal((compiled.diagnostics || []).filter(item => item.category === ts.DiagnosticCategory.Error).length, 0, filename + ' transpiles');
vm.runInNewContext(compiled.outputText, {
exports, UTSJSONObject: Json, Number, Math, Error, setTimeout, clearTimeout,
require: name => name.endsWith('api.uts') ? { requestJson: async () => new Json(response), getSession: () => null } :
name.endsWith('config.uts') ? { API_BASE_URL: 'https://synthetic.invalid/api/v1' } :
name.endsWith('records.uts') ? { revisionParams: value => ({ revisionId: value }) } :
name.endsWith('layout.uts') ? { usePageLayout: () => ({}) } :
name.endsWith('.uvue') || name.endsWith('assistant.uts') ? {} :
load(name.startsWith('@/') ? name.slice(2) : path.posix.normalize(path.posix.dirname(filename) + '/' + name)),
...extras
}, { filename });
return exports;
}
function load(filename) {
if (modules[filename]) return modules[filename];
const source = fs.readFileSync(path.join(root, filename), 'utf8').replace(/const (fieldNames|labels): UTSJSONObject = (.*)/g, 'const $1: UTSJSONObject = new UTSJSONObject($2)');
return modules[filename] = compile(source, filename);
}
function page(kind, exposure) {
const filename = `packages/finance/${kind}/${kind}.uvue`;
const source = fs.readFileSync(path.join(root, filename), 'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
return compile(source + '\nexports.page=' + exposure, filename, {
ref: value => ({ value }), computed: callback => ({ get value() { return callback(); } }),
onLoad: () => {}, onShow: () => {}, onHide: () => {}, onUnload: () => {}
}).page;
}
function fixture(state = 'eligible') {
return {
assessmentId: '9', revisionId: '11', resultVersion: 2,
matching: { status: 'completed', catalogSnapshotId: 'frozen-original', ruleVersion: 'product-matching-v2', orderingPolicyVersion: 'product-score-descending-v1' },
item: {
productId: '1', productVersion: 3, eligibility: state, score: 78, scorePolicyVersion: 'product-soft-score-v1', rank: 2,
currentAvailability: 'available', positiveReasons: [], blockingReasons: [], cautions: [], evidence: [], aiExplanation: { status: 'not_requested' },
productSnapshot: { productId: '1', productVersion: 3, productName: '合成产品', bankName: '合成机构', applicantTypes: ['personal'], regions: [],
source: { publisher: '合成机构', title: '原产品资料', reference: 'synthetic-reference' },
rules: [{ field: 'monthlyIncome', operator: 'gte', value: '8000.00', requiredSource: 'verified' }] }
}
};
}
function addReason(target, group, id, code, text, field = 'age', actual = null, source, outcome = 'unknown') {
const origin = source === undefined ? (code === 'AI_CONDITION_NOT_ASSESSED' ? 'ai_self_reported' : code === 'PROFILE_DETAIL_NOT_ASSESSED' ? 'product_snapshot' : 'self_reported') : source;
target.item.evidence.push({ evidenceId: id, fieldRefs: [field], actual, source: origin, kind: 'hard', outcome, reasonCode: code });
target.item[group].push({ reasonId: id, code, text, evidenceRefs: [id] });
}
const service = load('packages/finance/services/matches.uts');
async function detail(value) { response = value; return service.fetchMatchDetailRecord('9', '1', '11'); }
let checks = 0, failures = 0;
async function test(name, callback) {
try { await callback(); checks++; console.log('PASS ' + name); }
catch (error) { failures++; console.error('FAIL ' + name + '\n' + error.stack); }
}
const productText = fs.readFileSync(path.join(root, 'packages/finance/product/product.uvue'), 'utf8');
function subtitles(reason) {
const tags = Array.from(productText.matchAll(/<text\b((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/text>/g)).filter(item => /\bclass="reason-detail"/.test(item[1]));
assert.equal(tags.length, 2, 'both reason sections have a subtitle binding');
return tags.map(([, attributes, body]) => {
const condition = attributes.match(/v-if="([^"]+)"/);
if (condition && !vm.runInNewContext(condition[1], { reason })) return '';
return body.replace(/{{([\s\S]*?)}}/g, (_, expression) => vm.runInNewContext(expression, { reason }));
});
}
(async () => {
await test('only fixed advisory codes disappear; real missing, invalid, config, pending and limits remain', async () => {
const value = fixture();
const hidden = ['AI_CONDITION_NOT_ASSESSED', 'PROFILE_DETAIL_NOT_ASSESSED'];
const retained = ['AI_CONDITION_FAIL', 'FACT_MISSING', 'FACT_INVALID', 'SOURCE_INSUFFICIENT', 'PRODUCT_CONFIGURATION_UNVERIFIED', 'AI_CONDITION_PENDING', 'AI_CONDITION_LIMIT', 'AI_CONDITION_UNKNOWN'];
for (const code of hidden.concat(retained)) addReason(value, 'cautions', code, code, '未参与参考筛选;同样文字不能决定隐藏');
addReason(value, 'blockingReasons', 'real-fail', 'RULE_FAILED', '不满足已知条件', 'monthlyIncome', '1000.00', 'self_reported', 'fail');
const frozen = JSON.stringify(value);
const result = await detail(value);
assert.deepEqual(Array.from(result.item.cautions, item => item.id), retained);
assert.equal(result.item.blocking.length, 1);
assert.equal(JSON.stringify(value), frozen, 'original saved reasons and evidence remain intact');
});
await test('old and new frozen results retain eligibility, score, versions and full raw snapshots', async () => {
for (const state of ['eligible', 'unknown', 'ineligible']) {
const value = fixture(state);
addReason(value, 'cautions', 'unused-age', 'AI_CONDITION_NOT_ASSESSED', '年龄细项未参与参考筛选');
const frozen = JSON.stringify(value);
const result = await detail(value);
assert.equal(result.item.state, state); assert.equal(result.item.score, 78);
assert.equal(result.revisionId, '11'); assert.equal(result.snapshotId, 'frozen-original');
assert.equal(result.item.cautions.length, 0); assert.equal(JSON.stringify(value), frozen);
}
});
await test('advisory hiding requires a consistent unknown caution with an allowed real evidence source', async () => {
const value = fixture();
addReason(value, 'cautions', 'valid-ai', 'AI_CONDITION_NOT_ASSESSED', '未参与参考筛选');
addReason(value, 'cautions', 'valid-profile-user', 'PROFILE_DETAIL_NOT_ASSESSED', '未参与参考筛选', 'age', null, 'self_reported');
addReason(value, 'cautions', 'valid-profile-product', 'PROFILE_DETAIL_NOT_ASSESSED', '未参与参考筛选');
for (const [id, source, outcome, evidenceCode] of [
['conflicting-fail', 'ai_self_reported', 'fail', 'AI_CONDITION_NOT_ASSESSED'],
['wrong-code', 'ai_self_reported', 'unknown', 'FACT_MISSING'],
['wrong-source', 'product_snapshot', 'unknown', 'AI_CONDITION_NOT_ASSESSED'],
['unknown-source', 'unsupported', 'unknown', 'AI_CONDITION_NOT_ASSESSED']
]) {
addReason(value, 'cautions', id, 'AI_CONDITION_NOT_ASSESSED', '异常记录仍须显示', 'age', null, source, outcome);
value.item.evidence.at(-1).reasonCode = evidenceCode;
}
for (const [id, refs] of [['empty-refs', []], ['missing-refs', undefined], ['missing-evidence', ['absent']]])
value.item.cautions.push({ reasonId: id, code: 'AI_CONDITION_NOT_ASSESSED', text: '缺失证据仍须显示', evidenceRefs: refs });
addReason(value, 'cautions', 'mixed-evidence', 'AI_CONDITION_NOT_ASSESSED', '混合证据仍须显示');
value.item.cautions.at(-1).evidenceRefs.push('conflicting-fail');
addReason(value, 'blockingReasons', 'blocking-same-code', 'AI_CONDITION_NOT_ASSESSED', '异常阻断不能静默隐藏');
addReason(value, 'positiveReasons', 'positive-same-code', 'AI_CONDITION_NOT_ASSESSED', '异常正向不能静默隐藏');
const frozen = JSON.stringify(value);
const result = await detail(value);
assert.deepEqual(Array.from(result.item.cautions, item => item.id), ['conflicting-fail', 'wrong-code', 'wrong-source', 'unknown-source', 'empty-refs', 'missing-refs', 'missing-evidence', 'mixed-evidence']);
assert.equal(result.item.blocking[0].id, 'blocking-same-code');
assert.equal(result.item.positive[0].id, 'positive-same-code');
assert.equal(result.item.cautions.find(item => item.id === 'missing-evidence').evidence[0], '证据暂不可读取');
assert.equal(JSON.stringify(value), frozen);
});
await test('verified evidence and unknown or missing provenance retain their distinct source notices', async () => {
const value = fixture();
for (const [id, source] of [['verified', 'verified'], ['unknown', 'unsupported'], ['missing', null]])
addReason(value, 'cautions', id, 'SOURCE_INSUFFICIENT', '请查看来源状态', 'monthlyIncome', '12000.00', source);
const result = await detail(value);
assert.deepEqual(Array.from(result.item.cautions, item => item.evidence[0]), ['月收入:1.2 万元 · 外部核验', '月收入:1.2 万元 · 来源未知', '月收入:1.2 万元 · 来源未知']);
});
await test('standard known evidence keeps age, province, occupation and employer without provenance tails', async () => {
const value = fixture();
for (const [field, actual] of [['age', 36], ['provinceCode', '440000'], ['occupation', '企业职员'], ['employer', '合成单位']])
addReason(value, 'positiveReasons', field, 'RULE_PASSED', '满足对应条件', field, actual, 'self_reported', 'pass');
addReason(value, 'positiveReasons', 'product-evidence', 'RULE_PASSED', '满足产品条件', 'monthlyIncome', '12000.00', 'product_snapshot', 'pass');
const result = await detail(value);
assert.deepEqual(Array.from(result.item.positive, item => item.evidence[0]), ['年龄:36 岁', '所在省份:广东省', '职业类型:企业职员', '单位名称:合成单位', '月收入:1.2 万元']);
});
await test('legacy reason and saved explanation lose only known source parentheses', async () => {
const value = fixture();
const suffixes = ['(根据自填资料判断)', '(资料尚需核验)', '(用户自填,未经核验)', '(用户自填、未经核验)', '(自填信息未经核验)', '(产品快照)'];
value.item.aiExplanation = { status: 'completed', text: '已保存的原总解释', reasonExplanations: [] };
suffixes.forEach((suffix, index) => {
const id = 'reason-' + index;
addReason(value, 'positiveReasons', id, 'RULE_PASSED', '符合条件' + suffix + '。', 'age', 36, 'self_reported', 'pass');
value.item.aiExplanation.reasonExplanations.push({ reasonId: id, text: '满足该项要求' + suffix + '。', evidenceRefs: [id] });
});
const frozen = JSON.stringify(value);
const result = await detail(value);
for (const reason of result.item.positive) { assert.equal(reason.text, '符合条件。'); assert.equal(reason.explanation, '满足该项要求。'); }
assert.equal(JSON.stringify(value), frozen);
});
await test('real verification conditions and original product sources remain visible', async () => {
const value = fixture();
addReason(value, 'blockingReasons', 'source-required', 'SOURCE_INSUFFICIENT', '此条件需核验收入依据', 'monthlyIncome', '12000.00');
value.item.aiExplanation = { status: 'completed', reasonExplanations: [{ reasonId: 'source-required', text: '该产品条件要求提供可核验依据。', evidenceRefs: ['source-required'] }] };
const result = await detail(value);
assert.equal(result.item.blocking[0].text, '此条件需核验收入依据');
assert.equal(result.item.blocking[0].explanation, '该产品条件要求提供可核验依据。');
assert.equal(result.item.product.conditions[0], '月收入:不少于 0.8 万元(需核验依据)');
assert.equal(result.item.product.source, '合成机构 · 原产品资料');
assert.equal(result.item.product.sourceRef, 'synthetic-reference');
});
await test('known legacy footer sentence becomes a reference disclaimer in saved explanations', async () => {
const value = fixture();
const saved = '满足对应条件。自填信息未经核验;本页面不承诺额度、放款或获批。';
addReason(value, 'positiveReasons', 'legacy-copy', 'RULE_PASSED', '满足对应条件', 'age', 36, 'self_reported', 'pass');
value.item.aiExplanation = { status: 'completed', text: saved, reasonExplanations: [{ reasonId: 'legacy-copy', text: saved, evidenceRefs: ['legacy-copy'] }] };
const frozen = JSON.stringify(value);
const result = await detail(value);
assert.equal(result.item.positive[0].explanation, '满足对应条件。结果仅供参考,不代表审批结果。');
assert.equal(result.item.aiText, '满足对应条件。结果仅供参考,不代表审批结果。');
assert.equal(JSON.stringify(value), frozen);
});
await test('detail attention count uses displayed reasons while retaining real failures', async () => {
const value = fixture();
addReason(value, 'cautions', 'age', 'AI_CONDITION_NOT_ASSESSED', '年龄细项未参与参考筛选');
addReason(value, 'cautions', 'region', 'PROFILE_DETAIL_NOT_ASSESSED', '产品适用地区未参与参考筛选', 'cityCode', null, 'product_snapshot');
addReason(value, 'cautions', 'missing', 'FACT_MISSING', '请补充已采集的月收入', 'monthlyIncome');
addReason(value, 'blockingReasons', 'failed', 'RULE_FAILED', '已知条件不符');
const result = await detail(value);
const current = page('product', '{match,attention,attentionVisible}'); current.match.value = result.item;
assert.deepEqual(Array.from(current.attention.value, item => item.id), ['failed', 'missing']);
assert.equal(current.attentionVisible.value.length, 2);
});
await test('without saved explanation evidence is shown only through the evidence disclosure', () => {
const reason = { explanation: '', evidence: ['年龄:36 岁'] };
assert.deepEqual(subtitles(reason), ['', '']);
assert.equal((productText.match(/v-for="line in reason.evidence"/g) || []).length, 2, 'both disclosure lists retain the original evidence');
});
await test('saved explanation remains the subtitle in both detail sections', () => {
assert.deepEqual(subtitles({ explanation: '满足该项要求。', evidence: ['年龄:36 岁'] }), ['满足该项要求。', '满足该项要求。']);
});
await test('detail empty states and footer use reference language; compare labels do not request extra data', () => {
const empty = Array.from(productText.matchAll(/<text[^>]*class="empty-note"[^>]*>([\s\S]*?)<\/text>/g), item => item[1]);
assert.equal(empty.length, 2);
empty.forEach(value => assert.doesNotMatch(value, /核验|自填/));
const footer = productText.match(/<view class="footnotes">([\s\S]*?)<\/view>/)[1];
assert.doesNotMatch(footer, /自填|未经核验/); assert.match(footer, /仅供参考/); assert.match(footer, /不代表审批/);
const comparison = page('compare', '{detailRows}');
assert.equal(comparison.detailRows.find(row => row.id === 'cautions').label, '注意事项');
});
console.log(`PASS: ${checks} passed, ${failures} failed match detail copy scenarios; networkCalls=0 modelCalls=0 databaseWrites=0`);
if (failures) process.exitCode = 1;
})().catch(error => { console.error(error); process.exitCode = 1; });