tripkit 1.2.0 → 1.4.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,190 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * End-to-end regression test for the post-trip media flow.
4
+ *
5
+ * Exercises the full lifecycle on a freshly-authored trip:
6
+ * author trip -> validate -> render (pre) [no media]
7
+ * -> generate geotagged fixtures
8
+ * -> `tripkit media` (build review) [EXIF match + buckets]
9
+ * -> review/caption + promote an unmatched item [simulates the human/AI step]
10
+ * -> `tripkit media apply` [merge into stops[].media[]]
11
+ * -> re-apply [idempotency]
12
+ * -> validate (errors vs warnings) -> render (post)
13
+ * -> assert the rendered HTML embeds the media + captions.
14
+ *
15
+ * Self-contained: builds fixtures in an OS temp dir and cleans up. `sharp` is
16
+ * optional — thumbnail assertions soft-pass (with a note) when it isn't installed,
17
+ * so this can run in `npm test` on a sharp-free CI.
18
+ *
19
+ * Run: node scripts/test-media-lifecycle.js (also wired into `npm test`)
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const os = require('os');
24
+ const path = require('path');
25
+ const { execFileSync } = require('child_process');
26
+
27
+ const ROOT = path.join(__dirname, '..');
28
+ const CONVERT = path.join(ROOT, 'convert.js');
29
+ const yaml = require(path.join(ROOT, 'node_modules', 'js-yaml'));
30
+ const { validate } = require(path.join(ROOT, 'validate'));
31
+
32
+ let sharp = null;
33
+ try { sharp = require(path.join(ROOT, 'node_modules', 'sharp')); } catch (_) { /* optional */ }
34
+ const HAVE_SHARP = !!sharp;
35
+
36
+ let failures = 0;
37
+ function check(name, cond, detail) {
38
+ const ok = !!cond;
39
+ console.log(` ${ok ? '✓' : '✗'} ${name}${ok ? '' : ` — ${detail || ''}`}`);
40
+ if (!ok) failures++;
41
+ }
42
+ function soft(name, cond, detail) {
43
+ if (!HAVE_SHARP) { console.log(` ⊘ ${name} (skipped — sharp not installed)`); return; }
44
+ check(name, cond, detail);
45
+ }
46
+ function tk(args) {
47
+ return execFileSync('node', [CONVERT, ...args], { env: { ...process.env, NO_COLOR: '1' }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
48
+ }
49
+
50
+ // --- Minimal JPEG with GPS + DateTimeOriginal EXIF (no external EXIF writer needed) ---
51
+ function buildExif({ lat, lng, dateStr }) {
52
+ const tiff = Buffer.alloc(8192);
53
+ let p = 0;
54
+ const u16 = (v) => { tiff.writeUInt16LE(v, p); p += 2; };
55
+ const u32 = (v) => { tiff.writeUInt32LE(v, p); p += 4; };
56
+ tiff.write('II', p); p += 2; tiff.writeUInt16LE(42, p); p += 2; tiff.writeUInt32LE(8, p); p += 4;
57
+ const dt = Buffer.from(dateStr + '\0', 'ascii');
58
+ const dms = (dec) => { const d = Math.floor(Math.abs(dec)); const mf = (Math.abs(dec) - d) * 60; const m = Math.floor(mf); const s = (mf - m) * 60; return [[d, 1], [m, 1], [Math.round(s * 100), 100]]; };
59
+ const rationals = (arr) => { const b = Buffer.alloc(arr.length * 8); arr.forEach((pr, i) => { b.writeUInt32LE(pr[0], i * 8); b.writeUInt32LE(pr[1], i * 8 + 4); }); return b; };
60
+ const latBuf = rationals(dms(lat)), lngBuf = rationals(dms(lng));
61
+ const latRef = (lat >= 0 ? 'N' : 'S'), lngRef = (lng >= 0 ? 'E' : 'W');
62
+ const IFD0_OFF = 8, IFD0_SIZE = 2 + 2 * 12 + 4;
63
+ const EXIF_OFF = IFD0_OFF + IFD0_SIZE, EXIF_SIZE = 2 + 1 * 12 + 4;
64
+ const GPS_OFF = EXIF_OFF + EXIF_SIZE, GPS_SIZE = 2 + 4 * 12 + 4;
65
+ const DATA_OFF = GPS_OFF + GPS_SIZE;
66
+ const dtOff = DATA_OFF, latOff = dtOff + dt.length, lngOff = latOff + latBuf.length;
67
+ p = IFD0_OFF; u16(2);
68
+ u16(0x8769); u16(4); u32(1); u32(EXIF_OFF);
69
+ u16(0x8825); u16(4); u32(1); u32(GPS_OFF); u32(0);
70
+ p = EXIF_OFF; u16(1); u16(0x9003); u16(2); u32(dt.length); u32(dtOff); u32(0);
71
+ p = GPS_OFF; u16(4);
72
+ u16(0x0001); u16(2); u32(2); { const sp = p; tiff.write(latRef, p, 'ascii'); p = sp + 4; }
73
+ u16(0x0002); u16(5); u32(3); u32(latOff);
74
+ u16(0x0003); u16(2); u32(2); { const sp = p; tiff.write(lngRef, p, 'ascii'); p = sp + 4; }
75
+ u16(0x0004); u16(5); u32(3); u32(lngOff); u32(0);
76
+ dt.copy(tiff, dtOff); latBuf.copy(tiff, latOff); lngBuf.copy(tiff, lngOff);
77
+ return Buffer.concat([Buffer.from('Exif\0\0', 'ascii'), tiff.subarray(0, lngOff + lngBuf.length)]);
78
+ }
79
+ // A tiny valid baseline JPEG (1x1) we can append EXIF to — avoids needing sharp to make fixtures.
80
+ const TINY_JPEG = Buffer.from(
81
+ '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a' +
82
+ 'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA' +
83
+ 'AAAAAAAAAAAAB//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AfwD/2Q==', 'base64');
84
+ function geoJpeg(file, meta) {
85
+ const exif = buildExif(meta);
86
+ const app1Len = exif.length + 2;
87
+ const app1 = Buffer.concat([Buffer.from([0xFF, 0xE1, (app1Len >> 8) & 0xff, app1Len & 0xff]), exif]);
88
+ fs.writeFileSync(file, Buffer.concat([TINY_JPEG.subarray(0, 2), app1, TINY_JPEG.subarray(2)]));
89
+ }
90
+ function plainJpeg(file) { fs.writeFileSync(file, TINY_JPEG); }
91
+
92
+ const TRIP_YAML = `
93
+ trip:
94
+ title: "Yosemite Weekend 2026"
95
+ dates: "May 15-16, 2026"
96
+ total_days: 2
97
+ total_stops: 4
98
+ origin: "Fresno, CA"
99
+ origin_lat: 36.7378
100
+ origin_lng: -119.7871
101
+ days:
102
+ - number: 1
103
+ title: "Valley Floor"
104
+ date: "Friday, May 15"
105
+ status: completed
106
+ color: "#2e7db5"
107
+ lodging: { name: "Yosemite Valley Lodge", lat: 37.7456, lng: -119.5967 }
108
+ stops:
109
+ - { name: "Lower Yosemite Fall", lat: 37.7561, lng: -119.5966, type: hike, navigate_url: "https://maps.google.com/?q=lyf" }
110
+ - { name: "Tunnel View", lat: 37.7159, lng: -119.6770, type: scenic, navigate_url: "https://maps.google.com/?q=tv" }
111
+ - number: 2
112
+ title: "Mariposa Grove"
113
+ date: "Saturday, May 16"
114
+ status: completed
115
+ color: "#2d7a50"
116
+ lodging: { name: "Home", lat: 36.7378, lng: -119.7871 }
117
+ stops:
118
+ - { name: "Mariposa Grove", lat: 37.5142, lng: -119.6010, type: hike, navigate_url: "https://maps.google.com/?q=mg" }
119
+ - { name: "Glacier Point", lat: 37.7275, lng: -119.5734, type: scenic, navigate_url: "https://maps.google.com/?q=gp" }
120
+ `;
121
+
122
+ function main() {
123
+ console.log(`media lifecycle e2e${HAVE_SHARP ? '' : ' (sharp absent — thumbnail checks skipped)'}`);
124
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), 'tripkit-media-'));
125
+ const TRIP = path.join(work, 'trip.yaml');
126
+ const MEDIA = path.join(work, 'media');
127
+ const REVIEW = path.join(work, 'trip.media-review.yaml');
128
+ try {
129
+ fs.writeFileSync(TRIP, TRIP_YAML);
130
+ fs.mkdirSync(MEDIA);
131
+
132
+ // PRE-TRIP
133
+ check('pre-trip validates clean', /is valid/.test(tk(['validate', TRIP])));
134
+ tk([TRIP, path.join(work, 'pre.html')]);
135
+ const preData = (() => { const h = fs.readFileSync(path.join(work, 'pre.html'), 'utf8'); return h.slice(h.indexOf('const TRIP_DATA ='), h.indexOf('END TRIP DATA')); })();
136
+ check('pre-trip TRIP_DATA has no media', !/"media"\s*:/.test(preData));
137
+
138
+ // FIXTURES (day-gated; May 15 = day 1, May 16 = day 2)
139
+ geoJpeg(path.join(MEDIA, 'fall_01.jpg'), { lat: 37.7560, lng: -119.5965, dateStr: '2026:05:15 10:15:00' });
140
+ geoJpeg(path.join(MEDIA, 'fall_02.jpg'), { lat: 37.7562, lng: -119.5968, dateStr: '2026:05:15 10:22:00' });
141
+ geoJpeg(path.join(MEDIA, 'tunnel_01.jpg'), { lat: 37.7160, lng: -119.6771, dateStr: '2026:05:15 16:45:00' });
142
+ geoJpeg(path.join(MEDIA, 'sequoia_01.jpg'), { lat: 37.5141, lng: -119.6011, dateStr: '2026:05:16 09:30:00' });
143
+ geoJpeg(path.join(MEDIA, 'random_sf.jpg'), { lat: 37.7749, lng: -122.4194, dateStr: '2026:05:16 12:00:00' }); // too far
144
+ plainJpeg(path.join(MEDIA, 'no_gps.jpg')); // no GPS
145
+
146
+ // INGEST
147
+ tk(['media', MEDIA, TRIP]);
148
+ const review = yaml.load(fs.readFileSync(REVIEW, 'utf8'));
149
+ check('review total = 6', review.summary.total === 6, JSON.stringify(review.summary));
150
+ check('review matched = 4', review.summary.matched === 4, JSON.stringify(review.summary));
151
+ check('review unmatched = 2', review.summary.unmatched === 2, JSON.stringify(review.summary));
152
+ const fall = review.stops.find(s => s.stop === 'Lower Yosemite Fall');
153
+ check('Lower Yosemite Fall = 2 photos, day 1 idx 0', fall && fall.media.length === 2 && fall.day === 1 && fall.stop_index === 0, JSON.stringify(fall && { n: fall.media.length, d: fall.day, i: fall.stop_index }));
154
+ check('Mariposa matched on day 2', !!review.stops.find(s => /Mariposa/.test(s.stop) && s.day === 2));
155
+ check('SF photo unmatched (too far)', review.unmatched.some(u => u.src.includes('random_sf')));
156
+ check('no-GPS photo unmatched', review.unmatched.some(u => u.src.includes('no_gps') && /no GPS/i.test(u.reason)));
157
+ soft('thumbnails generated', fs.existsSync(path.join(MEDIA, 'thumb', 'fall_01.jpg')));
158
+ soft('review item carries thumb path', fall && /thumb\//.test(fall.media[0].thumb || ''));
159
+
160
+ // REVIEW STEP: caption everything; promote SF photo onto Glacier Point (day 2 idx 1)
161
+ review.stops.forEach(s => s.media.forEach(m => { m.caption = `Photo at ${s.stop}`; }));
162
+ const sf = review.unmatched.find(u => u.src.includes('random_sf'));
163
+ sf.day = 2; sf.stop_index = 1; sf.caption = 'Manually placed';
164
+ fs.writeFileSync(REVIEW, yaml.dump(review));
165
+
166
+ // APPLY + idempotency
167
+ check('apply reports items applied', /Applied [1-9]\d* media/.test(tk(['media', 'apply', REVIEW, TRIP])));
168
+ const after = yaml.load(fs.readFileSync(TRIP, 'utf8'));
169
+ check('Lower Yosemite Fall has 2 media', (after.days[0].stops[0].media || []).length === 2);
170
+ check('captions merged', after.days[0].stops[0].media[0].caption === 'Photo at Lower Yosemite Fall');
171
+ check('promoted SF photo on Glacier Point', (after.days[1].stops[1].media || []).some(m => /random_sf/.test(m.src)));
172
+ check('apply is idempotent', /Applied 0 media/.test(tk(['media', 'apply', REVIEW, TRIP])));
173
+
174
+ // POST-TRIP: validate (no errors; far-GPS warning expected) + render
175
+ const { errors, warnings } = validate(yaml.load(fs.readFileSync(TRIP, 'utf8')));
176
+ check('post-trip has no validation errors', errors.length === 0, JSON.stringify(errors));
177
+ check('post-trip warns on far manual placement', warnings.some(w => /media GPS is \d+ mi/.test(w.message)), JSON.stringify(warnings));
178
+ tk([TRIP, path.join(work, 'post.html')]);
179
+ const post = fs.readFileSync(path.join(work, 'post.html'), 'utf8');
180
+ check('post render embeds media src', post.includes('fall_01.jpg'));
181
+ check('post render embeds caption', post.includes('Photo at Lower Yosemite Fall'));
182
+
183
+ console.log(failures === 0 ? '✓ media lifecycle e2e passed' : `✖ media lifecycle e2e: ${failures} assertion(s) failed`);
184
+ } finally {
185
+ fs.rmSync(work, { recursive: true, force: true });
186
+ }
187
+ process.exit(failures === 0 ? 0 : 1);
188
+ }
189
+
190
+ main();
package/validate.js CHANGED
@@ -6,6 +6,9 @@
6
6
  */
