@jsonresume/utils 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2014-2026 JSON Resume contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@jsonresume/utils",
3
+ "version": "0.2.0",
4
+ "private": false,
5
+ "description": "Framework-free pure utilities for JSON Resume: date formatting, metrics, and URL safety",
6
+ "type": "module",
7
+ "main": "./src/index.js",
8
+ "types": "./src/index.d.ts",
9
+ "exports": {
10
+ ".": "./src/index.js",
11
+ "./dates": "./src/dates.js",
12
+ "./metrics": "./src/metrics/index.js",
13
+ "./url": "./src/url.js"
14
+ },
15
+ "files": [
16
+ "src"
17
+ ],
18
+ "devDependencies": {
19
+ "typescript": "^5.9.3",
20
+ "vitest": "^2",
21
+ "@jsonresume/types": "0.2.0",
22
+ "@repo/eslint-config-custom": "^0.0.0",
23
+ "tsconfig": "0.0.0"
24
+ },
25
+ "keywords": [
26
+ "resume",
27
+ "cv",
28
+ "json-resume",
29
+ "dates",
30
+ "metrics",
31
+ "utilities"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/jsonresume/jsonresume.org",
36
+ "directory": "packages/utils"
37
+ },
38
+ "homepage": "https://github.com/jsonresume/jsonresume.org/tree/master/packages/utils",
39
+ "bugs": {
40
+ "url": "https://github.com/jsonresume/jsonresume.org/issues"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "license": "MIT",
46
+ "scripts": {
47
+ "test": "vitest run",
48
+ "test:watch": "vitest",
49
+ "lint": "eslint src --cache --cache-location node_modules/.cache/.eslintcache",
50
+ "typecheck": "tsc --noEmit -p tsconfig.json"
51
+ }
52
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as barrel from '../metrics/index.js';
3
+ import { calculateTotalExperience } from '../metrics/experience.js';
4
+ import { work } from './fixtures.js';
5
+
6
+ /** Every public calculation helper, grouped by implementation module. */
7
+ const EXPECTED_EXPORTS = [
8
+ // experience.js
9
+ 'calculateTotalExperience',
10
+ 'calculateCurrentRoleExperience',
11
+ 'countCareerPositions',
12
+ 'getCareerProgressionRate',
13
+ 'countTotalHighlights',
14
+ // counts.js
15
+ 'countCompanies',
16
+ 'countProjects',
17
+ 'countPublications',
18
+ 'countAwards',
19
+ 'countTotalSkills',
20
+ 'countSkillCategories',
21
+ 'countLanguages',
22
+ // education.js
23
+ 'calculateEducationYears',
24
+ 'getHighestDegree',
25
+ // workHistory.js
26
+ 'calculateVolunteerYears',
27
+ 'getUniqueIndustries',
28
+ 'getCurrentEmployer',
29
+ 'isCurrentlyEmployed',
30
+ // keyMetrics.js
31
+ 'calculateKeyMetrics',
32
+ ];
33
+
34
+ describe('metrics barrel', () => {
35
+ it('re-exports exactly the 19 calculation helpers', () => {
36
+ expect(EXPECTED_EXPORTS).toHaveLength(19);
37
+ expect(Object.keys(barrel).sort()).toEqual([...EXPECTED_EXPORTS].sort());
38
+ });
39
+
40
+ it('exports a function for every helper', () => {
41
+ for (const name of EXPECTED_EXPORTS) {
42
+ expect(barrel[name], `${name} should be a function`).toBeTypeOf(
43
+ 'function'
44
+ );
45
+ }
46
+ });
47
+
48
+ it('re-exports the same implementations as the focused modules', () => {
49
+ expect(barrel.calculateTotalExperience).toBe(calculateTotalExperience);
50
+ });
51
+
52
+ it('helpers are callable through the barrel', () => {
53
+ // countCompanies is date-independent: 2 unique companies in the fixture.
54
+ expect(barrel.countCompanies(work)).toBe(2);
55
+ });
56
+ });
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ countCompanies,
4
+ countProjects,
5
+ countPublications,
6
+ countAwards,
7
+ countTotalSkills,
8
+ countSkillCategories,
9
+ countLanguages,
10
+ } from '../metrics/counts.js';
11
+ import {
12
+ work,
13
+ skills,
14
+ projects,
15
+ publications,
16
+ awards,
17
+ languages,
18
+ } from './fixtures.js';
19
+
20
+ describe('countCompanies', () => {
21
+ it('counts unique company names', () => {
22
+ // Acme Corp appears twice in the fixture
23
+ expect(countCompanies(work)).toBe(2);
24
+ });
25
+
26
+ it('ignores entries without a name', () => {
27
+ expect(
28
+ countCompanies([{ name: 'A' }, { position: 'Engineer' }, { name: 'B' }])
29
+ ).toBe(2);
30
+ });
31
+
32
+ it('returns 0 for empty, missing, or non-array input', () => {
33
+ expect(countCompanies([])).toBe(0);
34
+ expect(countCompanies()).toBe(0);
35
+ expect(countCompanies(null)).toBe(0);
36
+ expect(countCompanies('not-an-array')).toBe(0);
37
+ });
38
+ });
39
+
40
+ describe('countProjects', () => {
41
+ it('returns the number of projects', () => {
42
+ expect(countProjects(projects)).toBe(2);
43
+ expect(countProjects([{ name: 'Solo' }])).toBe(1);
44
+ });
45
+
46
+ it('returns 0 for empty, missing, or non-array input', () => {
47
+ expect(countProjects([])).toBe(0);
48
+ expect(countProjects()).toBe(0);
49
+ expect(countProjects(null)).toBe(0);
50
+ expect(countProjects({})).toBe(0);
51
+ });
52
+ });
53
+
54
+ describe('countPublications', () => {
55
+ it('returns the number of publications', () => {
56
+ expect(countPublications(publications)).toBe(1);
57
+ });
58
+
59
+ it('returns 0 for empty, missing, or non-array input', () => {
60
+ expect(countPublications([])).toBe(0);
61
+ expect(countPublications()).toBe(0);
62
+ expect(countPublications(null)).toBe(0);
63
+ });
64
+ });
65
+
66
+ describe('countAwards', () => {
67
+ it('returns the number of awards', () => {
68
+ expect(countAwards(awards)).toBe(1);
69
+ });
70
+
71
+ it('returns 0 for empty, missing, or non-array input', () => {
72
+ expect(countAwards([])).toBe(0);
73
+ expect(countAwards()).toBe(0);
74
+ expect(countAwards(null)).toBe(0);
75
+ });
76
+ });
77
+
78
+ describe('countTotalSkills', () => {
79
+ it('sums keywords across all skill categories', () => {
80
+ // 3 (Web) + 1 (Ops) + 0 (Soft Skills, no keywords)
81
+ expect(countTotalSkills(skills)).toBe(4);
82
+ });
83
+
84
+ it('treats categories without keywords as contributing 0', () => {
85
+ expect(countTotalSkills([{ name: 'Empty' }])).toBe(0);
86
+ });
87
+
88
+ it('returns 0 for empty, missing, or non-array input', () => {
89
+ expect(countTotalSkills([])).toBe(0);
90
+ expect(countTotalSkills()).toBe(0);
91
+ expect(countTotalSkills(null)).toBe(0);
92
+ });
93
+ });
94
+
95
+ describe('countSkillCategories', () => {
96
+ it('returns the number of skill categories', () => {
97
+ expect(countSkillCategories(skills)).toBe(3);
98
+ });
99
+
100
+ it('returns 0 for empty, missing, or non-array input', () => {
101
+ expect(countSkillCategories([])).toBe(0);
102
+ expect(countSkillCategories()).toBe(0);
103
+ expect(countSkillCategories(null)).toBe(0);
104
+ });
105
+ });
106
+
107
+ describe('countLanguages', () => {
108
+ it('returns the number of languages', () => {
109
+ expect(countLanguages(languages)).toBe(2);
110
+ expect(countLanguages([{ language: 'English' }])).toBe(1);
111
+ });
112
+
113
+ it('returns 0 for empty, missing, or non-array input', () => {
114
+ expect(countLanguages([])).toBe(0);
115
+ expect(countLanguages()).toBe(0);
116
+ expect(countLanguages(null)).toBe(0);
117
+ });
118
+ });
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import {
3
+ formatDateRange,
4
+ getRelativeTime,
5
+ getDuration,
6
+ normalizeDates,
7
+ } from '../dates.js';
8
+
9
+ const FIXED_NOW = new Date('2026-01-01T00:00:00Z');
10
+
11
+ describe('formatDateRange', () => {
12
+ it('returns empty string when startDate is missing', () => {
13
+ expect(formatDateRange({ startDate: '' })).toBe('');
14
+ expect(formatDateRange({ startDate: null })).toBe('');
15
+ expect(formatDateRange({ startDate: undefined })).toBe('');
16
+ });
17
+
18
+ it('renders a single date (no "Present") when endDate is undefined', () => {
19
+ // An omitted endDate is a point in time (award/certificate/publication
20
+ // date), NOT an ongoing range. It must render just the start with no
21
+ // separator and no "Present" — single-date theme sections depend on this.
22
+ const out = formatDateRange({ startDate: '2020-01-15' });
23
+ expect(out).toContain('2020');
24
+ expect(out).not.toContain('Present');
25
+ expect(out).not.toContain(' - ');
26
+ });
27
+
28
+ it('shows "Present" for an ongoing role when endDate is null', () => {
29
+ const out = formatDateRange({ startDate: '2020-01-15', endDate: null });
30
+ expect(out).toContain('2020');
31
+ expect(out).toContain('Present');
32
+ expect(out).toContain(' - ');
33
+ });
34
+
35
+ it('distinguishes undefined (single date) from null (Present)', () => {
36
+ expect(formatDateRange({ startDate: '2020-01-15' })).not.toBe(
37
+ formatDateRange({ startDate: '2020-01-15', endDate: null })
38
+ );
39
+ });
40
+
41
+ it('formats a closed range with start and end', () => {
42
+ const out = formatDateRange({
43
+ startDate: '2018-06-01',
44
+ endDate: '2020-03-01',
45
+ });
46
+ expect(out).toContain('2018');
47
+ expect(out).toContain('2020');
48
+ expect(out).toContain(' - ');
49
+ expect(out).not.toContain('Present');
50
+ });
51
+
52
+ it('supports long and numeric month formats', () => {
53
+ expect(
54
+ formatDateRange({ startDate: '2020-01-15', format: 'long' })
55
+ ).toContain('January');
56
+ expect(
57
+ formatDateRange({ startDate: '2020-01-15', format: 'numeric' })
58
+ ).toMatch(/01\/2020|01\s*2020/);
59
+ });
60
+
61
+ it('localizes the present label (fr-FR => Présent, de => Heute)', () => {
62
+ expect(
63
+ formatDateRange({
64
+ startDate: '2020-01-15',
65
+ endDate: null,
66
+ locale: 'fr-FR',
67
+ })
68
+ ).toContain('Présent');
69
+ expect(
70
+ formatDateRange({ startDate: '2020-01-15', endDate: null, locale: 'de' })
71
+ ).toContain('Heute');
72
+ });
73
+
74
+ it('honours an explicit presentLabel override', () => {
75
+ expect(
76
+ formatDateRange({
77
+ startDate: '2020-01-15',
78
+ endDate: null,
79
+ presentLabel: 'Now',
80
+ })
81
+ ).toContain('Now');
82
+ });
83
+
84
+ it('returns an invalid start date string as-is (single date, undefined end)', () => {
85
+ expect(formatDateRange({ startDate: 'not-a-date' })).toBe('not-a-date');
86
+ });
87
+
88
+ it('tags an invalid start date with Present when endDate is explicitly null', () => {
89
+ expect(formatDateRange({ startDate: 'not-a-date', endDate: null })).toBe(
90
+ 'not-a-date - Present'
91
+ );
92
+ });
93
+
94
+ it('accepts Date objects as well as strings', () => {
95
+ expect(
96
+ formatDateRange({ startDate: new Date('2021-05-01'), endDate: null })
97
+ ).toContain('2021');
98
+ });
99
+ });
100
+
101
+ describe('getRelativeTime', () => {
102
+ beforeEach(() => {
103
+ vi.useFakeTimers();
104
+ vi.setSystemTime(FIXED_NOW);
105
+ });
106
+ afterEach(() => {
107
+ vi.useRealTimers();
108
+ });
109
+
110
+ it('reports whole years ago', () => {
111
+ expect(getRelativeTime('2020-01-01')).toBe('6 years ago');
112
+ });
113
+
114
+ it('drops the suffix when ago=false', () => {
115
+ expect(getRelativeTime('2025-12-01', false)).toBe('1 month');
116
+ });
117
+
118
+ it('returns "just now" for the present moment', () => {
119
+ expect(getRelativeTime(FIXED_NOW)).toBe('just now');
120
+ });
121
+ });
122
+
123
+ describe('getDuration', () => {
124
+ it('reports years and months between two dates', () => {
125
+ expect(getDuration('2020-01-01', '2022-07-05')).toBe('2 years, 6 months');
126
+ });
127
+
128
+ it('reports whole years when no extra months', () => {
129
+ expect(getDuration('2020-01-01', '2023-01-02')).toBe('3 years');
130
+ });
131
+
132
+ it('reports months when under a year', () => {
133
+ expect(getDuration('2023-01-01', '2023-04-05')).toBe('3 months');
134
+ });
135
+
136
+ it('reports days when under a month', () => {
137
+ expect(getDuration('2023-01-01', '2023-01-10')).toBe('9 days');
138
+ });
139
+ });
140
+
141
+ describe('normalizeDates', () => {
142
+ it('stringifies Date-valued date fields across sections', () => {
143
+ const resume = {
144
+ basics: { name: 'A' },
145
+ work: [{ name: 'Co', startDate: new Date('2020-01-15T00:00:00Z') }],
146
+ };
147
+ const out = normalizeDates(resume);
148
+ expect(out.work[0].startDate).toBe('2020-01-15');
149
+ });
150
+
151
+ it('leaves string date fields untouched', () => {
152
+ const resume = { work: [{ startDate: '2020-01' }] };
153
+ expect(normalizeDates(resume).work[0].startDate).toBe('2020-01');
154
+ });
155
+
156
+ it('does not coerce non-Date object/array date fields', () => {
157
+ const resume = { work: [{ startDate: { y: 2020 } }] };
158
+ expect(normalizeDates(resume).work[0].startDate).toEqual({ y: 2020 });
159
+ });
160
+
161
+ it('does not mutate the input resume', () => {
162
+ const start = new Date('2020-01-15T00:00:00Z');
163
+ const resume = { work: [{ startDate: start }] };
164
+ normalizeDates(resume);
165
+ expect(resume.work[0].startDate).toBe(start);
166
+ });
167
+ });
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import {
3
+ calculateEducationYears,
4
+ getHighestDegree,
5
+ } from '../metrics/education.js';
6
+ import { education, FIXED_NOW } from './fixtures.js';
7
+
8
+ beforeEach(() => {
9
+ vi.useFakeTimers();
10
+ vi.setSystemTime(FIXED_NOW);
11
+ });
12
+
13
+ afterEach(() => {
14
+ vi.useRealTimers();
15
+ });
16
+
17
+ describe('calculateEducationYears (BUGFIX: ms-per-year)', () => {
18
+ // The original implementation divided by ((ms-per-day) / 365.25) instead of
19
+ // (ms-per-day * 365.25), inflating results ~133M-fold. The fixture spans ~5.5
20
+ // real years of study; the fix now reports a sane, rounded number of years.
21
+ it('returns a realistic number of years for the fixture', () => {
22
+ expect(calculateEducationYears(education)).toBe(5);
23
+ });
24
+
25
+ it('returns realistic years for a single entry (~3.75y => 4)', () => {
26
+ expect(
27
+ calculateEducationYears([
28
+ { startDate: '2012-09-01', endDate: '2016-06-01' },
29
+ ])
30
+ ).toBe(4);
31
+ });
32
+
33
+ it('is the same order of magnitude as the real elapsed years (not inflated)', () => {
34
+ // Guards against a regression to the old (end-start)/(ms-per-day/365.25)
35
+ // formula, which returned 733057 for this fixture.
36
+ expect(calculateEducationYears(education)).toBeLessThan(20);
37
+ });
38
+
39
+ it('skips entries without a startDate', () => {
40
+ expect(calculateEducationYears([{ endDate: '2016-06-01' }, {}])).toBe(0);
41
+ });
42
+
43
+ it('returns 0 for empty, missing, or non-array input', () => {
44
+ expect(calculateEducationYears([])).toBe(0);
45
+ expect(calculateEducationYears()).toBe(0);
46
+ expect(calculateEducationYears(null)).toBe(0);
47
+ expect(calculateEducationYears('not-an-array')).toBe(0);
48
+ });
49
+ });
50
+
51
+ describe('getHighestDegree', () => {
52
+ it('returns the studyType of the highest ranked degree', () => {
53
+ expect(getHighestDegree(education)).toBe('Master of Science');
54
+ });
55
+
56
+ it('ranks doctorates above masters above bachelors', () => {
57
+ expect(
58
+ getHighestDegree([
59
+ { studyType: 'Bachelor of Arts' },
60
+ { studyType: 'PhD in Physics' },
61
+ { studyType: 'Master of Science' },
62
+ ])
63
+ ).toBe('PhD in Physics');
64
+ });
65
+
66
+ it('matches degree keywords case-insensitively', () => {
67
+ expect(getHighestDegree([{ studyType: 'BACHELOR OF ARTS' }])).toBe(
68
+ 'BACHELOR OF ARTS'
69
+ );
70
+ expect(getHighestDegree([{ studyType: 'MBA' }])).toBe('MBA');
71
+ });
72
+
73
+ it('keeps the first entry on rank ties', () => {
74
+ expect(
75
+ getHighestDegree([{ studyType: 'Doctorate' }, { studyType: 'PhD' }])
76
+ ).toBe('Doctorate');
77
+ });
78
+
79
+ it('returns an empty string when no studyType matches a known degree', () => {
80
+ expect(getHighestDegree([{ studyType: 'Bootcamp' }])).toBe('');
81
+ expect(getHighestDegree([{ institution: 'No Study Type U' }])).toBe('');
82
+ });
83
+
84
+ it('returns an empty string for empty, missing, or non-array input', () => {
85
+ expect(getHighestDegree([])).toBe('');
86
+ expect(getHighestDegree()).toBe('');
87
+ expect(getHighestDegree(null)).toBe('');
88
+ });
89
+ });
@@ -0,0 +1,150 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import {
3
+ calculateTotalExperience,
4
+ calculateCurrentRoleExperience,
5
+ countCareerPositions,
6
+ getCareerProgressionRate,
7
+ countTotalHighlights,
8
+ } from '../metrics/experience.js';
9
+ import { work, FIXED_NOW } from './fixtures.js';
10
+
11
+ beforeEach(() => {
12
+ vi.useFakeTimers();
13
+ vi.setSystemTime(FIXED_NOW);
14
+ });
15
+
16
+ afterEach(() => {
17
+ vi.useRealTimers();
18
+ });
19
+
20
+ describe('calculateTotalExperience', () => {
21
+ it('sums tenure across all jobs, rounded to nearest year', () => {
22
+ // ~5.0 (ongoing) + ~3.0 + ~2.0 years
23
+ expect(calculateTotalExperience(work)).toBe(10);
24
+ });
25
+
26
+ it('uses the current date for ongoing roles (no endDate)', () => {
27
+ expect(calculateTotalExperience([{ startDate: '2021-01-01' }])).toBe(5);
28
+ });
29
+
30
+ it('skips entries without a startDate', () => {
31
+ expect(
32
+ calculateTotalExperience([
33
+ { startDate: '2018-01-01', endDate: '2021-01-01' },
34
+ { endDate: '2020-01-01' },
35
+ {},
36
+ ])
37
+ ).toBe(3);
38
+ });
39
+
40
+ it('double-counts overlapping periods (naive per-job sum)', () => {
41
+ expect(
42
+ calculateTotalExperience([
43
+ { startDate: '2018-01-01', endDate: '2020-01-01' },
44
+ { startDate: '2019-01-01', endDate: '2021-01-01' },
45
+ ])
46
+ ).toBe(4);
47
+ });
48
+
49
+ it('returns 0 for empty, missing, or non-array input', () => {
50
+ expect(calculateTotalExperience([])).toBe(0);
51
+ expect(calculateTotalExperience()).toBe(0);
52
+ expect(calculateTotalExperience(null)).toBe(0);
53
+ expect(calculateTotalExperience('not-an-array')).toBe(0);
54
+ });
55
+ });
56
+
57
+ describe('calculateCurrentRoleExperience', () => {
58
+ it('measures the first role without an endDate, rounded to 1 decimal', () => {
59
+ expect(calculateCurrentRoleExperience(work)).toBe(5);
60
+ });
61
+
62
+ it('finds the current role even when it is not first in the array', () => {
63
+ expect(
64
+ calculateCurrentRoleExperience([
65
+ { startDate: '2018-01-01', endDate: '2021-01-01' },
66
+ { startDate: '2023-07-01' },
67
+ ])
68
+ ).toBe(2.5);
69
+ });
70
+
71
+ it('falls back to the first entry when every role has ended', () => {
72
+ expect(
73
+ calculateCurrentRoleExperience([
74
+ { startDate: '2018-01-01', endDate: '2021-01-01' },
75
+ { startDate: '2016-01-01', endDate: '2018-01-01' },
76
+ ])
77
+ ).toBe(3);
78
+ });
79
+
80
+ it('returns 0 when the selected role has no startDate', () => {
81
+ expect(calculateCurrentRoleExperience([{ position: 'Engineer' }])).toBe(0);
82
+ });
83
+
84
+ it('returns 0 for empty, missing, or non-array input', () => {
85
+ expect(calculateCurrentRoleExperience([])).toBe(0);
86
+ expect(calculateCurrentRoleExperience()).toBe(0);
87
+ expect(calculateCurrentRoleExperience(null)).toBe(0);
88
+ });
89
+ });
90
+
91
+ describe('countCareerPositions', () => {
92
+ it('counts unique position titles', () => {
93
+ expect(countCareerPositions(work)).toBe(3);
94
+ });
95
+
96
+ it('deduplicates repeated titles and ignores missing ones', () => {
97
+ expect(
98
+ countCareerPositions([
99
+ { position: 'Engineer' },
100
+ { position: 'Engineer' },
101
+ { name: 'No Position Inc' },
102
+ { position: 'Manager' },
103
+ ])
104
+ ).toBe(2);
105
+ });
106
+
107
+ it('returns 0 for empty, missing, or non-array input', () => {
108
+ expect(countCareerPositions([])).toBe(0);
109
+ expect(countCareerPositions()).toBe(0);
110
+ expect(countCareerPositions(null)).toBe(0);
111
+ });
112
+ });
113
+
114
+ describe('getCareerProgressionRate', () => {
115
+ it('returns average years per position, rounded to 1 decimal', () => {
116
+ // 10 total years / 3 unique positions
117
+ expect(getCareerProgressionRate(work)).toBe(3.3);
118
+ });
119
+
120
+ it('returns 0 when there is no work history', () => {
121
+ expect(getCareerProgressionRate([])).toBe(0);
122
+ expect(getCareerProgressionRate()).toBe(0);
123
+ });
124
+
125
+ it('returns 0 when total experience rounds down to 0 years', () => {
126
+ expect(
127
+ getCareerProgressionRate([
128
+ { position: 'Intern', startDate: '2025-11-01', endDate: '2025-12-01' },
129
+ ])
130
+ ).toBe(0);
131
+ });
132
+ });
133
+
134
+ describe('countTotalHighlights', () => {
135
+ it('sums highlights across all jobs', () => {
136
+ expect(countTotalHighlights(work)).toBe(5);
137
+ });
138
+
139
+ it('treats jobs without highlights as contributing 0', () => {
140
+ expect(
141
+ countTotalHighlights([{ highlights: ['a', 'b'] }, { name: 'None Inc' }])
142
+ ).toBe(2);
143
+ });
144
+
145
+ it('returns 0 for empty, missing, or non-array input', () => {
146
+ expect(countTotalHighlights([])).toBe(0);
147
+ expect(countTotalHighlights()).toBe(0);
148
+ expect(countTotalHighlights(null)).toBe(0);
149
+ });
150
+ });