轻松聘
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

371 lines
13 KiB

/**
* 分享 / 导出文本的拼装函数(纯函数,API 层)。
*
* 【为什么值得单测,而不是只靠真机验证】
* 4.9 那批把 6 处「点了只弹功能开发中」改成了真导出,4.12 全部在模拟器上验过 ——
* 但真机验的是**当时的 mock 值**:面板预览里数字对上了,说明「这一次拼对了」,
* 不说明「以后一直拼得对」。分享文本会**离开本机、给到别人看**,
* 拼错了比页面显示错更难被发现(页面自己看得见,发出去的内容看不见)。
*
* 这些函数是纯函数,正是单测最划算的地方。4.9 的方案里写了「并补单测」,
* 但当时只给作为范式的 `buildChecklistText` 补了(见 onboarding-checklist.test.ts),
* 其余 5 个一直裸奔 —— 本文件补上。
*
* 侧重点不是「复述实现」,而是锁住三类容易悄悄坏掉的地方:
* ① **空值守卫**:对应的数据没有时,不该输出「0 条」「空行」「悬空的破折号」
* ② **取的是不是本地选择态**:面试配置那份必须读传入的选择,不是接口默认值
* ③ **外部可见的格式**:首行是什么、签名是什么,改了就是对外观感变了
*/
jest.mock('../lib/storage', () => {
const store = new Map<string, string>();
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<NonNullable<HollandAssessment['result']>> {
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');
});
});