fix: 修复匹配详情提示及公积金可选路径问题
修复匹配详情中“用户自填未经核验”等来源说明,清理未参与项的橙色注意提示,修正贷款统计口径文案;补充公积金完整可选路径提取逻辑,确保明确无额外时长要求的公积金分支可正确识别并通过。
This commit is contained in:
@@ -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'))
|
||||
@@ -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});
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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});
|
||||
@@ -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; });
|
||||
@@ -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;});
|
||||
@@ -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'],
|
||||
|
||||
@@ -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`);
|
||||
@@ -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 });
|
||||
|
||||
@@ -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; });
|
||||
Reference in New Issue
Block a user