Skip to content

Commit ee6ab49

Browse files
SableRafclaude
andcommitted
Extract Plus Code logic into testable module with full test coverage
Moves `resolvePlusCode` and `isValidPlusCode` from `process-new-event-issue.mjs` into a standalone `plus-code.mjs` module, and adds `plus-code.test.mjs` with 22 tests covering validation, short-code recovery, ambiguous input rejection, and failure cases. Updates AGENTS.md with testing protocol and adds TEST.md inventory. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8d5cdd4 commit ee6ab49

5 files changed

Lines changed: 379 additions & 94 deletions

File tree

.github/scripts/plus-code.mjs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { createRequire } from 'node:module';
2+
3+
// OLC library path: CI installs to /tmp/script-deps, locally it's in pcd-website/node_modules
4+
const _require = createRequire(import.meta.url);
5+
export let olc = null;
6+
for (const candidate of [
7+
'/tmp/script-deps/node_modules/open-location-code/openlocationcode.js',
8+
new URL('../../pcd-website/node_modules/open-location-code/openlocationcode.js', import.meta.url).pathname,
9+
]) {
10+
try {
11+
const { OpenLocationCode } = _require(candidate);
12+
olc = new OpenLocationCode();
13+
break;
14+
} catch { /* try next */ }
15+
}
16+
17+
// OLC character set used in both validation and extraction regexes.
18+
const OLC = '[23456789CFGHJMPQRVWX]';
19+
const VALID_FULL_RE = new RegExp(`^${OLC}{8}\\+${OLC}{2,3}$`);
20+
21+
export function isValidPlusCode(value) {
22+
return VALID_FULL_RE.test(value.replace(/\s+/g, '').toUpperCase());
23+
}
24+
25+
// Match the full run of OLC chars after '+' as one block, then accept only if
26+
// the run is exactly 2–3 chars long. This prevents V9H4+MCPARIS from being
27+
// misread as V9H4+MCP: the full run "MCP…" is longer than 3, so no match.
28+
const EXTRACT_RE = new RegExp(
29+
`(${OLC}{2,8}\\+)(${OLC}+)`,
30+
'i',
31+
);
32+
33+
export async function resolvePlusCode(rawValue, city, country) {
34+
const normalized = rawValue.replace(/\s+/g, '').toUpperCase();
35+
36+
if (!normalized) return { code: null, note: null };
37+
38+
// Fast path: already a valid full global OLC
39+
if (VALID_FULL_RE.test(normalized)) return { code: normalized, note: null };
40+
41+
if (!olc) return { code: null, note: null };
42+
43+
// Try to extract a Plus Code from anywhere in the input (e.g. "My code: QX5Q+C5,DENVER").
44+
// No leading anchor so we match even with arbitrary prefix text.
45+
// Captures the full run of OLC chars after '+'; the suffix length check below
46+
// rejects cases where city chars bleed into the suffix (see suffix.length > 3).
47+
const match = normalized.match(EXTRACT_RE);
48+
49+
let shortCode = normalized;
50+
let locationHint = '';
51+
52+
if (match) {
53+
const [fullMatch, prefix, suffix] = match;
54+
55+
// Suffix must be exactly 2–3 OLC chars. A longer run means the city name bled
56+
// into the code (e.g. "V9H4+MCPARIS" → suffix "MCPARIS", length 7).
57+
if (suffix.length > 3) {
58+
console.log(`[plus-code] rejected "${rawValue}": suffix "${suffix}" is longer than 3 chars`);
59+
return { code: null, note: null };
60+
}
61+
62+
// Reject if the matched code is immediately followed by a word character.
63+
// Non-OLC chars in the city name can truncate the suffix early, e.g. V9H4+MCPARIS
64+
// matches as V9H4+MCP (stops at 'A') but 'ARIS' follows — the code is embedded
65+
// in a longer token and must be rejected.
66+
const afterCode = normalized.slice(match.index + fullMatch.length);
67+
if (/^\w/.test(afterCode)) {
68+
console.log(`[plus-code] rejected "${rawValue}": extracted "${fullMatch}" is followed by word chars "${afterCode}"`);
69+
return { code: null, note: null };
70+
}
71+
72+
shortCode = prefix + suffix;
73+
74+
// Extract any trailing non-OLC text as a location hint (e.g. ",DENVER,COLORADO").
75+
// Used only when the city/country form fields are empty.
76+
if (afterCode) {
77+
locationHint = afterCode
78+
.replace(/,/g, ' ')
79+
.trim()
80+
.toLowerCase()
81+
.replace(/\b\w/g, (c) => c.toUpperCase());
82+
}
83+
}
84+
85+
// Attempt recovery if we have a short OLC and a location reference.
86+
// Prefer explicit city/country fields; fall back to the hint extracted from the input.
87+
const locationRef = (city || country)
88+
? [city, country].filter(Boolean).join(' ')
89+
: locationHint;
90+
91+
if (olc.isShort(shortCode) && locationRef) {
92+
try {
93+
const query = encodeURIComponent(locationRef);
94+
const response = await fetch(
95+
`https://nominatim.openstreetmap.org/search?q=${query}&format=json&limit=1`,
96+
{ headers: { 'User-Agent': 'PCD-Event-Intake/1.0' } },
97+
);
98+
const results = await response.json();
99+
if (results.length > 0) {
100+
const { lat, lon } = results[0];
101+
const recovered = olc.recoverNearest(shortCode, parseFloat(lat), parseFloat(lon));
102+
if (VALID_FULL_RE.test(recovered)) {
103+
return { code: recovered, note: 'auto-recovered from short code + city' };
104+
}
105+
}
106+
} catch (err) {
107+
console.log(`[plus-code] recovery failed: ${err.message}`);
108+
}
109+
}
110+
111+
return { code: null, note: null };
112+
}

