fix: 修复匹配详情提示及公积金可选路径问题

修复匹配详情中“用户自填未经核验”等来源说明,清理未参与项的橙色注意提示,修正贷款统计口径文案;补充公积金完整可选路径提取逻辑,确保明确无额外时长要求的公积金分支可正确识别并通过。
This commit is contained in:
hujun
2026-10-19 16:11:59 +08:00
parent c95482a0f3
commit e5406c23a6
75 changed files with 31813 additions and 107 deletions
+73
View File
@@ -0,0 +1,73 @@
"""从版本化选择字典生成前后端城市数据;不下载、不修改生产配置。"""
import argparse
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser()
parser.add_argument('--backend', type=Path, required=True)
parser.add_argument('--check', action='store_true')
args = parser.parse_args()
data = json.loads((ROOT / 'docs/data/city-options.json').read_text(encoding='utf-8'))
regions = data['regions']
names = [city['name'] for region in regions for city in region['cities']]
codes = {city['code']: city['name'] for region in regions for city in region['cities'] if city['code']}
assert len(names) == len(set(names)), '城市名称不能歧义'
assert len(codes) == sum(bool(city['code']) for region in regions for city in region['cities']), '代码重复'
assert all(re.fullmatch(r'\d{6}', code) for code in codes)
uts = "// 由 scripts/generate-city-options.py 生成;来源和覆盖口径见 docs/data/city-options.json。\n"
uts += "export type CityOption = { name: string, code: string }\nexport type CityRegion = { name: string, cities: CityOption[] }\n"
uts += "export const cityRegions: CityRegion[] = [\n"
uts += ',\n'.join(' ' + json.dumps(region, ensure_ascii=False) for region in regions) + '\n]\n'
provinces = {str(index['code']) + '0000': index['name'] for index in data['provinces']}
uts += 'export const provinceOptions: CityOption[] = ' + json.dumps([{'name':name,'code':code} for code,name in provinces.items()],ensure_ascii=False) + '\n'
uts += '''
export const citySelection = (name: string): number[] => {
for (let province = 0; province < cityRegions.length; province++) {
const cities = cityRegions[province].cities
for (let city = 0; city < cities.length; city++) {
if (cities[city].name == name) return [province, city]
}
}
return [-1, -1]
}
export const cityCodeForName = (name: string): string | null => {
const selected = citySelection(name)
if (selected[0] < 0) return null
const code = cityRegions[selected[0]].cities[selected[1]].code
return code.length > 0 ? code : null
}
export const cityDisplayName = (name: string): string => {
const selected = citySelection(name)
if (selected[0] < 0) return name
const province = cityRegions[selected[0]].name
return province == name ? name : province + ' / ' + name
}
export const provinceCodeForName = (name: string): string | null => {
const index = provinceOptions.findIndex((item: CityOption): boolean => item.name == name)
return index < 0 ? null : provinceOptions[index].code
}
// 仅供历史城市资料回填为省份;未知名称不猜测。
export const provinceForCity = (name: string): string => {
if (provinceCodeForName(name) != null) return name
const selected = citySelection(name)
return selected[0] < 0 ? '' : cityRegions[selected[0]].name
}
'''
constant = ' const PROVINCES = [\n' + '\n'.join(" '%s' => '%s'," % (code, name) for code,name in provinces.items()) + '\n ];\n'
backend = args.backend / 'application/common/library/AssessmentRecords.php'
original = backend.read_text(encoding='utf-8')
php, replacements = re.subn(r' const PROVINCES = .*?;\n', lambda _: constant, original, count=1, flags=re.S)
if not replacements:
php = original.replace(' private static function only(', constant + '\n private static function only(',1)
for path, content in [(ROOT / 'services/cities.uts', uts), (backend, php)]:
if args.check:
assert path.read_text(encoding='utf-8') == content, f'生成文件不同步:{path}'
else:
path.write_text(content, encoding='utf-8', newline='\n')
print(f'{len(provinces)} province choices; legacy city lookup retained; ' + ('consistent' if args.check else 'generated'))
+40
View File
@@ -0,0 +1,40 @@
// Execute the analysis page's actual lifecycle with synthetic status responses; no network/model access.
const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),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 repo=path.resolve(__dirname,'..');
const compile=source=>ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText;
const routeExports={};vm.runInNewContext(compile(fs.readFileSync(path.join(repo,'services/report-route.uts'),'utf8')),{exports:routeExports});
const script=fs.readFileSync(path.join(repo,'packages/finance/analyzing/analyzing.uvue'),'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
function status(overrides={}){return {id:'42',revisionId:'9',publishedRevisionId:'9',status:'succeeded',statusLabel:'分析完成',message:'',hasReport:true,stage:'completed',canRetry:false,taskId:'task9',...overrides};}
function page(reply){
let token='synthetic-owner',response=reply,onLoad,onShow,onHide,onUnload;const navigations=[],timers=[];
const session=()=>token==null?null:{token,getString:key=>key==='token'?token:''};
const context={exports:{},Error,Date,Math,Promise,ref:value=>({value}),
onLoad:fn=>{onLoad=fn},onShow:fn=>{onShow=fn},onHide:fn=>{onHide=fn},onUnload:fn=>{onUnload=fn},
setTimeout:fn=>{timers.push(fn);return timers.length},clearTimeout:()=>{},setInterval:()=>1,clearInterval:()=>{},
uni:{redirectTo:({url})=>{navigations.push({kind:'redirect',url})},navigateTo:({url})=>{navigations.push({kind:'navigate',url})}},
require:module=>module.endsWith('layout.uts')?{usePageLayout:()=>({})}:module.endsWith('api.uts')?{getSession:session}:module.endsWith('records.uts')?{fetchAssessmentStatus:async()=>typeof response==='function'?response():response}:module.endsWith('report-route.uts')?routeExports:(()=>{throw Error('Unexpected dependency '+module)})()};
vm.runInNewContext(compile(script+'\nexports.refreshStatus=refreshStatus; exports.report=report; exports.statusMessage=()=>message.value;'),context);
onLoad({assessmentId:'42'});
return {navigations,timers,show:()=>onShow(),hide:()=>onHide(),setReply:r=>{response=r},setToken:t=>{token=t},refresh:()=>context.exports.refreshStatus(),manualReport:()=>context.exports.report(),message:()=>context.exports.statusMessage()};
}
const flush=async()=>{await Promise.resolve();await Promise.resolve();await Promise.resolve()};
let checks=0;const check=(ok,label)=>{assert.ok(ok,label);checks++};
(async()=>{
let p=page(status());p.show();await flush();
check(p.navigations.length===1,'successful analysis automatically opens its result');
assert.deepEqual(p.navigations[0],{kind:'redirect',url:'/packages/finance/report/report?assessmentId=42&tab=matches&revisionId=9'});checks++;
check(p.timers.length===0,'successful result does not keep polling');
p=page(status({status:'processing',stage:'queued',revisionId:'10',publishedRevisionId:'9'}));p.show();await flush();
check(p.navigations.length===0&&p.timers.length===1,'processing revision never opens the older published report');
p.setReply(status({revisionId:'10',publishedRevisionId:'10'}));p.timers[0]();await flush();
check(p.navigations.length===1&&p.navigations[0].url.endsWith('tab=matches&revisionId=10'),'poll completion opens the newly published revision');
p=page(status({status:'failed',canRetry:true}));p.show();await flush();check(p.navigations.length===0,'failed assessment stays on recovery page');
check(p.message().includes('分析未完成')&&!p.message().includes('可查看本次报告'),'failed analysis without server message does not announce completed report');
p=page(status({hasReport:false,publishedRevisionId:''}));p.show();await flush();check(p.navigations.length===0,'missing report never navigates to an unfrozen result');
p=page(status({publishedRevisionId:'8'}));p.show();await flush();check(p.navigations.length===0,'inconsistent published revision is not opened as the new result');
let resolve;const pending=new Promise(r=>{resolve=r});p=page(()=>pending);p.show();p.hide();resolve(status());await flush();check(p.navigations.length===0,'late response after leaving cannot navigate');
let switched;const delayed=new Promise(r=>{switched=r});p=page(()=>delayed);p.show();p.setToken('other-account');switched(status());await flush();check(p.navigations.length===0,'account switch rejects former account result');
p=page(status());p.setToken(null);p.show();await flush();check(p.navigations[0].url==='/packages/account/login/login','expired session requires login');
console.log('PASS: '+checks+' analysis result route checks; no network/model access');
})().catch(e=>{console.error(e);process.exitCode=1});
+271
View File
@@ -0,0 +1,271 @@
// Exercise real archive adapters/page scripts with synthetic memory responses; no network, DB or build.
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, '..');
const amountKeys = ['totalDebtBalance', 'monthlyLoanPayment', 'creditCardTotalLimit', 'creditCardUsedLimit'];
class Json {
constructor(values = {}) { for (const [key, value] of Object.entries(values)) this[key] = wrap(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; }
getArray(key) { return Array.isArray(this[key]) ? this[key] : null; }
}
function wrap(value) { return Array.isArray(value) ? value.map(wrap) : value && typeof value === 'object' && !(value instanceof Json) ? new Json(value) : value; }
const plain = value => JSON.parse(JSON.stringify(value));
function harness() {
const modules = {}; const queue = []; const calls = []; const hooks = {}; const navigation = [];
let session = new Json({ token: 'synthetic-archive-owner' });
const api = {
getSession: () => session,
async requestJson(route, method, params) {
calls.push({ route, method, params: plain(params) });
const response = queue.shift(); assert.ok(response, 'response queued for ' + route);
assert.equal(route, response.route); assert.equal(method, 'GET');
if (response.params !== undefined) assert.deepEqual(plain(params), response.params);
if (response.error) throw response.error;
return wrap(response.promise ? await response.promise : response.data);
}
};
function compile(source, filename, extras = {}) {
const exports = {};
const prepared = source.replace(/const (labels|fieldNames): UTSJSONObject = (.*)/g, 'const $1: UTSJSONObject = new UTSJSONObject($2)');
const compiled = ts.transpileModule(prepared, { 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, Error, Number, Date, Math, Promise, Array,
require(name) {
if (name.endsWith('.uvue')) return {};
if (name.endsWith('/api.uts') || name === './api.uts') return api;
if (name.endsWith('/layout.uts')) return { usePageLayout: () => ({ pageStyle: () => '', contentStyle: () => '' }) };
return load(name.startsWith('@/') ? name.slice(2) : path.posix.normalize(path.posix.dirname(filename) + '/' + name));
}, setInterval: () => 1, clearInterval: () => {}, ...extras
}, { filename });
return exports;
}
function load(filename) {
if (!modules[filename]) modules[filename] = compile(fs.readFileSync(path.join(root, filename), 'utf8'), filename);
return modules[filename];
}
function page() {
const filename = 'packages/finance/archive/archive.uvue';
const source = fs.readFileSync(path.join(root, filename), 'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
const exposure = '{detail,sections,completeness,completenessFailed,error,readCompleteness,retryCompleteness,refresh,clearView,archiveCompleteness:typeof archiveCompleteness==="undefined"?null:archiveCompleteness}';
return compile(source + '\nexports.page=' + exposure, filename, {
ref: value => ({ value }), computed: callback => ({ get value() { return callback(); } }),
onLoad: callback => { hooks.load = callback; }, onShow: callback => { hooks.show = callback; },
onHide: callback => { hooks.hide = callback; }, onUnload: callback => { hooks.unload = callback; },
uni: { redirectTo: options => navigation.push(options.url), navigateTo: options => navigation.push(options.url) }
}).page;
}
function enqueue(route, data, params) { queue.push({ route, data, params }); }
function defer(route, params) {
let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
queue.push({ route, promise, params }); return { resolve, reject };
}
return { load, page, hooks, calls, queue, navigation, enqueue, defer, fail: (route, error = new Error('synthetic read failed')) => queue.push({ route, error }), setSession: token => { session = token == null ? null : new Json({ token }); } };
}
function detailResponse(overrides = {}) {
const form = { name: '合成资料', phone: '199****0009', city: '广东省', provinceCode: '440000', cityCode: null,
occupation: '合成职业', monthlyIncome: '20000.00', loanCountScope: 'all_loans', creditLoanCount: 0, creditCardCount: 0,
house: 'no', car: 'no', totalDebtBalance: null, monthlyLoanPayment: null, creditCardTotalLimit: null, creditCardUsedLimit: null, ...overrides.form };
return { assessmentId: '1', revisionId: '2', revisionNo: 1, type: 'personal', status: 'succeeded', statusLabel: '分析完成',
message: '', taskId: 'synthetic-task', stage: 'completed', canRetry: false, hasReport: true, publishedRevisionId: '2',
submittedAt: '2026-01-01T00:00:00Z', inputVersion: 3, moneyUnit: 'yuan', ...overrides, form };
}
function reportResponse(overrides = {}) {
return { assessmentId: '1', revisionId: '2', resultVersion: 2,
completeness: { status: 'evaluated', percent: 100, items: amountKeys.map(fieldKey => ({ fieldKey, state: 'not_applicable' })), ...overrides.completeness },
...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'completeness')) };
}
async function fetchDetail(h, response = detailResponse()) {
h.enqueue('/assessments/' + response.assessmentId, response, { revisionId: response.revisionId });
return h.load('packages/finance/services/records.uts').fetchAssessmentDetail(response.assessmentId, response.revisionId);
}
async function fetchCompleteness(h, response = reportResponse()) {
h.enqueue('/assessments/1/report', response, { revisionId: '2' });
return h.load('packages/finance/services/records.uts').fetchArchiveCompleteness('1', '2');
}
const settle = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
function queuePageReads(h, detail = detailResponse()) {
const id = detail.assessmentId; const revision = detail.revisionId;
h.enqueue('/assessments/' + id, detail, { revisionId: revision });
h.enqueue('/assessments', { items: [detail], hasMore: false, nextCursor: '' }, { limit: '20', cursor: '', type: detail.type });
h.enqueue('/assessments/' + id + '/revisions', { items: [detail], hasMore: false, nextCursor: '' }, { cursor: '' });
}
async function openPage(h, report = reportResponse(), options = {}) {
const detail = options.detail || detailResponse(); const view = h.page();
h.hooks.load({ assessmentId: detail.assessmentId, revisionId: detail.revisionId });
queuePageReads(h, detail);
let pending = null;
if (detail.status === 'succeeded') {
if (options.deferred) pending = h.defer('/assessments/' + detail.assessmentId + '/report', { revisionId: detail.revisionId });
else if (options.error) h.fail('/assessments/' + detail.assessmentId + '/report');
else h.enqueue('/assessments/' + detail.assessmentId + '/report', report, { revisionId: detail.revisionId });
}
h.hooks.show(); await settle();
return { view, pending };
}
function amountValues(sections) {
return plain(sections.find(item => item.id === 'assets').fields.filter(item => amountKeys.includes(item.fieldKey)).map(item => item.value));
}
let passed = 0, failed = 0;
async function test(name, callback) {
try { await callback(); passed++; console.log('PASS ' + name); }
catch (error) { failed++; console.error('FAIL ' + name + '\n' + error.stack); }
}
(async () => {
await test('same-revision saved not_applicable evidence changes only missing amount display', async () => {
const h = harness(); const records = h.load('packages/finance/services/records.uts');
const detail = await fetchDetail(h); const before = JSON.stringify(detail);
const completeness = await fetchCompleteness(h);
assert.ok(Array.isArray(completeness.notApplicableFields), 'saved report must expose applicable archive display evidence');
assert.deepEqual(plain(completeness.notApplicableFields), amountKeys);
const sections = records.archiveSections(detail, completeness);
const fields = sections.find(item => item.id === 'assets').fields;
assert.deepEqual(plain(fields.filter(item => amountKeys.includes(item.fieldKey)).map(item => item.value)), ['不适用', '不适用', '不适用', '不适用']);
assert.equal(sections.find(item => item.id === 'assets').provided, 4);
assert.equal(JSON.stringify(detail), before); assert.equal(h.calls.length, 2);
assert.ok(detail.fields.filter(item => amountKeys.includes(item.fieldKey)).every(item => item.isMissing && item.value === '未填写'));
});
await test('explicit zero and positive saved amounts remain visible despite conflicting display markers', async () => {
const h = harness(); const records = h.load('packages/finance/services/records.uts');
const detail = await fetchDetail(h, detailResponse({ form: { totalDebtBalance: '0.00', monthlyLoanPayment: '12345.67', creditCardTotalLimit: '50000.00', creditCardUsedLimit: null } }));
const before = JSON.stringify(detail); const completeness = await fetchCompleteness(h);
assert.deepEqual(amountValues(records.archiveSections(detail, completeness)), ['0 万元', '1.234567 万元', '5 万元', '不适用']);
assert.equal(JSON.stringify(detail), before);
assert.equal(detail.sections.find(item => item.id === 'assets').provided, 7);
assert.ok(detail.fields.filter(item => ['totalDebtBalance', 'monthlyLoanPayment', 'creditCardTotalLimit'].includes(item.fieldKey)).every(item => item.isMissing === false));
});
await test('missing origin flag rather than Chinese placeholder wording controls display adaptation', async () => {
const h = harness(); const records = h.load('packages/finance/services/records.uts'); const detail = await fetchDetail(h);
const fields = detail.sections.find(item => item.id === 'assets').fields;
fields.find(item => item.fieldKey === 'totalDebtBalance').value = '尚未提供';
const completeness = await fetchCompleteness(h);
assert.equal(records.archiveSections(detail, completeness).find(item => item.id === 'assets').fields.find(item => item.fieldKey === 'totalDebtBalance').value, '不适用');
assert.equal(fields.find(item => item.fieldKey === 'totalDebtBalance').value, '尚未提供');
});
await test('partial saved markers affect only matching whitelist field keys, not counts or unrelated blanks', async () => {
const h = harness(); const records = h.load('packages/finance/services/records.uts'); const detail = await fetchDetail(h);
const completeness = await fetchCompleteness(h, reportResponse({ completeness: { items: [
{ fieldKey: 'totalDebtBalance', state: 'not_applicable' }, { fieldKey: 'creditLoanCount', state: 'not_applicable' },
{ fieldKey: 'company', state: 'not_applicable' }, { fieldKey: 'monthlyLoanPayment', state: 'missing' }
] } }));
assert.deepEqual(plain(completeness.notApplicableFields), ['totalDebtBalance']);
assert.deepEqual(amountValues(records.archiveSections(detail, completeness)), ['不适用', '未填写', '未填写', '未填写']);
assert.equal(records.archiveSections(detail, completeness).find(item => item.id === 'assets').fields.find(item => item.fieldKey === 'creditLoanCount').value, '0');
});
await test('old loan scope and count zero do not infer a saved marker or recompute report completeness', async () => {
const h = harness(); const records = h.load('packages/finance/services/records.uts');
const detail = await fetchDetail(h, detailResponse({ form: { loanCountScope: undefined } }));
const completeness = await fetchCompleteness(h, reportResponse({ completeness: { percent: 60, items: [] } }));
assert.equal(completeness.percent, 60); assert.deepEqual(plain(completeness.notApplicableFields), []);
assert.deepEqual(amountValues(records.archiveSections(detail, completeness)), ['未填写', '未填写', '未填写', '未填写']);
});
await test('absent or nonarray saved items never supply display evidence', async () => {
for (const items of [undefined, null, {}, 'not_applicable']) {
const h = harness(); const result = await fetchCompleteness(h, reportResponse({ completeness: { items } }));
assert.deepEqual(plain(result.notApplicableFields), []); assert.equal(result.percent, 100);
}
});
await test('v1 and unevaluated reports supply no new not-applicable display evidence', async () => {
for (const response of [reportResponse({ resultVersion: 1 }), reportResponse({ completeness: { status: 'not_evaluated' } })]) {
const h = harness(); const result = await fetchCompleteness(h, response);
assert.equal(result.percent, null); assert.deepEqual(plain(result.notApplicableFields), []);
assert.equal(result.assessmentId, '1'); assert.equal(result.revisionId, '2');
}
});
await test('duplicate keys including conflicting or identical markers are excluded independently', async () => {
const h = harness(); const result = await fetchCompleteness(h, reportResponse({ completeness: { items: [
{ fieldKey: 'totalDebtBalance', state: 'not_applicable' }, { fieldKey: 'totalDebtBalance', state: 'provided' },
{ fieldKey: 'monthlyLoanPayment', state: 'not_applicable' }, { fieldKey: 'monthlyLoanPayment', state: 'not_applicable' },
{ fieldKey: 'creditCardTotalLimit', state: 'not_applicable' }, { fieldKey: 'creditCardUsedLimit', state: 'invalid' },
null, 0, 'unknown', { label: '欠款总余额', state: 'not_applicable' }
] } }));
assert.deepEqual(plain(result.notApplicableFields), ['creditCardTotalLimit']);
});
await test('different assessment or revision evidence cannot change the current archive', async () => {
const h = harness(); const records = h.load('packages/finance/services/records.uts'); const detail = await fetchDetail(h);
const result = await fetchCompleteness(h);
for (const evidence of [null, { ...result, assessmentId: '3' }, { ...result, revisionId: '4' }]) {
assert.deepEqual(amountValues(records.archiveSections(detail, evidence)), ['未填写', '未填写', '未填写', '未填写']);
}
assert.equal(h.calls.length, 2);
h.enqueue('/assessments/1/report', reportResponse({ revisionId: '4' }), { revisionId: '2' });
await assert.rejects(records.fetchArchiveCompleteness('1', '2'), /报告版本不一致/);
});
await test('display adaptation copies changed amount fields and preserves original values and provided counts', async () => {
const h = harness(); const records = h.load('packages/finance/services/records.uts'); const detail = await fetchDetail(h);
const result = await fetchCompleteness(h); const before = JSON.stringify(detail);
const display = records.archiveSections(detail, result);
assert.notEqual(display, detail.sections); assert.notEqual(display[0], detail.sections[0]);
const changed = display.find(item => item.id === 'assets').fields.find(item => item.fieldKey === 'totalDebtBalance');
const originalField = detail.sections.find(item => item.id === 'assets').fields.find(item => item.fieldKey === 'totalDebtBalance');
assert.notEqual(display[0].fields, detail.sections[0].fields); assert.notEqual(changed, originalField);
assert.deepEqual(plain(display.map(item => item.provided)), plain(detail.sections.map(item => item.provided)));
changed.value = 'changed-display-only'; assert.equal(JSON.stringify(detail), before);
});
await test('archive page uses its existing report read to present markers with no additional requests', async () => {
const h = harness(); const { view } = await openPage(h);
assert.equal(view.error.value, ''); assert.equal(view.completeness.value, 100);
assert.deepEqual(amountValues(view.sections.value), ['不适用', '不适用', '不适用', '不适用']);
assert.deepEqual(amountValues(view.detail.value.sections), ['未填写', '未填写', '未填写', '未填写']);
assert.equal(h.calls.length, 4); assert.equal(h.calls.filter(item => item.route.endsWith('/report')).length, 1);
assert.equal(h.queue.length, 0); h.hooks.hide(); assert.equal(view.archiveCompleteness.value, null);
});
await test('failed report read leaves raw amounts and manual retry restores only saved display markers', async () => {
const h = harness(); const { view } = await openPage(h, null, { error: true });
assert.equal(view.completenessFailed.value, true); assert.equal(view.archiveCompleteness.value, null);
assert.deepEqual(amountValues(view.sections.value), ['未填写', '未填写', '未填写', '未填写']);
h.enqueue('/assessments/1/report', reportResponse(), { revisionId: '2' }); view.retryCompleteness(); await settle();
assert.equal(view.completenessFailed.value, false); assert.deepEqual(amountValues(view.sections.value), ['不适用', '不适用', '不适用', '不适用']);
h.fail('/assessments/1/report'); view.retryCompleteness(); await settle();
assert.equal(view.completenessFailed.value, true); assert.equal(view.archiveCompleteness.value, null);
assert.deepEqual(amountValues(view.sections.value), ['未填写', '未填写', '未填写', '未填写']); assert.equal(h.calls.length, 6);
h.hooks.hide();
});
await test('late earlier report response cannot replace a newer manual retry result', async () => {
const h = harness(); const { view, pending } = await openPage(h, null, { deferred: true });
const newer = h.defer('/assessments/1/report', { revisionId: '2' }); view.retryCompleteness();
newer.resolve(reportResponse({ completeness: { items: [{ fieldKey: 'monthlyLoanPayment', state: 'not_applicable' }] } })); await settle();
assert.deepEqual(amountValues(view.sections.value), ['未填写', '不适用', '未填写', '未填写']);
pending.resolve(reportResponse()); await settle();
assert.deepEqual(amountValues(view.sections.value), ['未填写', '不适用', '未填写', '未填写']); h.hooks.hide();
});
await test('late failed earlier read cannot clear a newer successful display result', async () => {
const h = harness(); const { view, pending } = await openPage(h, null, { deferred: true });
h.enqueue('/assessments/1/report', reportResponse(), { revisionId: '2' }); view.retryCompleteness(); await settle();
pending.reject(new Error('synthetic old read failure')); await settle();
assert.equal(view.completenessFailed.value, false); assert.deepEqual(amountValues(view.sections.value), ['不适用', '不适用', '不适用', '不适用']); h.hooks.hide();
});
await test('changing account before a delayed report resolves clears old archive data', async () => {
const h = harness(); const { view, pending } = await openPage(h, null, { deferred: true });
h.setSession('synthetic-other-owner'); pending.resolve(reportResponse()); await settle();
assert.equal(view.detail.value, null); assert.equal(view.archiveCompleteness.value, null);
assert.ok(view.sections.value.every(item => item.fields.length === 0)); h.hooks.hide();
});
await test('hide before report completion prevents display evidence and personal detail from reappearing', async () => {
const h = harness(); const { view, pending } = await openPage(h, null, { deferred: true });
h.hooks.hide(); pending.resolve(reportResponse()); await settle();
assert.equal(view.detail.value, null); assert.equal(view.archiveCompleteness.value, null);
assert.equal(view.completeness.value, null);
});
await test('late report cannot attach after current detail changes assessment or revision', async () => {
for (const response of [detailResponse({ assessmentId: '3' }), detailResponse({ revisionId: '4', publishedRevisionId: '4' })]) {
const h = harness(); const { view, pending } = await openPage(h, null, { deferred: true });
view.detail.value = await fetchDetail(h, response); pending.resolve(reportResponse()); await settle();
assert.equal(view.archiveCompleteness.value, null);
assert.deepEqual(amountValues(view.sections.value), ['未填写', '未填写', '未填写', '未填写']); h.hooks.hide();
}
});
await test('processing or failed assessments do not fetch or infer report display markers', async () => {
for (const status of ['processing', 'failed']) {
const h = harness(); const { view } = await openPage(h, null, { detail: detailResponse({ status, hasReport: false, publishedRevisionId: '' }) });
assert.equal(view.archiveCompleteness.value, null); assert.equal(h.calls.length, 3);
assert.deepEqual(amountValues(view.sections.value), ['未填写', '未填写', '未填写', '未填写']); h.hooks.hide();
}
});
console.log(`${failed ? 'FAIL' : 'PASS'} ${passed} archive display checks; ${failed} failures; no network/database/model/build.`);
process.exitCode = failed ? 1 : 0;
})();
+22
View File
@@ -0,0 +1,22 @@
const fs=require('node:fs'),vm=require('node:vm'),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');
class Json {constructor(values={}){Object.assign(this,values)} getString(k){return this[k]??null} getNumber(k){return this[k]??null} getBoolean(k){return this[k]??null} getJSON(k){return this[k]??null} getArray(k){return this[k]??null}}
let sent, reply;
function load(file){const out={};const source=fs.readFileSync(__dirname+'/../'+file,'utf8');vm.runInNewContext(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,{exports:out,UTSJSONObject:Json,require:p=>{
if(p.endsWith('api.uts'))return {getSession:()=>({token:'synthetic'}),requestJson:async(path,method,body)=>{sent=body;return reply ?? new Json({assessmentId:'1',revisionId:'2',submissionStatus:'accepted'})}};
if(p.endsWith('/assessment.uts'))return {assessmentApiPayload:p=>p,draftText:(p,k)=>typeof p[k]==='string'?p[k]:''};
if(p.endsWith('assessment-flow.uts'))return {assessmentRequestKey:()=> 'synthetic-key'};
if(p.endsWith('cities.uts'))return load('services/cities.uts');
if(p.endsWith('contribution.uts'))return {contributionLabel:v=>v};
return {};
}});return out;}
(async()=>{const service=load('packages/finance/services/records.uts');let checks=0;
for(const [city,provinceCode,cityCode]of[['广东省','440000',null],['北京市','110000',null],['台湾省','710000',null],['深圳市',undefined,'440300'],['南京市',undefined,null],['旧手填地区',undefined,null]]){
await service.createAssessment(new Json({type:'personal',city,documents:[],authorizations:[]}));
assert.equal(sent.form.city,city);assert.equal(sent.form.cityCode,cityCode);assert.equal(sent.form.provinceCode,provinceCode);checks+=3;
}
for(const [city,provinceCode,label]of[['广东省','440000','所在省份'],['深圳市',undefined,'城市']]){
reply=new Json({assessmentId:'1',revisionId:'2',revisionNo:1,type:'personal',status:'succeeded',form:new Json({city,provinceCode})});
const detail=await service.fetchAssessmentDetail('1','2');assert.equal(detail.fields[2].label,label);assert.equal(detail.fields[2].value,city);checks+=2;
}
console.log(`PASS: ${checks} region submit/detail checks`);})().catch(e=>{console.error(e);process.exitCode=1});
+214
View File
@@ -0,0 +1,214 @@
// 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; });
+26
View File
@@ -0,0 +1,26 @@
// Exercise the actual UTS adapter with frozen synthetic responses; no network/model access.
const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),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');
class Json{constructor(v={}){Object.entries(v).forEach(([k,x])=>this[k]=wrap(x));}getString(k){return typeof this[k]==='string'?this[k]:null;}getNumber(k){return typeof this[k]==='number'?this[k]:null;}getBoolean(k){return typeof this[k]==='boolean'?this[k]:null;}}
function wrap(v){return Array.isArray(v)?v.map(wrap):v&&typeof v==='object'?new Json(v):v;}
let reply,query;
function load(file){const exports={};const source=fs.readFileSync(path.join(__dirname,'..',file),'utf8').replace(/const (fieldNames|labels): UTSJSONObject = (.*)/g,'const $1: UTSJSONObject = new UTSJSONObject($2)');vm.runInNewContext(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,{exports,UTSJSONObject:Json,Error,Number,Math,require:p=>p.endsWith('api.uts')?{requestJson:async(_url,_method,params)=>{query=params;return new Json(reply);}}:p.endsWith('config.uts')?{API_BASE_URL:'http://synthetic.invalid/api/v1'}:p.endsWith('records.uts')?{revisionParams:v=>({revisionId:v})}:p.endsWith('cities.uts')?load('services/cities.uts'):load('services/money.uts')},{filename:file});return exports;}
function item(id,state,score){return {productId:id,productVersion:1,eligibility:state,score,scorePolicyVersion:'product-soft-score-v1',currentAvailability:'available',positiveReasons:[],blockingReasons:[],cautions:[],evidence:[],aiExplanation:{status:'not_requested'},productSnapshot:{productId:id,productVersion:1,productName:'合成产品'+id,bankName:'合成机构',applicantTypes:['personal'],regions:[],source:{},rules:[]}};}
function page(items){return {assessmentId:'9',revisionId:'11',resultVersion:2,matching:{status:'completed',ruleVersion:'product-matching-v2',orderingPolicyVersion:'product-score-descending-v1',catalogSnapshotId:'frozen',candidateCount:0,ineligibleCount:0,unknownCount:5},items,hasMore:true,nextCursor:'synthetic-opaque'};}
(async()=>{const service=load('packages/finance/services/matches.uts');let checks=0;
for(const [state,score,want] of [['eligible',92,'92%'],['eligible',0,'0%'],['unknown',80,'80%'],['ineligible',20,'20%'],['unknown',null,''],['ineligible',null,''],['eligible',null,'']]){assert.equal(service.matchPercentage(state,score),want);checks++;}
assert.equal(service.eligibilityLabel('unknown'),'待核实');checks++;
reply=page([item('2','unknown',30),item('5','unknown',30),item('1','unknown',20)]);
let result=await service.fetchMatchPage('9','all','','11');assert.equal(result.items.length,3);assert.equal(query.eligibility,'all');checks+=2;
assert.deepEqual(Array.from(result.items,x=>x.state),['unknown','unknown','unknown']);checks++;
reply=page([item('1','eligible',0),item('2','unknown',50),item('3','ineligible',10)]);reply.matching.candidateCount=1;reply.matching.ineligibleCount=1;reply.matching.unknownCount=1;reply.hasMore=false;reply.nextCursor=null;
result=await service.fetchMatchPage('9','all','','11');assert.equal(result.items[0].score,0);assert.equal(service.matchPercentage('eligible',0),'0%');checks+=2;
await assert.rejects(service.fetchMatchPage('9','eligible','','11'));checks++;
reply=page([item('2','unknown',30),item('2','unknown',30)]);await assert.rejects(service.fetchMatchPage('9','all','','11'));checks++;
reply=page([item('1','unrecognized',20)]);await assert.rejects(service.fetchMatchPage('9','all','','11'));checks++;
reply=page([item('1','unknown',20)]);reply.revisionId='12';await assert.rejects(service.fetchMatchPage('9','all','','11'));checks++;
reply=page([item('1','unknown',20)]);reply.hasMore=false;reply.nextCursor=null;assert.equal((await service.fetchMatchPage('9','all','','11')).items.length,1);checks++;
const age=item('1','eligible',30);age.evidence=[{evidenceId:'age',fieldRefs:['age'],actual:36,source:'ai_self_reported'}];age.positiveReasons=[{reasonId:'age-reason',code:'AI_CONDITION_PASS',text:'符合年龄要求',evidenceRefs:['age']}];
reply={assessmentId:'9',revisionId:'11',resultVersion:2,matching:page([]).matching,item:age};result=await service.fetchMatchDetailRecord('9','1','11');assert.equal(result.item.positive[0].evidence[0],'年龄:36 岁');checks++;
const province=item('1','eligible',80);province.evidence=[{evidenceId:'province',fieldRefs:['provinceCode'],actual:'440000',source:'ai_self_reported'}];province.positiveReasons=[{reasonId:'region-reason',code:'AI_CONDITION_PASS',text:'符合省份要求',evidenceRefs:['province']}];reply={assessmentId:'9',revisionId:'11',resultVersion:2,matching:page([]).matching,item:province};result=await service.fetchMatchDetailRecord('9','1','11');assert.equal(result.item.positive[0].evidence[0],'所在省份:广东省');checks++;
console.log('PASS: '+checks+' match preview UTS checks');})().catch(e=>{console.error(e);process.exitCode=1;});
+6
View File
@@ -87,7 +87,13 @@ test('account login restores match context using the shared route module', () =>
assert.equal(restoredPath, '/packages/finance/product/product?assessmentId=42&productIds=3,7&revisionId=9&productId=7&tab=details&from=compare');
});
test('account login restores the all-products preview group', () => {
onLoad({ resumeMatch: '1', matchPage: 'matches', assessmentId: '42', productIds: '', revisionId: '9', group: 'all' });
assert.equal(restoredPath, '/packages/finance/matches/matches?assessmentId=42&productIds=&revisionId=9&group=all');
});
const cases = [
['all matches preview with revision', ['matches', '42', '', '9', '', 'all'], '/packages/finance/matches/matches?assessmentId=42&productIds=&revisionId=9&group=all'],
['matches with selected products and revision', ['matches', '42', '3,7', '9'], '/packages/finance/matches/matches?assessmentId=42&productIds=3,7&revisionId=9&group=eligible'],
['matches without a selection', ['matches', '42', '', '', '', 'unknown'], '/packages/finance/matches/matches?assessmentId=42&productIds=&group=unknown'],
['compare selection order', ['compare', '42', '7,3,8', '9'], '/packages/finance/compare/compare?assessmentId=42&productIds=7,3,8&revisionId=9'],
+10
View File
@@ -0,0 +1,10 @@
const fs=require('node:fs'),vm=require('node:vm'),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 out={};vm.runInNewContext(ts.transpileModule(fs.readFileSync(__dirname+'/../services/cities.uts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,{exports:out});
const {provinceOptions,provinceCodeForName,provinceForCity}=out;let checks=0;
const eq=(a,b,label)=>{assert.deepEqual(JSON.parse(JSON.stringify(a)),b,label);checks++};
eq(provinceOptions.length,34,'34 province-level choices');eq(new Set(provinceOptions.map(p=>p.code)).size,34,'codes unique');
for(const[name,code]of[['广东省','440000'],['江苏省','320000'],['北京市','110000'],['重庆市','500000'],['台湾省','710000'],['香港特别行政区','810000'],['澳门特别行政区','820000'],['',null],['深圳市',null],['未知省',null]])eq(provinceCodeForName(name),code,name);
for(const[name,province]of[['深圳市','广东省'],['南京市','江苏省'],['重庆市','重庆市'],['仙桃市','湖北省'],['白杨市','新疆维吾尔自治区'],['台北市','台湾省'],['广东省','广东省'],['旧手填地区','']])eq(provinceForCity(name),province,'restore '+name);
for(const p of provinceOptions)eq(provinceCodeForName(p.name),p.code,'province name/code exact');
console.log(`PASS: ${checks} province option UTS checks`);
+1 -1
View File
@@ -30,7 +30,7 @@ vm.runInNewContext(fs.readFileSync(built, 'utf8'), {
if (module.endsWith('/services/assessment.js')) return { draftText: (item, key) => typeof item[key] === 'string' ? item[key] : '' };
if (module.endsWith('/services/api.js')) return { requestJson: async () => response };
if (module.endsWith('/financing-grade.js')) return { financingGradeFrom: () => ({}) };
if (['/services/assessment-flow.js', '/services/contribution.js', '/services/money.js'].some(suffix => module.endsWith(suffix))) return {};
if (['/services/assessment-flow.js', '/services/contribution.js', '/services/money.js', '/services/cities.js'].some(suffix => module.endsWith(suffix))) return {};
throw Error('Unexpected dependency: ' + module);
}
}, { filename: built });
+211
View File
@@ -0,0 +1,211 @@
// Execute the actual UTS services and page scripts in memory. No app build, 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');
class Json {
constructor(values = {}) { Object.assign(this, values); }
getString(k) { return typeof this[k] === 'string' ? this[k] : null; }
getNumber(k) { return typeof this[k] === 'number' ? this[k] : null; }
getBoolean(k) { return typeof this[k] === 'boolean' ? this[k] : null; }
getArray(k) { return Array.isArray(this[k]) ? this[k] : null; }
}
const root = path.join(__dirname, '..');
const modules = {};
const calls = [];
const activePages = [];
let response = new Json({ assessmentId: '1', revisionId: '2', submissionStatus: 'accepted' });
const session = new Json({ token: 'synthetic-zero-balance-owner' });
const api = {
getSession: () => session,
requestJson: async (...args) => { calls.push(args); return response; }
};
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(d => d.category === ts.DiagnosticCategory.Error).length, 0, 'UTS transpiles: ' + filename);
vm.runInNewContext(compiled.outputText, {
exports, UTSJSONObject: Json, Error, Number, Date, Math, Promise,
require: p => p.endsWith('.uvue') ? {} : p === '@/services/api.uts' || p === './api.uts' ? api :
p === '@/services/layout.uts' ? { usePageLayout: () => ({ pageStyle: () => '', contentStyle: () => '', bottomStyle: () => '' }) } :
p === '@/services/documents.uts' ? { fetchOwnedDocuments: async () => [], releaseDocumentTemp: () => {} } :
load(p.startsWith('@/') ? p.slice(2) : path.posix.normalize(path.posix.dirname(filename) + '/' + p)),
...extras
}, { filename });
return exports;
}
function load(filename) {
if (modules[filename]) return modules[filename];
const result = compile(fs.readFileSync(path.join(root, filename), 'utf8'), filename);
modules[filename] = result;
return result;
}
const service = load('services/assessment.uts');
const records = load('packages/finance/services/records.uts');
const flow = load('services/assessment-flow.uts');
function form(overrides = {}) {
return Object.assign(service.emptyAssessmentForm(), {
name: '合成用户', idCard: '110101199001010000', phone: '19900000009', city: '广东省', maritalStatus: '未婚',
occupation: '企业职员', company: '合成单位', monthlyIncome: '1', socialSecurity: '未缴纳', housingFund: '未缴纳',
house: '无', car: '无', creditLoanCount: '0', creditCardCount: '0', loanCountScope: 'all_loans'
}, overrides);
}
function save(value) { service.setAssessmentInput('personal', 2, value, [], [], false, false); }
function snapshot(value) { return JSON.stringify(value); }
function detail(formValues) {
return new Json({ assessmentId: '1', revisionId: '2', revisionNo: 1, type: 'personal', status: 'succeeded', moneyUnit: 'yuan', form: new Json(formValues) });
}
function page(kind) {
const filename = `packages/finance/${kind}/${kind}.uvue`;
const hooks = {};
const events = { toasts: [], navigation: [] };
const exposure = kind === 'assessment' ? '{form,step,restore,next,validateStep,confirmLoanCountScope:typeof confirmLoanCountScope === "undefined" ? null : confirmLoanCountScope}' : '{payload,sections,submit,loadInput}';
const source = fs.readFileSync(path.join(root, filename), 'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
const result = compile(source + '\nexports.page=' + exposure, filename, {
ref: value => ({ value }), computed: fn => ({ get value() { return fn(); } }),
onLoad: fn => { hooks.load = fn; }, onShow: fn => { hooks.show = fn; },
onHide: fn => { hooks.hide = fn; }, onUnload: fn => { hooks.unload = fn; }, onBackPress: () => {},
getCurrentPages: () => [], setInterval: () => 1, clearInterval: () => {},
uni: { showToast: value => events.toasts.push(value.title), navigateTo: value => events.navigation.push(value.url),
redirectTo: value => events.navigation.push(value.url), reLaunch: value => events.navigation.push(value.url) }
});
if (kind === 'assessment') { hooks.load({}); hooks.show(); }
else hooks.show();
activePages.push(hooks);
return { ...result.page, events };
}
let checks = 0, failures = 0;
async function test(name, callback) {
activePages.splice(0).forEach(hooks => { hooks.hide?.(); });
flow.clearAssessmentFlow(); calls.length = 0;
try { await callback(); checks++; console.log('PASS ' + name); }
catch (error) { failures++; console.error('FAIL ' + name + '\n' + error.stack); }
}
(async () => {
await test('zero counts make only empty related amounts not applicable without changing input', () => {
const value = form(); const before = snapshot(value); save(value);
const progress = service.assessmentProgress('personal');
assert.equal(progress.assetsDone, true, 'empty balances for explicitly absent loans/cards are complete');
assert.equal(progress.percent, 100);
assert.equal(snapshot(value), before);
for (const key of ['totalDebtBalance', 'monthlyLoanPayment', 'creditCardTotalLimit', 'creditCardUsedLimit'])
assert.equal(service.getAssessmentInput('personal')[key], '');
});
await test('missing counts and positive counts never infer zero balances', () => {
for (const value of [form({ creditLoanCount: '', creditCardCount: '' }), form({ creditLoanCount: '1', creditCardCount: '1' })]) {
save(value); const progress = service.assessmentProgress('personal');
assert.equal(progress.assetsDone, false); assert.ok(progress.percent < 100);
}
});
await test('all accepted zero-count spellings share the same non-applicable behavior', () => {
for (const count of ['0', '00', '000', '0000']) {
const value = form({ creditLoanCount: count, creditCardCount: count }); save(value);
assert.equal(service.assessmentProgress('personal').assetsDone, true);
assert.equal(service.assessmentProgress('personal').percent, 100);
assert.equal(service.getAssessmentInput('personal').creditLoanCount, count);
}
});
await test('legacy credit-loan zero never receives the new loan scope during restoration', () => {
const saved = new Json({ ...form(), loanCountScope: '' });
const restored = service.formFromInput(saved); save(restored);
assert.equal(restored.creditLoanCount, '0'); assert.equal(restored.loanCountScope, '');
assert.equal(service.assessmentProgress('personal').assetsDone, false);
assert.ok(service.assetValidationMessage(restored).includes('重新确认'));
});
await test('zero count and positive related amounts are rejected without overwriting', () => {
for (const [key, expected] of [['totalDebtBalance', '贷款笔数为 0 时,欠款总余额和贷款月还款金额不能大于 0'],
['monthlyLoanPayment', '贷款笔数为 0 时,欠款总余额和贷款月还款金额不能大于 0'],
['creditCardTotalLimit', '信用卡张数为 0 时,总额度和已使用总额度不能大于 0'],
['creditCardUsedLimit', '信用卡张数为 0 时,总额度和已使用总额度不能大于 0']]) {
const value = form({ [key]: '0.000001' }); const before = snapshot(value);
assert.equal(service.assetValidationMessage(value), expected); save(value);
assert.equal(service.assessmentProgress('personal').assetsDone, false);
assert.equal(snapshot(value), before); assert.equal(service.getAssessmentInput('personal')[key], '0.000001');
}
});
await test('invalid amounts cannot grant a zero-count exemption to sibling empty amounts', () => {
for (const key of ['totalDebtBalance', 'monthlyLoanPayment', 'creditCardTotalLimit', 'creditCardUsedLimit']) {
const value = form({ [key]: 'bad' }); save(value);
assert.ok(service.assetValidationMessage(value).length > 0);
assert.equal(service.assessmentProgress('personal').assetsDone, false);
assert.equal(service.getAssessmentInput('personal')[key], 'bad');
}
});
await test('entered zero money stays entered and legacy loan scope is not inferred from zero values', () => {
const value = form({ totalDebtBalance: '0.000000', monthlyLoanPayment: '0.00', creditCardTotalLimit: '0', creditCardUsedLimit: '0.000' });
const before = snapshot(value); save(value); assert.equal(service.assessmentProgress('personal').assetsDone, true);
assert.equal(service.assetValidationMessage(value), ''); assert.equal(snapshot(value), before);
const old = service.formFromInput(new Json({ ...value, loanCountScope: '' }));
assert.equal(old.loanCountScope, ''); assert.ok(service.assetValidationMessage(old).length > 0);
});
await test('new submission carries all-loan scope while empty monetary fields remain null', async () => {
const value = new Json({ ...form(), type: 'personal', documents: [], authorizations: [] });
const before = snapshot(value); await records.createAssessment(value);
const body = calls.at(-1)[2]; assert.equal(body.form.loanCountScope, 'all_loans');
assert.equal(body.form.creditLoanCount, 0); assert.equal(body.form.creditCardCount, 0);
for (const key of ['totalDebtBalance', 'monthlyLoanPayment', 'creditCardTotalLimit', 'creditCardUsedLimit']) assert.equal(body.form[key], null);
assert.equal(snapshot(value), before);
});
await test('serialization preserves entered amounts and never fabricates scope for legacy callers', async () => {
const value = new Json({ ...form({ loanCountScope: '', creditLoanCount: '2', creditCardCount: '1', totalDebtBalance: '1.234567', monthlyLoanPayment: '0', creditCardTotalLimit: '2', creditCardUsedLimit: '0.123456' }), type: 'personal' });
const before = snapshot(value); await records.createAssessment(value);
const body = calls.at(-1)[2]; assert.equal(body.form.loanCountScope, undefined);
assert.equal(body.form.totalDebtBalance, '12345.67'); assert.equal(body.form.monthlyLoanPayment, '0.00');
assert.equal(body.form.creditCardTotalLimit, '20000.00'); assert.equal(body.form.creditCardUsedLimit, '1234.56');
assert.equal(snapshot(value), before);
});
await test('edit restoration preserves old and new scope and every existing amount', async () => {
for (const scope of [undefined, 'all_loans']) {
response = detail({ creditLoanCount: 0, loanCountScope: scope, creditCardCount: 0, totalDebtBalance: '12000.00', monthlyLoanPayment: null, creditCardTotalLimit: '0.00', creditCardUsedLimit: null });
const restored = await records.fetchAssessmentEdit('1');
assert.equal(restored.loanCountScope || '', scope || ''); assert.equal(restored.creditLoanCount, '0');
assert.equal(restored.totalDebtBalance, '1.2'); assert.equal(restored.monthlyLoanPayment, '');
assert.equal(restored.creditCardTotalLimit, '0'); assert.equal(restored.creditCardUsedLimit, '');
}
});
await test('archive labels retain historical credit-loan meaning and name new scope correctly', async () => {
for (const [scope, label] of [[undefined, '信用贷款笔数(旧口径)'], ['all_loans', '贷款笔数']]) {
response = detail({ creditLoanCount: 0, loanCountScope: scope });
const archived = await records.fetchAssessmentDetail('1', '2');
assert.ok(archived.fields.some(item => item.label === label && item.value === '0'));
}
});
await test('historical count requires an explicit page confirmation without clearing any values', () => {
const current = page('assessment');
const old = form({ loanCountScope: '', totalDebtBalance: '1.2', monthlyLoanPayment: '0.03' });
save(old); current.restore('personal'); current.step.value = 2;
const before = snapshot(current.form.value); current.next();
assert.equal(current.step.value, 2); assert.ok(current.events.toasts.at(-1).includes('重新确认'));
assert.equal(typeof current.confirmLoanCountScope, 'function'); current.confirmLoanCountScope();
const after = JSON.parse(snapshot(current.form.value)); after.loanCountScope = '';
assert.equal(JSON.stringify(after), before); assert.equal(current.form.value.loanCountScope, 'all_loans');
current.next(); assert.equal(current.step.value, 2, 'positive money conflict remains after confirming zero loans');
});
await test('page confirmation enables blank balances for old zero counts and remembers scope', () => {
const current = page('assessment'); save(form({ loanCountScope: '' })); current.restore('personal'); current.step.value = 2;
current.next(); assert.equal(current.step.value, 2);
current.confirmLoanCountScope(); current.next(); assert.equal(current.step.value, 3);
assert.equal(service.getAssessmentInput('personal').loanCountScope, 'all_loans');
assert.equal(service.getAssessmentInput('personal').totalDebtBalance, '');
});
await test('confirmation page refuses legacy scope and displays submitted scope without reinterpreting', async () => {
save(form({ loanCountScope: '' })); let current = page('confirm');
const before = calls.length; await current.submit(); assert.equal(calls.length, before);
assert.ok(current.events.toasts.at(-1).includes('重新确认'));
assert.equal(service.getAssessmentInput('personal').step, 2);
save(form()); current = page('confirm');
assert.ok(current.sections.value[2].rows.some(row => row.label === '贷款笔数'));
assert.ok(current.sections.value[2].rows.some(row => row.value.includes('房贷') && row.value.includes('车贷')));
});
await test('confirmation distinguishes exempt empty amounts from entered zero amounts', () => {
save(form()); let current = page('confirm'); let rows = current.sections.value[2].rows;
assert.equal(rows.find(row => row.label === '欠款总余额').value, '不适用(无贷款)');
assert.equal(rows.find(row => row.label === '总额度').value, '不适用(无信用卡)');
save(form({ totalDebtBalance: '0.000000', creditCardTotalLimit: '0.00' })); current = page('confirm'); rows = current.sections.value[2].rows;
assert.equal(rows.find(row => row.label === '欠款总余额').value, '0.000000 万元');
assert.equal(rows.find(row => row.label === '总额度').value, '0.00 万元');
});
console.log((failures ? 'FAIL' : 'PASS') + ': ' + checks + ' passed, ' + failures + ' failed zero-balance UTS/page scenarios; modelCalls=0 businessWrites=0 networkCalls=0');
if (failures) process.exitCode = 1;
})().catch(error => { console.error(error.stack); process.exitCode = 1; });