7
7
 
8
8
  const STOP_TYPES = new Set(['hike', 'scenic', 'food', 'city', 'activity', 'beach', 'museum', 'shopping']);
9
+ const MEDIA_TYPES = new Set(['photo', 'video']);
10
+ // Distance above which a media item's own GPS looks suspiciously far from its stop.
11
+ const MEDIA_FAR_MILES = 25;
9
12
  const DAY_STATUS = new Set(['completed', 'active', 'upcoming']);
10
13
  const MAP_STYLES = new Set(['terrain', 'satellite', 'topo', 'street']);
11
14
  const HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
@@ -47,6 +50,29 @@ function dayStartAnchor(d) {
47
50
  // Threshold above which a single straight-line polyline segment looks visually disjointed.
48
51
  const LONG_LEG_MILES = 250;
49
52
 
53
+ // Validate an optional media[] array (on a stop or lodging). `anchor` is the
54
+ // [lat, lng] of the owning stop/lodging, used to flag wildly-off media GPS.
55
+ function validateMedia(media, basePath, anchor, err, warn) {
56
+ if (media == null) return;
57
+ if (!Array.isArray(media)) { err(`${basePath}.media`, 'must be an array when present'); return; }
58
+ media.forEach((m, mi) => {
59
+ const mp = `${basePath}.media[${mi}]`;
60
+ if (!m || typeof m !== 'object') { err(mp, 'must be an object'); return; }
61
+ if (!isNonEmptyString(m.src)) err(`${mp}.src`, 'required, non-empty string (relative path or URL)');
62
+ if (m.type != null && !MEDIA_TYPES.has(m.type)) {
63
+ err(`${mp}.type`, `must be one of ${[...MEDIA_TYPES].join(', ')}; got "${m.type}"`);
64
+ }
65
+ if (m.lat != null && (!isFiniteNumber(m.lat) || m.lat < -90 || m.lat > 90)) err(`${mp}.lat`, `must be a number in [-90, 90]; got ${m.lat}`);
66
+ if (m.lng != null && (!isFiniteNumber(m.lng) || m.lng < -180 || m.lng > 180)) err(`${mp}.lng`, `must be a number in [-180, 180]; got ${m.lng}`);
67
+ if (anchor && isFiniteNumber(m.lat) && isFiniteNumber(m.lng)) {
68
+ const miles = distanceMiles(anchor[0], anchor[1], m.lat, m.lng);
69
+ if (miles > MEDIA_FAR_MILES) {
70
+ warn(`${mp}`, `media GPS is ${Math.round(miles)} mi from its stop — likely matched to the wrong stop`);
71
+ }
72
+ }
73
+ });
74
+ }
75
+
50
76
  function validate(data) {
51
77
  const errors = [];
52
78
  const warnings = [];
@@ -132,6 +158,8 @@ function validate(data) {
132
158
  if (s.kid_friendly != null && typeof s.kid_friendly !== 'boolean') {
133
159
  err(`${sp}.kid_friendly`, 'must be boolean');
134
160
  }
161
+ const anchor = isFiniteNumber(s.lat) && isFiniteNumber(s.lng) ? [s.lat, s.lng] : null;
162
+ validateMedia(s.media, sp, anchor, err, warn);
135
163
  });
136
164
  }
137
165
  }
@@ -153,6 +181,8 @@ function validate(data) {
153
181
  if (d.lodging.booked && !isHome && !isNonEmptyString(d.lodging.confirmation)) {
154
182
  warn(`${lp}.confirmation`, 'lodging is marked booked but confirmation is empty');
155
183
  }
184
+ const lodgeAnchor = isFiniteNumber(lat) && isFiniteNumber(lng) ? [lat, lng] : null;
185
+ validateMedia(d.lodging.media, lp, lodgeAnchor, err, warn);
156
186
  }
157
187
  });
158
188