.github/scripts/plus-code.test.mjs

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { test, describe, beforeEach, afterEach } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { isValidPlusCode, resolvePlusCode, olc } from './plus-code.mjs';
4+
5+
// Paris reference: 145 rue La Fayette
6+
// Full code: 8FW4V9H4+MC, short: V9H4+MC, center ~48.879188, 2.356063
7+
const PARIS_FULL = '8FW4V9H4+MC';
8+
const PARIS_LAT = 48.879188;
9+
const PARIS_LON = 2.356063;
10+
11+
function makeNominatimFetch(lat, lon) {
12+
return async () => ({
13+
json: async () => [{ lat: String(lat), lon: String(lon) }],
14+
});
15+
}
16+
17+
function makeEmptyNominatimFetch() {
18+
return async () => ({ json: async () => [] });
19+
}
20+
21+
function makeThrowingFetch() {
22+
return async () => { throw new Error('network error'); };
23+
}
24+
25+
// ── isValidPlusCode ──────────────────────────────────────────────────────────
26+
27+
describe('isValidPlusCode', () => {
28+
test('valid full code', () => assert.equal(isValidPlusCode('8FW4V9H4+MC'), true));
29+
test('valid 3-char suffix', () => assert.equal(isValidPlusCode('8FW4V9H4+MC7'), true));
30+
test('trailing spaces normalized', () => assert.equal(isValidPlusCode('8FW4V9H4+MC '), true));
31+
test('lowercase normalized', () => assert.equal(isValidPlusCode('8fw4v9h4+mc'), true));
32+
test('short code → false', () => assert.equal(isValidPlusCode('V9H4+MC'), false));
33+
test('not a code → false', () => assert.equal(isValidPlusCode('NOTACODE'), false));
34+
test('empty string → false', () => assert.equal(isValidPlusCode(''), false));
35+
});
36+
37+
// ── resolvePlusCode — fast path ──────────────────────────────────────────────
38+
39+
describe('resolvePlusCode — fast path', () => {
40+
test('already valid full code', async () => {
41+
const r = await resolvePlusCode('8FW4V9H4+MC', '', '');
42+
assert.deepEqual(r, { code: '8FW4V9H4+MC', note: null });
43+
});
44+
45+
test('valid code with trailing spaces', async () => {
46+
const r = await resolvePlusCode('8FW4V9H4+MC ', '', '');
47+
assert.deepEqual(r, { code: '8FW4V9H4+MC', note: null });
48+
});
49+
50+
test('lowercase valid code', async () => {
51+
const r = await resolvePlusCode('8fw4v9h4+mc', '', '');
52+
assert.deepEqual(r, { code: '8FW4V9H4+MC', note: null });
53+
});
54+
});
55+
56+
// ── resolvePlusCode — short code recovery ────────────────────────────────────
57+
58+
describe('resolvePlusCode — short code recovery', () => {
59+
let originalFetch;
60+
61+
beforeEach(() => {
62+
originalFetch = globalThis.fetch;
63+
globalThis.fetch = makeNominatimFetch(PARIS_LAT, PARIS_LON);
64+
});
65+
66+
afterEach(() => {
67+
globalThis.fetch = originalFetch;
68+
});
69+
70+
function assertRecovered(result) {
71+
assert.equal(result.code, PARIS_FULL);
72+
assert.notEqual(result.note, null);
73+
if (olc) {
74+
const decoded = olc.decode(result.code);
75+
assert.ok(Math.abs(decoded.latitudeCenter - PARIS_LAT) < 0.001, `lat ${decoded.latitudeCenter} not near ${PARIS_LAT}`);
76+
assert.ok(Math.abs(decoded.longitudeCenter - PARIS_LON) < 0.001, `lon ${decoded.longitudeCenter} not near ${PARIS_LON}`);
77+
}
78+
}
79+
80+
test('short code + city/country fields', async () => {
81+
assertRecovered(await resolvePlusCode('V9H4+MC', 'Paris', 'France'));
82+
});
83+
84+
test('comma-separated city hint', async () => {
85+
assertRecovered(await resolvePlusCode('V9H4+MC,PARIS', '', ''));
86+
});
87+
88+
test('comma-separated city and country hint', async () => {
89+
assertRecovered(await resolvePlusCode('V9H4+MC,PARIS,FRANCE', '', ''));
90+
});
91+
92+
test('hint with spaces around commas (stripped)', async () => {
93+
assertRecovered(await resolvePlusCode('V9H4+MC, Paris, France', '', ''));
94+
});
95+
96+
test('arbitrary prefix text with comma separator', async () => {
97+
assertRecovered(await resolvePlusCode('My code: V9H4+MC,PARIS', '', ''));
98+
});
99+
100+
test('short code with no location ref → null', async () => {
101+
const r = await resolvePlusCode('V9H4+MC', '', '');
102+
assert.deepEqual(r, { code: null, note: null });
103+
});
104+
});
105+
106+
// ── resolvePlusCode — ambiguous input (city chars bleed into code) ───────────
107+
//
108+
// When city chars are valid OLC chars (e.g. P in PARIS), the extraction regex
109+
// absorbs them into the suffix. The result is an incorrect-but-valid-looking
110+
// code — this is a documented limitation. Tests assert the actual behavior so
111+
// future changes don't silently regress it.
112+
113+
describe('resolvePlusCode — ambiguous input (city chars bleed into suffix)', () => {
114+
let originalFetch;
115+
116+
beforeEach(() => {
117+
originalFetch = globalThis.fetch;
118+
globalThis.fetch = makeNominatimFetch(PARIS_LAT, PARIS_LON);
119+
});
120+
121+
afterEach(() => {
122+
globalThis.fetch = originalFetch;
123+
});
124+
125+
// V9H4+MCPARIS: regex extracts V9H4+MCP (stops at 'A'), but 'ARIS' immediately
126+
// follows the match — the code is embedded in a longer token, so we reject it.
127+
test('no separator — null (code embedded in city name)', async () => {
128+
const r = await resolvePlusCode('V9H4+MCPARIS', '', '');
129+
assert.equal(r.code, null, 'should reject code embedded in city name');
130+
});
131+
132+
test('no separator before city, comma before country — null (code embedded in city name)', async () => {
133+
// V9H4+MCPARIS,FRANCE → regex extracts V9H4+MCP, but 'ARIS' follows immediately.
134+
const r = await resolvePlusCode('V9H4+MCPARIS,FRANCE', '', '');
135+
assert.equal(r.code, null, 'should reject code embedded in city name even with country hint');
136+
});
137+
});
138+
139+
// ── resolvePlusCode — other failure cases ────────────────────────────────────
140+
141+
describe('resolvePlusCode — failure cases', () => {
142+
let originalFetch;
143+
144+
beforeEach(() => {
145+
originalFetch = globalThis.fetch;
146+
});
147+
148+
afterEach(() => {
149+
globalThis.fetch = originalFetch;
150+
});
151+
152+
test('not extractable input', async () => {
153+
globalThis.fetch = makeNominatimFetch(PARIS_LAT, PARIS_LON);
154+
const r = await resolvePlusCode('NOTACODE', 'Paris', 'France');
155+
assert.deepEqual(r, { code: null, note: null });
156+
});
157+
158+
test('Nominatim returns empty array', async () => {
159+
globalThis.fetch = makeEmptyNominatimFetch();
160+
const r = await resolvePlusCode('V9H4+MC', 'Paris', 'France');
161+
assert.deepEqual(r, { code: null, note: null });
162+
});
163+
164+
test('fetch throws', async () => {
165+
globalThis.fetch = makeThrowingFetch();
166+
const r = await resolvePlusCode('V9H4+MC', 'Paris', 'France');
167+
assert.deepEqual(r, { code: null, note: null });
168+
});
169+
170+
test('empty input', async () => {
171+
globalThis.fetch = makeNominatimFetch(PARIS_LAT, PARIS_LON);
172+
const r = await resolvePlusCode('', '', '');
173+
assert.deepEqual(r, { code: null, note: null });
174+
});
175+
});

0 commit comments

Comments
 (0)