/** * 分享 / 导出文本的拼装函数(纯函数,API 层)。 * * 【为什么值得单测,而不是只靠真机验证】 * 4.9 那批把 6 处「点了只弹功能开发中」改成了真导出,4.12 全部在模拟器上验过 —— * 但真机验的是**当时的 mock 值**:面板预览里数字对上了,说明「这一次拼对了」, * 不说明「以后一直拼得对」。分享文本会**离开本机、给到别人看**, * 拼错了比页面显示错更难被发现(页面自己看得见,发出去的内容看不见)。 * * 这些函数是纯函数,正是单测最划算的地方。4.9 的方案里写了「并补单测」, * 但当时只给作为范式的 `buildChecklistText` 补了(见 onboarding-checklist.test.ts), * 其余 5 个一直裸奔 —— 本文件补上。 * * 侧重点不是「复述实现」,而是锁住三类容易悄悄坏掉的地方: * ① **空值守卫**:对应的数据没有时,不该输出「0 条」「空行」「悬空的破折号」 * ② **取的是不是本地选择态**:面试配置那份必须读传入的选择,不是接口默认值 * ③ **外部可见的格式**:首行是什么、签名是什么,改了就是对外观感变了 */ jest.mock('../lib/storage', () => { const store = new Map(); return { storageGet: jest.fn(async (key: string) => store.get(key) ?? null), storageSet: jest.fn(async (key: string, value: string) => { store.set(key, value); }), storageRemove: jest.fn(async (key: string) => { store.delete(key); }), }; }); import { buildHollandReportText, buildInterviewConfigText, buildJobShareText, buildPrivacySummaryText, buildRankReportText, fetchAIInterviewData, fetchHollandAssessment, fetchJobDetail, fetchJobOverallScore, fetchPrivacyCenter, fetchSalaryData, } from '../api/api'; import type { AIInterviewData, HollandAssessment, InterviewQuestionTypeCode, PrivacyCenter, RankOverview, } from '../types'; /** 所有分享文本都以这句收尾 —— 对外可见,单独锁一条 */ const SIGNATURE = '—— 来自「有职」'; const textOf = (text: string) => text.split('\n'); const hasLineStartingWith = (text: string, prefix: string) => textOf(text).some((line) => line.startsWith(prefix)); describe('buildJobShareText(岗位详情分享)', () => { async function fixtures() { const [detail, overallScore, salaryData] = await Promise.all([ fetchJobDetail('1'), fetchJobOverallScore('1'), fetchSalaryData('1'), ]); return { detail, overallScore, salaryData }; } it('首三行是「岗位|公司」「地点·经验·学历」「薪资区间」', async () => { const { detail, overallScore, salaryData } = await fixtures(); const lines = textOf(buildJobShareText(detail, overallScore, salaryData, 3)); expect(lines[0]).toBe(`${detail.positionName}|${detail.companyName}`); expect(lines[1]).toContain(detail.companyLocation); expect(lines[2]).toBe( `薪资 ${detail.salaryMin}-${detail.salaryMax}K · ${detail.salaryMonths}薪`, ); }); it('薪资月数缺失时不输出「0薪」这种话', async () => { const { detail, overallScore, salaryData } = await fixtures(); const text = buildJobShareText({ ...detail, salaryMonths: 0 }, overallScore, salaryData, 3); expect(text).not.toContain('0薪'); expect(hasLineStartingWith(text, '薪资 ')).toBe(true); }); it('没有评分数据时整行不出现(而不是输出 null 或 0 分)', async () => { const { detail, salaryData } = await fixtures(); const text = buildJobShareText(detail, null, salaryData, 3); expect(text).not.toContain('岗位综合评分'); expect(text).not.toContain('null'); expect(text).not.toContain('undefined'); }); it('真实薪资样本数为 0 时不输出「真实薪资」(避免展示无依据的数据)', async () => { const { detail, overallScore, salaryData } = await fixtures(); const text = buildJobShareText(detail, overallScore, { ...salaryData, totalCount: 0 }, 3); expect(text).not.toContain('真实薪资'); }); it('福利为空时不留下「福利:」这个空壳', async () => { const { detail, overallScore, salaryData } = await fixtures(); const text = buildJobShareText({ ...detail, benefits: [] }, overallScore, salaryData, 3); expect(text).not.toContain('福利:'); }); it('评价数为 0 时说「暂无员工评价」,不说「员工评价 0 条」', async () => { const { detail, overallScore, salaryData } = await fixtures(); const text = buildJobShareText(detail, overallScore, salaryData, 0); expect(text).toContain('暂无员工评价'); expect(text).not.toContain('员工评价 0 条'); }); it('评价数大于 0 时给出条数', async () => { const { detail, overallScore, salaryData } = await fixtures(); expect(buildJobShareText(detail, overallScore, salaryData, 3)).toContain('员工评价 3 条'); }); it('以签名收尾', async () => { const { detail, overallScore, salaryData } = await fixtures(); expect(buildJobShareText(detail, overallScore, salaryData, 3)).toContain(SIGNATURE); }); }); describe('buildHollandReportText(霍兰德测评报告)', () => { /** 取真实测评数据结构,再按需覆盖 —— 避免手搭一份与接口脱节的形状 */ async function baseResult(): Promise> { const assessment = await fetchHollandAssessment(); if (!assessment.result) throw new Error('mock 的测评结果缺失,无法构造用例'); return assessment.result; } it('头部是报告名与「类型代码:X · 名称」', async () => { const result = await baseResult(); const lines = textOf(buildHollandReportText(result)); expect(lines[0]).toBe('霍兰德职业兴趣测评报告'); expect(lines[1]).toBe(`类型代码:${result.code} · ${result.name}`); }); it('六维为空时连标题都不出现', async () => { const result = await baseResult(); const text = buildHollandReportText({ ...result, dimensions: [] }); expect(text).not.toContain('【六维得分】'); }); it('某类型没有推荐职业时,不给它加「适合职业:」', async () => { const result = await baseResult(); const first = result.analyses[0]; if (!first) throw new Error('mock 里没有主导类型解析'); const text = buildHollandReportText({ ...result, analyses: [{ ...first, careers: [] }], }); expect(text).not.toContain('适合职业:'); }); it('推荐岗位为空时不出现「【推荐岗位】」', async () => { const result = await baseResult(); const text = buildHollandReportText({ ...result, jobs: [] }); expect(text).not.toContain('【推荐岗位】'); }); it('以签名收尾', async () => { const result = await baseResult(); expect(buildHollandReportText(result)).toContain(SIGNATURE); }); }); describe('buildInterviewConfigText(面试配置分享)', () => { let data: AIInterviewData; beforeAll(async () => { data = await fetchAIInterviewData(); }); it('模式输出中文 label,不是「full」这种 code', () => { const text = buildInterviewConfigText(data, 'full', [], []); const expected = data.modes.find((m) => m.id === 'full')?.label; expect(expected).toBeDefined(); expect(text).toContain(`模式:${expected}`); expect(text).not.toContain('模式:full'); }); it('**只输出被选中的题型** —— 分享的是本地选择,不是接口默认值', () => { // 4.12 用「改选后再分享、面板预览随之变」在真机上证过这一点; // 这里把它固化成不依赖设备的断言:传子集 → 未选中的不能出现。 const all = data.questionTypes.map((t) => t.id); const excluded = data.questionTypes.filter((t) => t.id !== all[0]); const onlyFirst: InterviewQuestionTypeCode[] = [all[0]]; const text = buildInterviewConfigText(data, 'full', onlyFirst, []); for (const t of excluded) { expect(text).not.toContain(t.label); } }); it('未知模式 code 回落到 code 本身,不崩也不输出「undefined」', () => { const text = buildInterviewConfigText(data, 'unknown-mode' as never, [], []); expect(text).toContain('模式:unknown-mode'); expect(text).not.toContain('undefined'); }); it('未知题型 code 被忽略,不崩也不输出「undefined」', () => { const text = buildInterviewConfigText(data, 'full', ['nope' as never], []); expect(text).not.toContain('undefined'); expect(text).not.toContain('题型:'); }); it('没选题型与专项时,这两行都不出现', () => { const text = buildInterviewConfigText(data, 'full', [], []); expect(text).not.toContain('题型:'); expect(text).not.toContain('专项突破:'); }); it('以签名收尾', () => { expect(buildInterviewConfigText(data, 'full', [], [])).toContain(SIGNATURE); }); }); describe('buildRankReportText(竞争力分析报告)', () => { it('没有排名数据时不崩,且不输出头部行', () => { const overview: RankOverview = { hasData: false }; const text = buildRankReportText(overview); expect(text).not.toContain('竞争力分析报告'); expect(text).toContain(SIGNATURE); }); it('没有排名但仍有维度与建议时,照样输出这两块', () => { const overview: RankOverview = { hasData: true, dimensions: [{ id: 'd1', name: '技术能力', percentile: '前 20%', score: 88 }], tips: [{ id: 't1', title: '补足项目数据', description: '用指标描述成果' }], }; const text = buildRankReportText(overview); expect(text).toContain('【维度百分位】'); expect(text).toContain('技术能力:前 20%(88 分)'); expect(text).toContain('【提升建议】'); expect(text).toContain('· 补足项目数据 —— 用指标描述成果'); }); it('建议没有描述时不留下悬空的分隔符', () => { const overview: RankOverview = { hasData: true, tips: [{ id: 't1', title: '补足项目数据' }], }; const text = buildRankReportText(overview); // 断言**那一行**,不是整段文本:签名行本身就含「——」, // 对整段断言 not.toContain('——') 会被签名行打脸(这条一开始就写错了,见提交说明) const tipLine = textOf(text).find((line) => line.startsWith('· ')); expect(tipLine).toBe('· 补足项目数据'); }); }); describe('buildPrivacySummaryText(隐私设置摘要)', () => { let center: PrivacyCenter; beforeAll(async () => { center = await fetchPrivacyCenter(); }); it('首两行是摘要标题与等级说明', () => { const lines = textOf(buildPrivacySummaryText(center)); expect(lines[0]).toBe(`我的隐私设置摘要(当前等级:${center.privacyLevel.label})`); expect(lines[1]).toBe(center.privacyLevel.description); }); it('开关项输出 [已开启] / [已关闭]', () => { const fixture: PrivacyCenter = { ...center, sections: [ { id: 's1', title: '数据可见性', items: [ { id: 'i1', type: 'switch', title: '对 HR 公开简历', enabled: true }, { id: 'i2', type: 'switch', title: '允许推荐', enabled: false }, ], }, ], }; const text = buildPrivacySummaryText(fixture); expect(text).toContain('【数据可见性】'); expect(text).toContain('[已开启] 对 HR 公开简历'); expect(text).toContain('[已关闭] 允许推荐'); }); it('取值项输出「标题:值」', () => { const fixture: PrivacyCenter = { ...center, sections: [ { id: 's1', title: '可见范围', items: [{ id: 'i1', type: 'value', title: '简历可见范围', value: '仅投递企业' }], }, ], }; expect(buildPrivacySummaryText(fixture)).toContain('简历可见范围:仅投递企业'); }); it('取值项没填值时只输出标题,不留「标题:」这个空壳', () => { const fixture: PrivacyCenter = { ...center, sections: [ { id: 's1', title: '可见范围', items: [{ id: 'i1', type: 'value', title: '简历可见范围', value: '' }], }, ], }; const text = buildPrivacySummaryText(fixture); expect(text).toContain('简历可见范围'); expect(text).not.toContain('简历可见范围:'); }); it('以签名收尾', () => { expect(buildPrivacySummaryText(center)).toContain(SIGNATURE); }); it('空分组不产生多余的连续空行', () => { const fixture: PrivacyCenter = { ...center, sections: [{ id: 's1', title: '空分组', items: [] }], }; const lines = textOf(buildPrivacySummaryText(fixture)); expect(lines.some((line) => line.includes('【空分组】'))).toBe(true); // 分组之间只留一个空行,不留「\n\n\n」 expect(buildPrivacySummaryText(fixture)).not.toContain('\n\n\n'); }); });