/* 关键前端逻辑单测(node 直跑):错误翻译 / 结论分档 / 阶段命名 / 本期结论分组 */ "use strict"; const fs = require("fs"); const path = require("path"); const src = fs.readFileSync(path.join(__dirname, "..", "webui", "app.js"), "utf-8"); function extractFn(name) { const m = src.match(new RegExp(`function ${name}\\([\\s\\S]*?\\n}`)); if (!m) throw new Error(`未找到函数 ${name}`); return m[0]; } function loadFn(name) { // 在独立函数作用域内定义并返回目标函数(规避 eval 严格模式作用域问题) return new Function(`${extractFn(name)}; return ${name};`)(); } const friendlyErr = loadFn("friendlyErr"); const matchAdvice = loadFn("matchAdvice"); let pass = 0, fail = 0; function eq(actual, expected, label) { const ok = actual === expected; if (ok) pass++; else { fail++; console.error(`FAIL ${label}: 期望 ${JSON.stringify(expected)} 实际 ${JSON.stringify(actual)}`); } } function includes(hay, needle, label) { const ok = String(hay).includes(needle); if (ok) pass++; else { fail++; console.error(`FAIL ${label}: ${JSON.stringify(hay)} 不含 ${needle}`); } } /* friendlyErr:禁止出现 Failed to fetch / 内部文件名 / 接口路径 */ eq(friendlyErr(new TypeError("Failed to fetch")), "暂时无法连接服务,请检查后端是否已启动", "fetch 失败翻译"); eq(friendlyErr(new Error("NetworkError when attempting to fetch resource.")), "暂时无法连接服务,请检查后端是否已启动", "NetworkError 翻译"); eq(friendlyErr(new Error("记录不存在")), "记录不存在", "服务端中文 detail 原样保留"); eq(friendlyErr(new Error("HTTP 500")), "服务响应异常(500)", "HTTP 状态码中文化"); eq(friendlyErr(new Error("batch_2019.jsonl#92 读取失败")), "操作失败,请稍后重试", "内部文件名不外显"); eq(friendlyErr(new Error("")), "操作失败,请稍后重试", "空消息兜底"); /* matchAdvice:三档结论 */ includes(matchAdvice(85), "强烈推荐", ">=80 档"); includes(matchAdvice(72), "补齐核心缺口", "60-80 档"); includes(matchAdvice(40), "先系统学习", "<60 档"); includes(matchAdvice(80), "强烈推荐", "边界 80"); includes(matchAdvice(60), "补齐核心缺口", "边界 60"); includes(matchAdvice(59.9), "先系统学习", "边界 59.9"); /* 阶段语义化命名(与 renderPath 内逻辑一致) */ const STAGE_NAMES = ["基础补齐", "岗位核心", "项目验证"]; const stageName = (i) => STAGE_NAMES[i] || "综合提升"; eq(stageName(0), "基础补齐", "阶段1命名"); eq(stageName(1), "岗位核心", "阶段2命名"); eq(stageName(2), "项目验证", "阶段3命名"); eq(stageName(3), "综合提升", "阶段4顺延"); /* 本期结论分组逻辑(与 renderEvoSummary 一致) */ const evoEvents = [ { window: "2026Q2", change_type: "新增", skill: "Kubernetes", prev_weight: 0, curr_weight: 2.52 }, { window: "2026Q2", change_type: "增强", skill: "微服务", prev_weight: 1.3, curr_weight: 2.1 }, { window: "2026Q3", change_type: "减弱", skill: "JSP", prev_weight: 0.8, curr_weight: 0.2 }, { window: "2026Q3", change_type: "退出", skill: "Struts", prev_weight: 0.1, curr_weight: 0 }, ]; const wins = [...new Set(evoEvents.map((e) => e.window))].sort().slice(-2); eq(wins.join(","), "2026Q2,2026Q3", "最近两个季度窗口"); const verb = { "新增": "成为新要求", "增强": "需求显著增强", "减弱": "需求明显减弱", "退出": "退出能力画像" }; eq(verb[evoEvents[1].change_type], "需求显著增强", "增强动词"); const line = `${evoEvents[1].skill}(需求指数 ${evoEvents[1].prev_weight.toFixed(2)}→${evoEvents[1].curr_weight.toFixed(2)})`; eq(line, "微服务(需求指数 1.30→2.10)", "需求指数文案"); /* 热度走势默认近 8 季切片 */ const heat = Array.from({ length: 30 }, (_, i) => [`20${10 + i}Q1`, i]); eq(heat.slice(-8).length, 8, "近8季切片"); eq(heat.slice(-Infinity).length, 30, "全部范围"); /* candidateTrust:候选可信等级四指标(JD≥15 / 企业≥5 / 增长趋势 / 差异度≥0.40) */ const candidateTrust = loadFn("candidateTrust"); const stageEffortText = loadFn("stageEffortText"); const existJobs = [{ title: "Java开发工程师", required_skills: ["Java", "MySQL"], preferred_skills: ["Redis"] }]; const strong = candidateTrust({ source_count: 31, company_count: 12, support_windows: ["2025Q1", "2025Q2", "2025Q3", "2025Q4", "2026Q1", "2026Q2"], required_skills: ["RAG", "向量检索"], preferred_skills: [], }, existJobs); eq(strong.dots.length, 4, "四指标点"); eq(strong.level, "高", "四指标全达标为高"); const weak = candidateTrust({ source_count: 3, company_count: 1, support_windows: ["2026Q2"], required_skills: ["Java", "MySQL"], preferred_skills: ["Redis"], }, existJobs); eq(weak.level, "低", "弱证据且与既有岗位重合为低"); eq(weak.nearest.title, "Java开发工程师", "识别出最相近既有岗位"); eq(weak.distinctness, 0, "技能完全重合时差异度为 0"); /* stageEffortText:阶段预计投入(每周约 8 小时) */ eq(stageEffortText(2), "2 周 / 约 16 小时", "阶段投入文案"); eq(stageEffortText(1), "1 周 / 约 8 小时", "单周投入文案"); /* ganttBars:甘特图横条位置(左偏移/宽度百分比) */ const ganttBars = loadFn("ganttBars"); const gb = ganttBars([2, 2, 4]); eq(gb.length, 3, "甘特条数"); eq(gb[0].left + "," + gb[0].width, "0,25", "甘特首条位置"); eq(gb[2].left + "," + gb[2].width, "50,50", "甘特末条位置"); eq(ganttBars([4, 4])[1].left, 50, "等长两阶段第二条起点"); /* diffSkillRows:时点画像 Git 风格差集 */ const diffSkillRows = loadFn("diffSkillRows"); const d = diffSkillRows( [{ skill: "Java", importance: "required" }, { skill: "JSP", importance: "preferred" }], [{ skill: "Java", importance: "preferred" }, { skill: "Kubernetes", importance: "required" }]); eq(d.add.map((r) => r.skill).join(","), "Kubernetes", "diff 新增"); eq(d.del.map((r) => r.skill).join(","), "JSP", "diff 退出"); eq(d.keep.length, 1, "diff 保留数"); eq(d.keep[0].up, true, "加分升级必备标记"); /* easeOutCubic:count-up 缓动曲线(0→1,中点已过 87.5%) */ const easeOutCubic = loadFn("easeOutCubic"); eq(easeOutCubic(0), 0, "缓动起点为 0"); eq(easeOutCubic(1), 1, "缓动终点为 1"); eq(easeOutCubic(0.5), 0.875, "缓动中点 easeOut 特征"); eq(easeOutCubic(0.25) < easeOutCubic(0.5), true, "缓动单调递增"); /* trendSeries:近 n 季度事件序列(总量 + 四类计数) */ const trendSeries = new Function( `const CHANGE_META = { "新增": {}, "增强": {}, "减弱": {}, "退出": {} };\n${extractFn("trendSeries")}; return trendSeries;`)(); const tsEvts = [ ...Array.from({ length: 9 }, (_, i) => ({ window: `20${10 + i}Q1`, change_type: "新增" })), { window: "2018Q1", change_type: "增强" }, { window: "2018Q1", change_type: "退出" }, { window: "2018Q1", change_type: "其他" }, ]; const ts = trendSeries(tsEvts, 8); eq(ts.length, 8, "趋势序列只取近 8 季"); eq(ts[7].window, "2018Q1", "最近窗口在序列末尾"); eq(ts[7].total, 4, "末季总量含未分类事件"); eq(ts[7].byType["新增"], 1, "末季新增计数"); eq(ts[7].byType["增强"], 1, "末季增强计数"); eq(ts[7].byType["退出"], 1, "末季退出计数"); eq(trendSeries([], 8).length, 0, "空事件空序列"); /* computeReplaceBuckets:能力更替三桶(旧核心 / 持续 / 新核心,近 4 季度口径) */ const computeReplaceBuckets = loadFn("computeReplaceBuckets"); const rpWins = ["2025Q2", "2025Q3", "2025Q4", "2026Q1", "2026Q2", "2026Q3"]; const rpEdges = [ { target: "JSP", intervals: [["2020Q1", "2025Q4"]] }, // 已退出 → 旧核心 { target: "Java", intervals: [["2020Q1", null]] }, // 持续 { target: "Kubernetes", intervals: [["2026Q1", null]] }, // 近 4 季进入 → 新核心 { target: "Docker", intervals: [["2025Q2", null]] }, // 起点不在近 4 季 → 持续 ]; const rp = computeReplaceBuckets(rpEdges, rpWins); eq(rp.oldCore.map((e) => e.target).join(","), "JSP", "旧核心=已退出"); eq(rp.cont.map((e) => e.target).join(","), "Java,Docker", "持续=生效且非近期进入"); eq(rp.newCore.map((e) => e.target).join(","), "Kubernetes", "新核心=近 4 季进入"); eq(computeReplaceBuckets(rpEdges, []).newCore.length, 0, "无窗口时无新核心"); /* radarValues:双雷达候选人四维归一化(缺失 0 / 超界截断) */ const radarValues = new Function( `const RADAR_KEYS = ["required", "preferred", "experience", "scenario"];\n${extractFn("radarValues")}; return radarValues;`)(); eq(radarValues({ required: 80, preferred: 50, experience: 100, scenario: 0 }).join(","), "0.8,0.5,1,0", "雷达四维映射"); eq(radarValues({ required: 120 }).join(","), "1,0,0,0", "雷达超界截断"); eq(radarValues(null).join(","), "0,0,0,0", "雷达空对象兜底"); /* graph.js 纯逻辑:时序边生效判定 / 职级带归属(函数在 IIFE 内,按两空格缩进闭合提取) */ const gsrc = fs.readFileSync(path.join(__dirname, "..", "webui", "graph.js"), "utf-8"); function loadGraphFns(names) { const code = names.map((n) => { const m = gsrc.match(new RegExp(`function ${n}\\([\\s\\S]*?\\n \\}`)); if (!m) throw new Error(`graph.js 未找到函数 ${n}`); return m[0]; }).join("\n"); return new Function(`${code}; return { ${names.join(", ")} };`)(); } const gf = loadGraphFns(["jobLevel", "levelBandOf", "edgeActiveAt"]); eq(gf.edgeActiveAt({ intervals: [] }, "2026Q1"), true, "无区间边始终生效"); eq(gf.edgeActiveAt({ intervals: [["2025Q1", "2025Q3"]] }, "2025Q2"), true, "区间内生效"); eq(gf.edgeActiveAt({ intervals: [["2025Q1", "2025Q3"]] }, "2025Q3"), false, "结束边界半开不生效"); eq(gf.edgeActiveAt({ intervals: [["2025Q1", "2025Q3"]] }, "2024Q4"), false, "起点前不生效"); eq(gf.edgeActiveAt({ intervals: [["2025Q1", null]] }, "2026Q4"), true, "开放区间持续生效"); eq(gf.jobLevel({ expected_years: 1 }), "初级", "≤2年初级"); eq(gf.jobLevel({ expected_years: 4 }), "中级", "3-5年中级"); eq(gf.jobLevel({ expected_years: 8 }), "高级", ">5年高级"); eq(gf.levelBandOf({ type: "Job", expected_years: 1 }), 0, "初级岗位入带0"); eq(gf.levelBandOf({ type: "Job", expected_years: 8 }), 2, "高级岗位入带2"); eq(gf.levelBandOf({ type: "Skill", level: "L1" }), 0, "L1技能入带0"); eq(gf.levelBandOf({ type: "Skill", level: "L3" }), 2, "L3技能入带2"); eq(gf.levelBandOf({ type: "Skill" }), 1, "无级别技能默认中级带"); /* graph.js 纯逻辑:搜索排序 / 岗位对比 / 时序季度摘要 */ const gf2 = loadGraphFns(["searchRank", "compareJobs", "windowDigest"]); const SN = [ { id: "job_Java开发工程师", type: "Job", title: "Java开发工程师" }, { id: "job_Java架构师", type: "Job", title: "Java架构师" }, { id: "JavaScript", type: "Skill", title: "JavaScript" }, { id: "Docker", type: "Skill", title: "Docker" }, ]; eq(gf2.searchRank(SN, "").length, 0, "搜索:空串无结果"); eq(gf2.searchRank(SN, "架构").map((n) => n.id).join(","), "job_Java架构师", "搜索:中文子串命中"); eq(gf2.searchRank(SN, "javascript")[0].id, "JavaScript", "搜索:大小写不敏感"); eq(gf2.searchRank(SN, "java")[0].type, "Job", "搜索:并列时岗位优先于技能"); const CE = [ { source: "A", target: "Java", rel: "REQUIRES", importance: "required" }, { source: "A", target: "MySQL", rel: "REQUIRES", importance: "required" }, { source: "B", target: "Java", rel: "REQUIRES", importance: "required" }, { source: "B", target: "Go", rel: "REQUIRES", importance: "required" }, { source: "B", target: "K8s", rel: "REQUIRES", importance: "preferred" }, { source: "A", target: "X场景", rel: "APPLIES_TO" }, { source: "B", target: "Y技能", rel: "REQUIRES", importance: "required", pending: true }, ]; const cmp = gf2.compareJobs(CE, "A", "B"); eq(cmp.shared.map((x) => x.skill).join(","), "Java", "对比:共有技能(忽略非技能关系与待审核边)"); eq(cmp.onlyA.map((x) => x.skill).join(","), "MySQL", "对比:A 独有"); eq(cmp.onlyB.map((x) => x.skill).join(","), "Go,K8s", "对比:B 独有"); eq(cmp.gaps.join(","), "Go", "对比:转岗必备缺口只算 required"); eq(cmp.bReq, 2, "对比:目标必备总数"); eq(cmp.covered, 1, "对比:必备已覆盖数"); eq(cmp.ready, 0.5, "对比:就绪度=覆盖/必备"); const WE = [ { source: "job_A", target: "Java", intervals: [["2025Q1", null]] }, { source: "job_A", target: "K8s", intervals: [["2026Q1", null]] }, { source: "job_B", target: "Go", intervals: [["2026Q1", null]] }, ]; const WEV = [ { window: "2026Q1", change_type: "新增", skill: "K8s", job_id: "job_A" }, { window: "2026Q1", change_type: "退出", skill: "JSP", job_id: "job_A" }, { window: "2025Q4", change_type: "增强", skill: "Java", job_id: "job_A" }, ]; const WN = [ { id: "job_A", type: "Job", title: "A工程师" }, { id: "job_B", type: "Job", title: "B工程师" }, ]; const dg = gf2.windowDigest(WE, WEV, WN, "2026Q1"); eq(dg.newEdges.length, 2, "摘要:当季新增关系数"); eq(dg.newJobs.join(","), "B工程师", "摘要:当季新晋岗位按首次出现窗口判定"); eq(dg.added.length, 1, "摘要:当季新增事件"); eq(dg.exited.length, 1, "摘要:当季退出事件"); eq(dg.boosted.length, 0, "摘要:非当季事件不计入"); /* graph.js 纯逻辑:凸包(技术栈类别气泡轮廓) */ const gf3 = loadGraphFns(["convexHull"]); const sq = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }, { x: 5, y: 5 }]; eq(gf3.convexHull(sq).length, 4, "凸包:内部点被剔除"); eq(gf3.convexHull([{ x: 0, y: 0 }, { x: 5, y: 5 }]).length, 2, "凸包:两点原样返回"); eq(gf3.convexHull([{ x: 0, y: 0 }, { x: 5, y: 0 }, { x: 10, y: 0 }]).length, 2, "凸包:共线点只留端点"); const hullPt = gf3.convexHull(sq); eq(hullPt.some((p) => p.x === 5 && p.y === 5), false, "凸包:中心点不在顶点中"); console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0);