verify-company-classify-sync.mjs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. /**
  2. * 验证企业分类同步的**纯逻辑**(不碰网络、不碰 DMS)。
  3. *
  4. * 验证什么:
  5. * ① 身份抽取的取值顺序与容错(profile.data 优先、回退 company、非字符串一律不算)
  6. * ② 分类响应 → 1888 期望行(失败角度不写、保守模式只写非空角度)
  7. * ③ 1888 diff:无变化 → null(零写入)、`"[]"` 与 `[]` 不产生假差异、期望为空不清存量
  8. * ④ 1886 荣誉对齐:解析 `honors.records`、按「荣誉名+级别+来源」全量对齐
  9. * (补新增 / 更新差异 / 删多余)、`complete !== true` 时**不许删**、
  10. * 荣誉名回退顺序(c_honor → c_tag_name → title,字符串 "null" 不算名字)——
  11. * c_honor 是 2026-09-18 晚用户新建的**荣誉名称**列;c_tag_name 只作读取兼容
  12. * ⑤ partial / errors 不可解析时的降级语义
  13. * ⑥ 分类接口地址拼接(VITE_CHAT_API 的两种写法)
  14. *
  15. * 怎么跑(先按 _entry-company-classify.ts 头部注释重新打包):
  16. * node harness/tools/verify-company-classify-sync.mjs
  17. */
  18. import {
  19. ANGLE_TO_FIELD,
  20. HONOR_NAME_FIELD,
  21. PROFILE_FIELD_MAP,
  22. buildEnterpriseRow,
  23. buildHonorKey,
  24. buildHonorRowContent,
  25. buildProfileFields,
  26. decodeAngleTags,
  27. diffEnterpriseRow,
  28. diffHonorRows,
  29. encodeAngleTags,
  30. extractFailedAngles,
  31. extractIdentityFromCompanyInfo,
  32. isCompanyInfoUsable,
  33. normalizeTagSet,
  34. parseHonorRecords,
  35. readHonorsFromCompanyInfo,
  36. readProfileDataFromCompanyInfo,
  37. resolveClassifyEndpoint,
  38. resolveSyncedIdentity,
  39. sameTagSet,
  40. stableJson,
  41. } from './_company-classify.mjs';
  42. let pass = 0;
  43. let fail = 0;
  44. const check = (name, cond, detail) => {
  45. if (cond) {
  46. pass += 1;
  47. console.log(` ok ${name}`);
  48. } else {
  49. fail += 1;
  50. console.log(` FAIL ${name}${detail === undefined ? '' : ` → ${JSON.stringify(detail)}`}`);
  51. }
  52. };
  53. /* ---------------------------------------------------------------- *
  54. * ① 身份抽取
  55. * ---------------------------------------------------------------- */
  56. console.log('\n【1】身份抽取');
  57. {
  58. const full = {
  59. company: { KeyNo: 'k1', Name: '公司名A', CreditCode: 'CODE-A', OperName: '法人A' },
  60. profile: { data: { KeyNo: 'k1', Name: '公司名B', CreditCode: 'CODE-B', OperName: '法人B' } },
  61. };
  62. const id = extractIdentityFromCompanyInfo(full);
  63. check('profile.data 优先于 company', id.name === '公司名B' && id.creditCode === 'CODE-B' && id.legalRep === '法人B', id);
  64. const companyOnly = { company: { Name: '只有 company', CreditCode: 'CODE-C' } };
  65. const id2 = extractIdentityFromCompanyInfo(companyOnly);
  66. check('profile 缺失时回退 company(逐项回退)', id2.name === '只有 company' && id2.creditCode === 'CODE-C' && id2.legalRep === null, id2);
  67. const partial = { company: { Name: 'A' }, profile: { data: { CreditCode: 'ONLY-CC' } } };
  68. const id3 = extractIdentityFromCompanyInfo(partial);
  69. check('profile 有但缺字段 → 该项回退 company', id3.name === 'A' && id3.creditCode === 'ONLY-CC', id3);
  70. const dirty = { company: { Name: 123, CreditCode: ' CODE-D ', OperName: '' } };
  71. const id4 = extractIdentityFromCompanyInfo(dirty);
  72. check('非字符串/空串不算,字符串去首尾空白', id4.name === null && id4.creditCode === 'CODE-D' && id4.legalRep === null, id4);
  73. check('全缺 → null', extractIdentityFromCompanyInfo({ company: {}, profile: { data: {} } }) === null);
  74. check('null/undefined/{} → null', extractIdentityFromCompanyInfo(null) === null && extractIdentityFromCompanyInfo(undefined) === null && extractIdentityFromCompanyInfo({}) === null);
  75. check('数组/字符串 → null', extractIdentityFromCompanyInfo([]) === null && extractIdentityFromCompanyInfo('x') === null);
  76. check('isCompanyInfoUsable 与之一致', isCompanyInfoUsable({ company: { Name: 'X' } }) === true && isCompanyInfoUsable({}) === false);
  77. check('readHonorsFromCompanyInfo 取 honors 节点', readHonorsFromCompanyInfo({ honors: { records: [] } }) !== null && readHonorsFromCompanyInfo({}) === null);
  78. }
  79. /* ---------------------------------------------------------------- *
  80. * ② 标签编解码
  81. * ---------------------------------------------------------------- */
  82. console.log('\n【2】标签编解码');
  83. {
  84. check('encode 数组 → JSON 字符串', encodeAngleTags(['第一产业', '现代农业']) === '["第一产业","现代农业"]');
  85. check('encode 空数组 → "[]"', encodeAngleTags([]) === '[]');
  86. check('encode null → "[]"', encodeAngleTags(null) === '[]');
  87. check('encode undefined → undefined(表示本轮不写)', encodeAngleTags(undefined) === undefined);
  88. check('normalize 去空、去重、trim、保序', JSON.stringify(normalizeTagSet([' a ', '', 'b', 'a', null, 3])) === '["a","b"]');
  89. check('decode 往返', JSON.stringify(decodeAngleTags(encodeAngleTags(['x', 'y']))) === '["x","y"]');
  90. check('decode 非法/非字符串 → []', decodeAngleTags('not json').length === 0 && decodeAngleTags(null).length === 0 && decodeAngleTags('{"a":1}').length === 0);
  91. check('sameTagSet 与顺序无关', sameTagSet(['a', 'b'], ['b', 'a']) === true && sameTagSet(['a'], ['a', 'b']) === false);
  92. }
  93. /* ---------------------------------------------------------------- *
  94. * ③ 期望行 + diff
  95. * ---------------------------------------------------------------- */
  96. console.log('\n【3】1888 期望行与 diff');
  97. {
  98. const classification = {
  99. company_name: '合成公司',
  100. credit_code: 'CODE-X',
  101. legal_representative: '合成法人',
  102. categories: {
  103. 基本信息: ['小微企业'],
  104. 资质荣誉: ['高新技术企业'],
  105. 产业信息: [],
  106. 经营活动: [],
  107. 行业信息: ['涉农(广义)'],
  108. 许可认证: [],
  109. },
  110. status: 'completed',
  111. };
  112. const row = buildEnterpriseRow(classification, { failed: new Set(), conservative: false });
  113. check('身份三字段落位', row.c_name === '合成公司' && row.c_credit_code === 'CODE-X' && row.c_oper_name === '合成法人', row);
  114. check('六角度各自落位(含空数组 → "[]")', row[ANGLE_TO_FIELD['基本信息']] === '["小微企业"]' && row[ANGLE_TO_FIELD['产业信息']] === '[]' && row[ANGLE_TO_FIELD['许可认证']] === '[]', row);
  115. check('行业信息用 c_tag_industry(不占用既有的 c_industry)', row[ANGLE_TO_FIELD['行业信息']] === '["涉农(广义)"]' && row.c_industry === undefined);
  116. const failedRow = buildEnterpriseRow(classification, { failed: new Set(['产业信息']), conservative: false });
  117. check('失败角度不写该字段', failedRow[ANGLE_TO_FIELD['产业信息']] === undefined && failedRow[ANGLE_TO_FIELD['基本信息']] === '["小微企业"]');
  118. const conservativeRow = buildEnterpriseRow(classification, { failed: new Set(), conservative: true });
  119. check('保守模式只写非空角度', conservativeRow[ANGLE_TO_FIELD['行业信息']] === '["涉农(广义)"]' && conservativeRow[ANGLE_TO_FIELD['产业信息']] === undefined);
  120. const noIdentity = buildEnterpriseRow({ categories: {} }, { failed: new Set(), conservative: false });
  121. check('身份缺失时不写身份字段(而不是写 null)', !('c_name' in noIdentity) && !('c_credit_code' in noIdentity) && !('c_oper_name' in noIdentity), noIdentity);
  122. const hintRow = buildEnterpriseRow({ categories: {} }, { failed: new Set(), conservative: false, identityHint: { name: '回退名', creditCode: 'FALLBACK', legalRep: null } });
  123. check('响应缺身份时回退 identityHint', hintRow.c_name === '回退名' && hintRow.c_credit_code === 'FALLBACK', hintRow);
  124. const synced = resolveSyncedIdentity({ company_name: null, credit_code: 'C1' }, { name: 'N1', creditCode: 'C0', legalRep: 'L1' });
  125. check('resolveSyncedIdentity 逐项回退', synced.name === 'N1' && synced.creditCode === 'C1' && synced.legalRep === 'L1', synced);
  126. // diff
  127. const existing = { id: 'uuid-1', c_name: '合成公司', c_credit_code: 'CODE-X', [ANGLE_TO_FIELD['基本信息']]: '["小微企业"]' };
  128. const same = diffEnterpriseRow(existing, { c_name: '合成公司', c_credit_code: 'CODE-X', [ANGLE_TO_FIELD['基本信息']]: '["小微企业"]' });
  129. check('全同 → null(零写入)', same === null, same);
  130. const reordered = diffEnterpriseRow(existing, { [ANGLE_TO_FIELD['基本信息']]: '["小微企业"]' });
  131. check('集合比较:内容相同不算变化', reordered === null, reordered);
  132. const changed = diffEnterpriseRow(existing, { c_name: '改名了', [ANGLE_TO_FIELD['基本信息']]: '["小微企业","专精特新"]' });
  133. check('变化字段进 patch,未变字段不进', changed.c_name === '改名了' && changed[ANGLE_TO_FIELD['基本信息']] === '["小微企业","专精特新"]' && !('c_credit_code' in changed), changed);
  134. const desiredEmptyTags = diffEnterpriseRow({ [ANGLE_TO_FIELD['基本信息']]: '["旧标签"]' }, { [ANGLE_TO_FIELD['基本信息']]: '[]' });
  135. check('空集是合法目标值(会覆盖旧标签)', desiredEmptyTags !== null && desiredEmptyTags[ANGLE_TO_FIELD['基本信息']] === '[]', desiredEmptyTags);
  136. const noOverwrite = diffEnterpriseRow({ c_name: '存量名' }, {});
  137. check('期望里没有的字段不参与比较、不清存量', noOverwrite === null, noOverwrite);
  138. const fillGap = diffEnterpriseRow({ c_name: '存量名' }, { c_oper_name: '新法人' });
  139. check('存量缺失 + 期望有 → 补齐', fillGap !== null && fillGap.c_oper_name === '新法人', fillGap);
  140. }
  141. /* ---------------------------------------------------------------- *
  142. * ③b 1888 工商信息(数据源 profile.data)
  143. * ---------------------------------------------------------------- */
  144. console.log('\n【3b】工商信息字段映射与比较');
  145. {
  146. const profileData = {
  147. Name: '合成公司',
  148. CreditCode: 'CODE-X',
  149. OperName: '合成法人',
  150. No: '310118000000001',
  151. BelongOrg: '青浦区市场监督管理局',
  152. Status: '存续(在营、开业、在册)',
  153. RegistCapi: '1000万元',
  154. RegisteredCapital: '1000',
  155. IsOnStock: '0',
  156. Scope: '一般项目:技术服务…',
  157. // 以下应当被跳过(空值不写)
  158. EndDate: '',
  159. RevokeInfo: null,
  160. DesignatedRepresentativeList: [],
  161. StockNumber: undefined,
  162. // 数组/对象 → JSON 字符串
  163. OriginalName: [{ Name: '曾用名', ChangeDate: '2024-07-22' }],
  164. Area: { Province: '上海市', City: '上海市', County: '青浦区' },
  165. };
  166. const row = buildProfileFields(profileData);
  167. check('标量字段按映射落位', row.c_name === '合成公司' && row.c_status === '存续(在营、开业、在册)' && row.c_regist_capi === '1000万元' && row.c_no === '310118000000001', row);
  168. check('数组/对象 → JSON 字符串', row.c_original_name === '[{"Name":"曾用名","ChangeDate":"2024-07-22"}]' && JSON.parse(row.c_area).County === '青浦区', row);
  169. check('空字符串/null/undefined/空数组 **不写**(不清存量)', !('c_end_date' in row) && !('c_revoke_info' in row) && !('c_designated_representative_list' in row) && !('c_stock_number' in row), row);
  170. check('未在映射里的字段被忽略', !('c_industry' in row) && !('KeyNo' in row));
  171. check('字段数量 = 映射里有值的那些', Object.keys(row).length === 12, Object.keys(row).length);
  172. check('readProfileDataFromCompanyInfo 取 profile.data', readProfileDataFromCompanyInfo({ profile: { data: { Name: 'X' } } })?.Name === 'X' && readProfileDataFromCompanyInfo({}) === null);
  173. // ⚠️ 关键:DMS 里灌入的 JSON 是 Python/jsonb 序列化的(带空格、键按字母序),
  174. // 与 JS 的 JSON.stringify 输出不同 —— 不能因此判成「有差异」而反复写
  175. const seededArea = '{"City": "上海市", "County": "青浦区", "Province": "上海市"}';
  176. const jsArea = JSON.stringify({ Province: '上海市', City: '上海市', County: '青浦区' });
  177. check('与灌入值**语义相同** → 不写(键序/空白不造成 churn)', diffEnterpriseRow({ c_area: seededArea }, { c_area: jsArea }) === null);
  178. check('顶层键顺序不同也不写', diffEnterpriseRow({ c_area: '{"Province":"上海市","City":"上海市","County":"青浦区"}' }, { c_area: jsArea }) === null);
  179. const seededArr = '[{"Name": "旧名", "ChangeDate": "2020-01-01"}]';
  180. check('数组内容真的变了 → 写', diffEnterpriseRow({ c_original_name: seededArr }, { c_original_name: row.c_original_name }) !== null);
  181. check('数组内容相同(仅格式不同)→ 不写', diffEnterpriseRow({ c_original_name: '[{"Name": "曾用名", "ChangeDate": "2024-07-22"}]' }, { c_original_name: row.c_original_name }) === null);
  182. check('存量是空串、本次有值 → 写', diffEnterpriseRow({ c_revoke_info: '' }, { c_revoke_info: '{"A":1}' })?.c_revoke_info === '{"A":1}');
  183. check('普通文本列语义同前(不同才写)', diffEnterpriseRow({ c_status: '存续' }, { c_status: '存续(在营、开业、在册)' })?.c_status === '存续(在营、开业、在册)' && diffEnterpriseRow({ c_status: '存续' }, { c_status: '存续' }) === null);
  184. check('stableJson 递归排序键', stableJson({ b: 1, a: [{ d: 2, c: 3 }] }) === '{"a":[{"c":3,"d":2}],"b":1}');
  185. }
  186. /* ---------------------------------------------------------------- *
  187. * ④ 1886 荣誉对齐(数据源 honors.records)
  188. * ---------------------------------------------------------------- */
  189. console.log('\n【4】1886 荣誉对齐');
  190. {
  191. // —— 解析 ——
  192. const honors = {
  193. complete: true,
  194. records: [
  195. { data: { Name: '高新技术企业', Level: '国家级', Source: '某认定公告', PublishOffice: '科技部', PublishDate: '2025-01-02', CertificateCode: 'CERT-1' } },
  196. { data: { Name: '独角兽企业', Level: '省级', Source: '2026 浙江独角兽', PublishOffice: '', PublishDate: '2026-04-23' } },
  197. { data: { Level: '国家级' } }, // 缺 Name → 丢弃
  198. { notData: true }, // 缺 data → 丢弃
  199. ],
  200. };
  201. const parsed = parseHonorRecords(honors);
  202. check('解析出 2 条(缺 Name / 缺 data 的各丢一条)', parsed.records.length === 2 && parsed.malformed === 2, parsed);
  203. check('complete 透传', parsed.complete === true);
  204. check('字段映射正确', parsed.records[0].name === '高新技术企业' && parsed.records[0].level === '国家级' && parsed.records[0].certificateCode === 'CERT-1' && parsed.records[1].publishOffice === null, parsed.records);
  205. check('honors 缺失 → available=false(调用方整块跳过)', parseHonorRecords(null).available === false && parseHonorRecords(undefined).available === false);
  206. check('honors={} → available=true 但 0 条、complete=false', (() => { const p = parseHonorRecords({}); return p.available === true && p.records.length === 0 && p.complete === false; })());
  207. check('complete:false 透传为 false(不许删)', parseHonorRecords({ complete: false, records: [] }).complete === false);
  208. // —— 键 ——
  209. check('buildHonorKey 三字段拼接', buildHonorKey('A', '国家级', 'X') === 'A|国家级|X');
  210. check('buildHonorKey 缺字段留空', buildHonorKey('A', null, undefined) === 'A||');
  211. const records = parsed.records; // 高新技术企业 | 独角兽企业
  212. // —— 全量对齐 ——
  213. /** 灌入行:荣誉名只在系统字段 `title` 里(DMS 侧 09-17 灌的那批就是这样,没有 c_tag_name) */
  214. const seededRow = (id, title, level, source, extra = {}) => ({ id, title, c_level: level, c_source: source, ...extra });
  215. /** 已对齐过的行:title 与 c_tag_name 都有 */
  216. const alignedRow = (id, title, level, source, extra = {}) =>
  217. seededRow(id, title, level, source, { [HONOR_NAME_FIELD]: title, ...extra });
  218. const rows = [
  219. alignedRow('u1', '高新技术企业', '国家级', '某认定公告', { c_publish_office: '科技部', c_publish_date: '2025-01-02', c_certificate_code: 'CERT-1' }),
  220. alignedRow('u2', '独角兽企业', '省级', '2026 浙江独角兽', { c_publish_date: '2026-04-23' }),
  221. seededRow('u3', '过期的荣誉', '市级', '老名单 2019'),
  222. ];
  223. const aligned = diffHonorRows(rows, records, { allowDelete: true });
  224. check('已对齐的行不产生增/改(幂等)', aligned.toAdd.length === 0 && aligned.toUpdate.length === 0, { add: aligned.toAdd.length, upd: aligned.toUpdate.length, patch: aligned.toUpdate[0]?.patch });
  225. check('库里多出来的 → toDelete(带 id 与键)', aligned.toDelete.length === 1 && aligned.toDelete[0].id === 'u3', aligned.toDelete);
  226. check('无 malformed', aligned.malformed === 0, aligned.malformed);
  227. const noDelete = diffHonorRows(rows, records, { allowDelete: false });
  228. check('**complete=false 时绝不删**', noDelete.toDelete.length === 0 && noDelete.toAdd.length === 0, noDelete);
  229. // 灌入行(只有 title、没有 c_tag_name)→ 首次对齐只补那一列
  230. const seedOnly = diffHonorRows(
  231. [seededRow('u1', '高新技术企业', '国家级', '某认定公告', { c_publish_office: '科技部', c_publish_date: '2025-01-02', c_certificate_code: 'CERT-1' })],
  232. [records[0]],
  233. { allowDelete: true }
  234. );
  235. check('灌入行首次对齐:只补 c_honor,不动其它列', seedOnly.toUpdate.length === 1 && JSON.stringify(Object.keys(seedOnly.toUpdate[0].patch)) === JSON.stringify([HONOR_NAME_FIELD]), seedOnly.toUpdate[0]?.patch);
  236. // 差异 → update(只 patch 有差异的列)
  237. const staleRow = [seededRow('u1', '高新技术企业', '国家级', '某认定公告', { c_publish_office: '旧机构' })];
  238. const upd = diffHonorRows(staleRow, [records[0]], { allowDelete: true });
  239. check('同键字段有差异 → toUpdate,只含差异列', upd.toUpdate.length === 1 && upd.toUpdate[0].patch.c_publish_office === '科技部' && upd.toUpdate[0].patch.title === undefined, upd.toUpdate);
  240. check('title 与荣誉名一致时不重复 patch', upd.toUpdate[0].patch.title === undefined);
  241. check('已匹配的行若缺 c_honor 也会补上', upd.toUpdate[0].patch[HONOR_NAME_FIELD] === '高新技术企业', upd.toUpdate[0].patch);
  242. const titleNull = [seededRow('u9', 'null', '国家级', '某认定公告', { c_tag_name: '高新技术企业' })];
  243. const fixTitle = diffHonorRows(titleNull, [records[0]], { allowDelete: true });
  244. check('title 是字符串 "null" → 用 c_tag_name 认名字,并把 title 修正', fixTitle.toUpdate.length === 1 && fixTitle.toUpdate[0].patch.title === '高新技术企业', fixTitle);
  245. check('字符串 "null" 不被当成荣誉名(键能对上)', fixTitle.toAdd.length === 0);
  246. check('早期同步的行(只有 c_tag_name)也会补上 c_honor', fixTitle.toUpdate[0].patch[HONOR_NAME_FIELD] === '高新技术企业', fixTitle.toUpdate[0].patch);
  247. // 新增
  248. const empty = diffHonorRows([], records, { allowDelete: true });
  249. check('库里没有 → 全部 toAdd', empty.toAdd.length === 2 && empty.toDelete.length === 0, empty);
  250. // 载荷内部重复
  251. const dup = parseHonorRecords({ complete: true, records: [{ data: { Name: 'A', Level: '国家级', Source: 'X' } }, { data: { Name: 'A', Level: '国家级', Source: 'X' } }] });
  252. const dupDiff = diffHonorRows([], dup.records, { allowDelete: true });
  253. check('载荷内重复的荣誉只算一次', dupDiff.toAdd.length === 1, dupDiff.toAdd);
  254. // 缺 id 的行
  255. const noId = diffHonorRows([{ title: '高新技术企业', c_level: '国家级', c_source: '某认定公告' }], records, { allowDelete: true });
  256. check('缺 uuid 的行 → malformed,不匹配也不删', noId.malformed === 1 && noId.toAdd.length === 2 && noId.toDelete.length === 0, noId);
  257. // 内容
  258. const content = buildHonorRowContent('CODE-X', '合成公司', records[0], '2026-09-18 18:00:00');
  259. check('荣誉行内容:title 与 c_honor 都写荣誉名', content.title === '高新技术企业' && content[HONOR_NAME_FIELD] === '高新技术企业', content);
  260. check('荣誉行内容:级别/来源/机构/日期/证书编号各就各位', content.c_level === '国家级' && content.c_source === '某认定公告' && content.c_publish_office === '科技部' && content.c_publish_date === '2025-01-02' && content.c_certificate_code === 'CERT-1', content);
  261. check('荣誉行内容:必填与归属', content.c_credit_code === 'CODE-X' && content.c_name === '合成公司' && content.c_id === 0, content);
  262. check('空字段不写进行(不落空值)', !('c_dead_line' in content) && !('c_beging_date' in content), content);
  263. }
  264. /* ---------------------------------------------------------------- *
  265. * ⑤ partial / errors 语义
  266. * ---------------------------------------------------------------- */
  267. console.log('\n【5】失败角度与保守降级');
  268. {
  269. const partial = { status: 'partial', errors: { 产业信息: 'model_call_failed' }, categories: { 基本信息: ['a'] } };
  270. const r1 = extractFailedAngles(partial);
  271. check('errors 键 = 角度名 → 识别为失败角度', r1.failed.has('产业信息') && r1.unknown === false, [...r1.failed]);
  272. const weird = { status: 'partial', errors: { something_else: 'x' }, categories: {} };
  273. check('errors 键对不上角度 → unknown 置位', extractFailedAngles(weird).unknown === true);
  274. check('partial 但无 errors → unknown(不知道哪些可信)', extractFailedAngles({ status: 'partial', categories: {} }).unknown === true);
  275. check('completed + 空 errors → 不降级', extractFailedAngles({ status: 'completed', errors: {} }).unknown === false);
  276. }
  277. /* ---------------------------------------------------------------- *
  278. * ⑥ 分类接口地址
  279. * ---------------------------------------------------------------- */
  280. console.log('\n【6】分类接口地址拼接');
  281. {
  282. check('服务前缀写法', resolveClassifyEndpoint('http://192.168.2.23:8000') === 'http://192.168.2.23:8000/api/company/classify');
  283. check('dev 代理前缀写法', resolveClassifyEndpoint('/chat-api') === '/chat-api/api/company/classify');
  284. check('完整聊天接口写法(先剥 /api/chat)', resolveClassifyEndpoint('http://x:8000/api/chat') === 'http://x:8000/api/company/classify');
  285. check('末尾斜杠容错', resolveClassifyEndpoint('http://x:8000///') === 'http://x:8000/api/company/classify');
  286. check('空 → 空串(调用方会跳过)', resolveClassifyEndpoint('') === '' && resolveClassifyEndpoint(undefined) === '');
  287. }
  288. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  289. process.exit(fail ? 1 : 0);