Files
CareerNexus/tests/test_frontend_logic.js
T

249 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 关键前端逻辑单测(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);