Skip to content

Commit 1b3d681

Browse files
committed
refactor: move re-used functions into lib/ utilities
1 parent 3fdba67 commit 1b3d681

16 files changed

Lines changed: 867 additions & 574 deletions

File tree

web/package-lock.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
},
1818
"dependencies": {
1919
"@uwdata/vgplot": "^0.11.0",
20+
"papaparse": "^5.5.3",
2021
"three": "^0.184.0"
2122
},
2223
"devDependencies": {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
hashStr,
4+
normalizeRole,
5+
getInitials,
6+
getLastName,
7+
getFirstName,
8+
authorColor,
9+
CREDIT_ROLES,
10+
ROLE_GROUP,
11+
} from '../contributions/credit-helpers.js';
12+
13+
describe('hashStr', () => {
14+
it('returns a non-negative integer', () => expect(hashStr('test')).toBeGreaterThanOrEqual(0));
15+
it('is deterministic', () => expect(hashStr('abc')).toBe(hashStr('abc')));
16+
it('differs for different strings', () => expect(hashStr('a')).not.toBe(hashStr('b')));
17+
});
18+
19+
describe('normalizeRole', () => {
20+
it('lowercases and collapses whitespace', () => expect(normalizeRole('Formal analysis')).toBe('formal analysis'));
21+
it('converts em-dash to en-dash', () =>
22+
expect(normalizeRole('Writing \u2014 original draft')).toBe('writing \u2013 original draft'));
23+
});
24+
25+
describe('getInitials', () => {
26+
it('returns two-letter initials for full name', () => expect(getInitials('Alice Smith')).toBe('AS'));
27+
it('returns single letter for mononym', () => expect(getInitials('Mononym')).toBe('M'));
28+
it('uses first and last for three-part names', () => expect(getInitials('Alice B Smith')).toBe('AS'));
29+
});
30+
31+
describe('getLastName', () => {
32+
it('returns last word', () => expect(getLastName('Alice Smith')).toBe('Smith'));
33+
it('handles single name', () => expect(getLastName('Mononym')).toBe('Mononym'));
34+
});
35+
36+
describe('getFirstName', () => {
37+
it('returns first word', () => expect(getFirstName('Alice Smith')).toBe('Alice'));
38+
});
39+
40+
describe('authorColor', () => {
41+
it('returns an hsl string', () => {
42+
const color = authorColor({ name: 'Alice Smith', credit_levels: [{ role: 'Conceptualization', level: 'Lead' }] });
43+
expect(color).toMatch(/^hsl\(/);
44+
});
45+
it('is deterministic for the same name', () => {
46+
const a = { name: 'Test Author', credit_levels: [{ role: 'Software', level: 'Equal' }] };
47+
expect(authorColor(a)).toBe(authorColor(a));
48+
});
49+
it('handles author with no credit_levels', () => {
50+
expect(authorColor({ name: 'No Roles' })).toMatch(/^hsl\(/);
51+
});
52+
});
53+
54+
describe('CREDIT_ROLES', () => {
55+
it('has 14 entries', () => expect(CREDIT_ROLES).toHaveLength(14));
56+
it('contains expected roles', () => {
57+
expect(CREDIT_ROLES).toContain('Conceptualization');
58+
expect(CREDIT_ROLES).toContain('Software');
59+
expect(CREDIT_ROLES).toContain('Visualization');
60+
});
61+
});
62+
63+
describe('ROLE_GROUP', () => {
64+
it('maps normalized conceptualization to leadership', () =>
65+
expect(ROLE_GROUP[normalizeRole('Conceptualization')]).toBe('leadership'));
66+
it('maps normalized software to analysis', () => expect(ROLE_GROUP[normalizeRole('Software')]).toBe('analysis'));
67+
it('maps normalized methodology to methods', () => expect(ROLE_GROUP[normalizeRole('Methodology')]).toBe('methods'));
68+
it('maps normalized validation to data', () => expect(ROLE_GROUP[normalizeRole('Validation')]).toBe('data'));
69+
});
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { buildTableHead, buildFilterInput, buildPagingBar } from '../lib/paginated-table.js';
3+
4+
describe('buildTableHead', () => {
5+
it('generates header with sort arrows', () => {
6+
const html = buildTableHead(
7+
['name', 'email'],
8+
{ name: 'Name', email: 'Email' },
9+
'name',
10+
'asc',
11+
{ name: '', email: '' },
12+
[],
13+
{},
14+
40,
15+
[],
16+
);
17+
expect(html).toContain('Name ▲');
18+
expect(html).toContain('Email');
19+
expect(html).toContain('data-col="name"');
20+
expect(html).toContain('data-col="email"');
21+
});
22+
23+
it('skips filter inputs for specified columns', () => {
24+
const html = buildTableHead(
25+
['name', 'email'],
26+
{ name: 'Name', email: 'Email' },
27+
'name',
28+
'asc',
29+
{ name: '', email: '' },
30+
[],
31+
{},
32+
40,
33+
['email'],
34+
);
35+
expect(html).toContain('col-filter');
36+
expect(html.match(/col-filter/g).length).toBe(1);
37+
});
38+
39+
it('shows sort direction toggle', () => {
40+
const htmlAsc = buildTableHead(
41+
['name'],
42+
{ name: 'Name' },
43+
'name',
44+
'asc',
45+
{ name: '' },
46+
[],
47+
);
48+
const htmlDesc = buildTableHead(
49+
['name'],
50+
{ name: 'Name' },
51+
'name',
52+
'desc',
53+
{ name: '' },
54+
[],
55+
);
56+
expect(htmlAsc).toContain('▲');
57+
expect(htmlDesc).toContain('▼');
58+
});
59+
});
60+
61+
describe('buildFilterInput', () => {
62+
it('generates text input by default', () => {
63+
const html = buildFilterInput('name', 'test', []);
64+
expect(html).toContain('class="col-filter"');
65+
expect(html).toContain('data-col="name"');
66+
expect(html).toContain('value="test"');
67+
expect(html).toContain('type=');
68+
expect(html).toContain('text');
69+
});
70+
71+
it('forces select type when forceType is "select"', () => {
72+
const rows = [{ color: 'red' }, { color: 'blue' }];
73+
const html = buildFilterInput('color', 'red', rows, 'select');
74+
expect(html).toContain('<select');
75+
expect(html).toContain('selected');
76+
});
77+
78+
it('generates select when unique count below threshold', () => {
79+
const rows = Array.from({ length: 10 }, (_, i) => ({ status: i < 5 ? 'active' : 'inactive' }));
80+
const html = buildFilterInput('status', '', rows, undefined, 40);
81+
expect(html).toContain('<select');
82+
});
83+
84+
it('escapes HTML in column names', () => {
85+
const html = buildTableHead(
86+
['<script>'],
87+
{ '<script>': 'Label' },
88+
'',
89+
'asc',
90+
{ '<script>': '' },
91+
[],
92+
);
93+
expect(html).not.toContain('<script>');
94+
expect(html).toContain('&lt;script&gt;');
95+
});
96+
});
97+
98+
describe('buildPagingBar', () => {
99+
it('generates prev and next buttons', () => {
100+
const html = buildPagingBar(0, 100, 250);
101+
expect(html).toContain('id="prev-page"');
102+
expect(html).toContain('id="next-page"');
103+
});
104+
105+
it('disables prev button on first page', () => {
106+
const html = buildPagingBar(0, 100, 250);
107+
expect(html).toContain('id="prev-page" disabled');
108+
});
109+
110+
it('disables next button on last page', () => {
111+
const html = buildPagingBar(2, 100, 250);
112+
expect(html).toContain('id="next-page" disabled');
113+
});
114+
115+
it('displays correct info text', () => {
116+
const html = buildPagingBar(1, 100, 250);
117+
expect(html).toContain('101–200 of 250');
118+
});
119+
120+
it('handles empty results', () => {
121+
const html = buildPagingBar(0, 100, 0);
122+
expect(html).toContain('0–0 of 0');
123+
});
124+
});

web/src/__tests__/utils.test.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { escHtml, formatDatetime, formatDate, sortRows, uniqueValues, filterRows, PAGE_SIZE, SELECT_THRESHOLD } from '../lib/utils.js';
3+
4+
describe('escHtml', () => {
5+
it('escapes ampersands', () => expect(escHtml('a&b')).toBe('a&amp;b'));
6+
it('escapes less-than', () => expect(escHtml('a<b')).toBe('a&lt;b'));
7+
it('escapes greater-than', () => expect(escHtml('a>b')).toBe('a&gt;b'));
8+
it('escapes double quotes', () => expect(escHtml('a"b')).toBe('a&quot;b'));
9+
it('handles null', () => expect(escHtml(null)).toBe(''));
10+
it('handles undefined', () => expect(escHtml(undefined)).toBe(''));
11+
it('coerces numbers to string', () => expect(escHtml(42)).toBe('42'));
12+
});
13+
14+
describe('formatDatetime', () => {
15+
it('formats ISO UTC to YYYY-MM-DD HH:MM', () => expect(formatDatetime('2024-03-15T09:05:00Z')).toBe('2024-03-15 09:05'));
16+
it('returns empty for null', () => expect(formatDatetime(null)).toBe(''));
17+
it('returns raw string for invalid date', () => expect(formatDatetime('not-a-date')).toBe('not-a-date'));
18+
it('handles empty string', () => expect(formatDatetime('')).toBe(''));
19+
});
20+
21+
describe('formatDate', () => {
22+
it('formats ISO UTC to YYYY-MM-DD', () => expect(formatDate('2024-03-15T09:05:00Z')).toBe('2024-03-15'));
23+
it('returns empty for null', () => expect(formatDate(null)).toBe(''));
24+
it('returns raw string for invalid date', () => expect(formatDate('not-a-date')).toBe('not-a-date'));
25+
it('handles empty string', () => expect(formatDate('')).toBe(''));
26+
});
27+
28+
describe('sortRows', () => {
29+
it('sorts rows ascending by column', () => {
30+
const rows = [{ name: 'Bob', age: 30 }, { name: 'Alice', age: 25 }];
31+
sortRows(rows, 'name', 'asc');
32+
expect(rows[0].name).toBe('Alice');
33+
expect(rows[1].name).toBe('Bob');
34+
});
35+
36+
it('sorts rows descending by column', () => {
37+
const rows = [{ name: 'Bob', age: 30 }, { name: 'Alice', age: 25 }];
38+
sortRows(rows, 'name', 'desc');
39+
expect(rows[0].name).toBe('Bob');
40+
expect(rows[1].name).toBe('Alice');
41+
});
42+
43+
it('handles missing values as empty strings (sort to start in ascending)', () => {
44+
const rows = [{ name: 'Bob' }, { name: 'Alice' }, { age: 25 }];
45+
sortRows(rows, 'name', 'asc');
46+
// Missing 'name' property coerces to '', which sorts first
47+
expect(rows[0].age).toBe(25);
48+
expect(rows[0].name).toBeUndefined();
49+
expect(rows[1].name).toBe('Alice');
50+
expect(rows[2].name).toBe('Bob');
51+
});
52+
});
53+
54+
describe('uniqueValues', () => {
55+
it('returns unique values from a column', () => {
56+
const rows = [{ type: 'A' }, { type: 'B' }, { type: 'A' }, { type: 'C' }];
57+
const values = uniqueValues(rows, 'type');
58+
expect(values).toEqual(['A', 'B', 'C']);
59+
});
60+
61+
it('filters out null and empty values', () => {
62+
const rows = [{ type: 'A' }, { type: null }, { type: '' }, { type: 'B' }];
63+
const values = uniqueValues(rows, 'type');
64+
expect(values).toEqual(['A', 'B']);
65+
});
66+
67+
it('returns sorted values', () => {
68+
const rows = [{ type: 'Z' }, { type: 'A' }, { type: 'M' }];
69+
const values = uniqueValues(rows, 'type');
70+
expect(values).toEqual(['A', 'M', 'Z']);
71+
});
72+
});
73+
74+
describe('filterRows', () => {
75+
it('returns all rows when no filters are applied', () => {
76+
const rows = [{ name: 'Alice', age: 25 }, { name: 'Bob', age: 30 }];
77+
const result = filterRows(rows, {});
78+
expect(result).toEqual(rows);
79+
});
80+
81+
it('filters rows by a single column', () => {
82+
const rows = [{ name: 'Alice', age: 25 }, { name: 'Bob', age: 30 }];
83+
const result = filterRows(rows, { name: 'Alice' });
84+
expect(result).toHaveLength(1);
85+
expect(result[0].name).toBe('Alice');
86+
});
87+
88+
it('uses case-insensitive substring matching', () => {
89+
const rows = [{ name: 'Alice' }, { name: 'Alison' }, { name: 'Bob' }];
90+
const result = filterRows(rows, { name: 'ALI' });
91+
expect(result).toHaveLength(2);
92+
});
93+
94+
it('filters by multiple columns (AND condition)', () => {
95+
const rows = [
96+
{ name: 'Alice', age: 25 },
97+
{ name: 'Alice', age: 30 },
98+
{ name: 'Bob', age: 25 },
99+
];
100+
const result = filterRows(rows, { name: 'Alice', age: '25' });
101+
expect(result).toHaveLength(1);
102+
expect(result[0].name).toBe('Alice');
103+
});
104+
});
105+
106+
describe('Constants', () => {
107+
it('PAGE_SIZE is 100', () => expect(PAGE_SIZE).toBe(100));
108+
it('SELECT_THRESHOLD is 40', () => expect(SELECT_THRESHOLD).toBe(40));
109+
});

0 commit comments

Comments
 (0)