@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.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Shared fixtures for calculation helper tests.
3
+ *
4
+ * All date math in these tests pins "now" to FIXED_NOW via vi.setSystemTime
5
+ * so that open-ended entries (no endDate) produce deterministic values.
6
+ */
7
+
8
+ /** Pinned system time used by every date-dependent test. */
9
+ export const FIXED_NOW = new Date('2026-01-01T00:00:00Z');
10
+
11
+ /**
12
+ * Work history (most recent first, matching JSON Resume convention):
13
+ * - Acme Corp (current, no endDate): 2021-01-01 -> now = ~5.0 years
14
+ * - Globex: 2018-01-01 -> 2021-01-01 = ~3.0 years
15
+ * - Acme Corp (again, duplicate company name): 2016-01-01 -> 2018-01-01 = ~2.0 years
16
+ * Total ~10.0 years, 2 unique companies, 3 unique positions, 5 highlights.
17
+ */
18
+ export const work = [
19
+ {
20
+ name: 'Acme Corp',
21
+ position: 'Senior Engineer',
22
+ startDate: '2021-01-01',
23
+ industry: 'Technology',
24
+ highlights: ['Led platform migration', 'Mentored four engineers'],
25
+ },
26
+ {
27
+ name: 'Globex',
28
+ position: 'Engineer',
29
+ startDate: '2018-01-01',
30
+ endDate: '2021-01-01',
31
+ industry: 'Finance',
32
+ highlights: [
33
+ 'Shipped payments service',
34
+ 'Cut API latency 40%',
35
+ 'On-call rotation lead',
36
+ ],
37
+ },
38
+ {
39
+ name: 'Acme Corp',
40
+ position: 'Junior Engineer',
41
+ startDate: '2016-01-01',
42
+ endDate: '2018-01-01',
43
+ },
44
+ ];
45
+
46
+ export const education = [
47
+ {
48
+ institution: 'State University',
49
+ studyType: 'Bachelor of Science',
50
+ area: 'Computer Science',
51
+ startDate: '2012-09-01',
52
+ endDate: '2016-06-01',
53
+ },
54
+ {
55
+ institution: 'State University',
56
+ studyType: 'Master of Science',
57
+ area: 'Computer Science',
58
+ startDate: '2016-09-01',
59
+ endDate: '2018-06-01',
60
+ },
61
+ ];
62
+
63
+ export const volunteer = [
64
+ {
65
+ organization: 'Code Club',
66
+ position: 'Mentor',
67
+ startDate: '2019-01-01',
68
+ endDate: '2022-01-01',
69
+ },
70
+ // No startDate: must be skipped by duration calculations.
71
+ { organization: 'Food Bank', position: 'Helper' },
72
+ ];
73
+
74
+ export const skills = [
75
+ { name: 'Web', keywords: ['JavaScript', 'TypeScript', 'React'] },
76
+ { name: 'Ops', keywords: ['Docker'] },
77
+ // No keywords: contributes 0 to countTotalSkills but 1 category.
78
+ { name: 'Soft Skills' },
79
+ ];
80
+
81
+ export const projects = [{ name: 'Project One' }, { name: 'Project Two' }];
82
+
83
+ export const publications = [{ name: 'A Paper' }];
84
+
85
+ export const awards = [{ title: 'Best Engineer' }];
86
+
87
+ export const languages = [
88
+ { language: 'English', fluency: 'Native' },
89
+ { language: 'Spanish', fluency: 'Professional' },
90
+ ];
91
+
92
+ /** A representative, fully-populated JSON Resume object. */
93
+ export const fullResume = {
94
+ basics: { name: 'Jane Developer' },
95
+ work,
96
+ education,
97
+ volunteer,
98
+ skills,
99
+ projects,
100
+ publications,
101
+ awards,
102
+ languages,
103
+ };
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { calculateKeyMetrics } from '../metrics/keyMetrics.js';
3
+ import { fullResume, skills, languages, FIXED_NOW } from './fixtures.js';
4
+
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ vi.setSystemTime(FIXED_NOW);
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.useRealTimers();
12
+ });
13
+
14
+ describe('calculateKeyMetrics', () => {
15
+ it('builds the full dashboard metric list in order for a populated resume', () => {
16
+ expect(calculateKeyMetrics(fullResume)).toEqual([
17
+ { label: 'Years Experience', value: 10 },
18
+ { label: 'Companies', value: 2 },
19
+ { label: 'Projects', value: 2 },
20
+ { label: 'Core Skills', value: 4 },
21
+ { label: 'Publications', value: 1 },
22
+ { label: 'Awards', value: 1 },
23
+ { label: 'Education', value: 'Master of Science' },
24
+ { label: 'Languages', value: 2 },
25
+ ]);
26
+ });
27
+
28
+ it('returns an empty array for an empty resume object', () => {
29
+ expect(calculateKeyMetrics({})).toEqual([]);
30
+ });
31
+
32
+ it('only includes metrics for populated sections', () => {
33
+ expect(calculateKeyMetrics({ skills })).toEqual([
34
+ { label: 'Core Skills', value: 4 },
35
+ ]);
36
+ });
37
+
38
+ it('omits the Languages metric unless multilingual (more than one)', () => {
39
+ expect(calculateKeyMetrics({ languages: [languages[0]] })).toEqual([]);
40
+ expect(calculateKeyMetrics({ languages })).toEqual([
41
+ { label: 'Languages', value: 2 },
42
+ ]);
43
+ });
44
+
45
+ it('omits Education when no studyType matches a known degree', () => {
46
+ expect(
47
+ calculateKeyMetrics({ education: [{ institution: 'No Degree U' }] })
48
+ ).toEqual([]);
49
+ });
50
+
51
+ // Documents current behavior: the resume argument has no default, so
52
+ // destructuring undefined throws. Callers must pass an object.
53
+ it('throws when called without a resume object', () => {
54
+ expect(() => calculateKeyMetrics()).toThrow(TypeError);
55
+ });
56
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { formatLocation, normalizeResume } from '../resume.js';
3
+
4
+ describe('formatLocation', () => {
5
+ it('joins city, region, countryCode with ", "', () => {
6
+ expect(
7
+ formatLocation({ city: 'Berlin', region: 'BE', countryCode: 'DE' })
8
+ ).toBe('Berlin, BE, DE');
9
+ });
10
+
11
+ it('drops empty / missing parts', () => {
12
+ expect(formatLocation({ city: 'Berlin', countryCode: 'DE' })).toBe(
13
+ 'Berlin, DE'
14
+ );
15
+ expect(formatLocation({ region: 'CA' })).toBe('CA');
16
+ });
17
+
18
+ it('returns empty string for falsy / non-object input', () => {
19
+ expect(formatLocation()).toBe('');
20
+ expect(formatLocation(null)).toBe('');
21
+ expect(formatLocation('x')).toBe('');
22
+ expect(formatLocation({})).toBe('');
23
+ });
24
+ });
25
+
26
+ describe('normalizeResume', () => {
27
+ it('defaults all eleven array sections to [] and basics to {}', () => {
28
+ const out = normalizeResume({});
29
+ expect(out.basics).toEqual({});
30
+ for (const section of [
31
+ 'work',
32
+ 'volunteer',
33
+ 'education',
34
+ 'awards',
35
+ 'certificates',
36
+ 'publications',
37
+ 'skills',
38
+ 'languages',
39
+ 'interests',
40
+ 'references',
41
+ 'projects',
42
+ ]) {
43
+ expect(out[section]).toEqual([]);
44
+ }
45
+ });
46
+
47
+ it('preserves existing sections and basics', () => {
48
+ const resume = {
49
+ basics: { name: 'A' },
50
+ work: [{ name: 'Co' }],
51
+ };
52
+ const out = normalizeResume(resume);
53
+ expect(out.basics).toEqual({ name: 'A' });
54
+ expect(out.work).toEqual([{ name: 'Co' }]);
55
+ expect(out.education).toEqual([]);
56
+ });
57
+
58
+ it('coerces non-array section values to []', () => {
59
+ expect(normalizeResume({ work: 'nope' }).work).toEqual([]);
60
+ expect(normalizeResume({ skills: { a: 1 } }).skills).toEqual([]);
61
+ });
62
+
63
+ it('handles falsy / non-object input by returning fully-defaulted shape', () => {
64
+ expect(normalizeResume().basics).toEqual({});
65
+ expect(normalizeResume(null).work).toEqual([]);
66
+ });
67
+
68
+ it('does not mutate the input', () => {
69
+ const resume = { basics: { name: 'A' } };
70
+ const out = normalizeResume(resume);
71
+ expect(resume.work).toBeUndefined();
72
+ expect(out).not.toBe(resume);
73
+ });
74
+
75
+ it('preserves unknown extra fields', () => {
76
+ expect(normalizeResume({ custom: 1 }).custom).toBe(1);
77
+ });
78
+ });
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ safeUrl,
4
+ getLinkRel,
5
+ sanitizeHtml,
6
+ isExternalUrl,
7
+ formatUrlForDisplay,
8
+ } from '../url.js';
9
+
10
+ describe('safeUrl', () => {
11
+ it('passes through safe protocols', () => {
12
+ expect(safeUrl('https://example.com')).toBe('https://example.com');
13
+ expect(safeUrl('mailto:a@b.com')).toBe('mailto:a@b.com');
14
+ expect(safeUrl('tel:+123')).toBe('tel:+123');
15
+ });
16
+
17
+ it('blocks dangerous schemes (returns null)', () => {
18
+ expect(safeUrl('javascript:alert(1)')).toBeNull();
19
+ expect(safeUrl('data:text/html,<script>')).toBeNull();
20
+ expect(safeUrl('vbscript:msgbox')).toBeNull();
21
+ expect(safeUrl('file:///etc/passwd')).toBeNull();
22
+ expect(safeUrl('about:blank')).toBeNull();
23
+ });
24
+
25
+ it('keeps relative URLs as-is', () => {
26
+ expect(safeUrl('/about')).toBe('/about');
27
+ expect(safeUrl('./x')).toBe('./x');
28
+ });
29
+
30
+ it('prefixes https for bare www and bare domains', () => {
31
+ expect(safeUrl('www.example.com')).toBe('https://www.example.com');
32
+ expect(safeUrl('example.com')).toBe('https://example.com');
33
+ });
34
+
35
+ it('returns null for falsy / non-string input', () => {
36
+ expect(safeUrl('')).toBeNull();
37
+ expect(safeUrl(null)).toBeNull();
38
+ expect(safeUrl(undefined)).toBeNull();
39
+ expect(safeUrl(42)).toBeNull();
40
+ });
41
+
42
+ it('does not write to the console for blocked or uncertain URLs', () => {
43
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
44
+ safeUrl('javascript:alert(1)');
45
+ safeUrl('some uncertain value');
46
+ expect(spy).not.toHaveBeenCalled();
47
+ spy.mockRestore();
48
+ });
49
+ });
50
+
51
+ describe('getLinkRel', () => {
52
+ it('adds noopener noreferrer for http(s) opening in a new tab', () => {
53
+ expect(getLinkRel('https://example.com', true)).toBe('noopener noreferrer');
54
+ });
55
+
56
+ it('returns empty otherwise', () => {
57
+ expect(getLinkRel('https://example.com', false)).toBe('');
58
+ expect(getLinkRel('mailto:a@b.com', true)).toBe('');
59
+ expect(getLinkRel('', true)).toBe('');
60
+ });
61
+ });
62
+
63
+ describe('sanitizeHtml', () => {
64
+ it('escapes HTML-significant characters', () => {
65
+ expect(sanitizeHtml('<script>alert(1)</script>')).toBe(
66
+ '&lt;script&gt;alert(1)&lt;/script&gt;'
67
+ );
68
+ expect(sanitizeHtml(`"&'`)).toBe('&quot;&amp;&#039;');
69
+ });
70
+
71
+ it('returns empty string for falsy / non-string input', () => {
72
+ expect(sanitizeHtml('')).toBe('');
73
+ expect(sanitizeHtml(null)).toBe('');
74
+ });
75
+ });
76
+
77
+ describe('isExternalUrl', () => {
78
+ it('treats relative, hash, mailto, tel as not external', () => {
79
+ expect(isExternalUrl('/about')).toBe(false);
80
+ expect(isExternalUrl('#top')).toBe(false);
81
+ expect(isExternalUrl('mailto:a@b.com')).toBe(false);
82
+ });
83
+
84
+ it('compares origins when one is provided', () => {
85
+ expect(isExternalUrl('https://other.com/x', 'https://me.com')).toBe(true);
86
+ expect(isExternalUrl('https://me.com/x', 'https://me.com')).toBe(false);
87
+ });
88
+
89
+ it('returns false for falsy / non-string input', () => {
90
+ expect(isExternalUrl('')).toBe(false);
91
+ expect(isExternalUrl(null)).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe('formatUrlForDisplay', () => {
96
+ it('strips protocol and trailing slash', () => {
97
+ expect(formatUrlForDisplay('https://example.com/')).toBe('example.com');
98
+ expect(formatUrlForDisplay('http://example.com/blog')).toBe(
99
+ 'example.com/blog'
100
+ );
101
+ expect(formatUrlForDisplay('https://example.com///')).toBe('example.com');
102
+ });
103
+
104
+ it('leaves protocol-less URLs alone (minus trailing slash)', () => {
105
+ expect(formatUrlForDisplay('example.com/')).toBe('example.com');
106
+ });
107
+
108
+ it('returns empty string for falsy / non-string input', () => {
109
+ expect(formatUrlForDisplay('')).toBe('');
110
+ expect(formatUrlForDisplay(null)).toBe('');
111
+ expect(formatUrlForDisplay(123)).toBe('');
112
+ });
113
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import {
3
+ calculateVolunteerYears,
4
+ getUniqueIndustries,
5
+ getCurrentEmployer,
6
+ isCurrentlyEmployed,
7
+ } from '../metrics/workHistory.js';
8
+ import { work, volunteer, FIXED_NOW } from './fixtures.js';
9
+
10
+ beforeEach(() => {
11
+ vi.useFakeTimers();
12
+ vi.setSystemTime(FIXED_NOW);
13
+ });
14
+
15
+ afterEach(() => {
16
+ vi.useRealTimers();
17
+ });
18
+
19
+ describe('calculateVolunteerYears', () => {
20
+ it('sums volunteer tenure, skipping entries without a startDate', () => {
21
+ // 3 years at Code Club; Food Bank entry has no startDate
22
+ expect(calculateVolunteerYears(volunteer)).toBe(3);
23
+ });
24
+
25
+ it('uses the current date for ongoing roles (no endDate)', () => {
26
+ expect(calculateVolunteerYears([{ startDate: '2024-01-01' }])).toBe(2);
27
+ });
28
+
29
+ it('returns 0 for empty, missing, or non-array input', () => {
30
+ expect(calculateVolunteerYears([])).toBe(0);
31
+ expect(calculateVolunteerYears()).toBe(0);
32
+ expect(calculateVolunteerYears(null)).toBe(0);
33
+ expect(calculateVolunteerYears('not-an-array')).toBe(0);
34
+ });
35
+ });
36
+
37
+ describe('getUniqueIndustries', () => {
38
+ it('returns unique industries in insertion order, skipping missing ones', () => {
39
+ expect(getUniqueIndustries(work)).toEqual(['Technology', 'Finance']);
40
+ });
41
+
42
+ it('deduplicates repeated industries', () => {
43
+ expect(
44
+ getUniqueIndustries([
45
+ { industry: 'Technology' },
46
+ { industry: 'Technology' },
47
+ { industry: 'Healthcare' },
48
+ ])
49
+ ).toEqual(['Technology', 'Healthcare']);
50
+ });
51
+
52
+ it('returns an empty array for empty, missing, or non-array input', () => {
53
+ expect(getUniqueIndustries([])).toEqual([]);
54
+ expect(getUniqueIndustries()).toEqual([]);
55
+ expect(getUniqueIndustries(null)).toEqual([]);
56
+ });
57
+ });
58
+
59
+ describe('getCurrentEmployer', () => {
60
+ it('returns the first job without an endDate', () => {
61
+ expect(getCurrentEmployer(work)).toBe(work[0]);
62
+ });
63
+
64
+ it('finds the current job even when it is not first in the array', () => {
65
+ const current = { name: 'Now Co', startDate: '2024-01-01' };
66
+ expect(
67
+ getCurrentEmployer([
68
+ { name: 'Old Co', startDate: '2018-01-01', endDate: '2021-01-01' },
69
+ current,
70
+ ])
71
+ ).toBe(current);
72
+ });
73
+
74
+ it('falls back to the first entry when every job has ended', () => {
75
+ const jobs = [
76
+ { name: 'A', startDate: '2018-01-01', endDate: '2021-01-01' },
77
+ { name: 'B', startDate: '2016-01-01', endDate: '2018-01-01' },
78
+ ];
79
+ expect(getCurrentEmployer(jobs)).toBe(jobs[0]);
80
+ });
81
+
82
+ it('returns null for empty, missing, or non-array input', () => {
83
+ expect(getCurrentEmployer([])).toBeNull();
84
+ expect(getCurrentEmployer()).toBeNull();
85
+ expect(getCurrentEmployer(null)).toBeNull();
86
+ });
87
+ });
88
+
89
+ describe('isCurrentlyEmployed', () => {
90
+ it('returns true when any job has no endDate', () => {
91
+ expect(isCurrentlyEmployed(work)).toBe(true);
92
+ });
93
+
94
+ it('returns false when every job has ended', () => {
95
+ expect(
96
+ isCurrentlyEmployed([
97
+ { name: 'A', startDate: '2018-01-01', endDate: '2021-01-01' },
98
+ ])
99
+ ).toBe(false);
100
+ });
101
+
102
+ it('returns false for empty, missing, or non-array input', () => {
103
+ expect(isCurrentlyEmployed([])).toBe(false);
104
+ expect(isCurrentlyEmployed()).toBe(false);
105
+ expect(isCurrentlyEmployed(null)).toBe(false);
106
+ });
107
+ });