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
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');
|
|
});
|
|
});
|
|
|