diff --git a/src/parks/oceanpark/__tests__/oceanpark.test.ts b/src/parks/oceanpark/__tests__/oceanpark.test.ts index 2516b9aac..bb585528a 100644 --- a/src/parks/oceanpark/__tests__/oceanpark.test.ts +++ b/src/parks/oceanpark/__tests__/oceanpark.test.ts @@ -1528,20 +1528,43 @@ describe('buildLiveData', () => { expect(showtimes[0].startTime).toContain('19:00:00+08:00'); }); - test('a numeric queueTime maps to OPERATING with that standby wait', async () => { + // The site's per-ride queue text behaves like a coarse flag, not a measured + // queue, so it is used as an open/closed signal and never published as a + // wait time. + test('a numeric queueTime maps to OPERATING and publishes no queue', async () => { const probe = new Probe({attractions: [mkAttraction({queueTime: {text: ' 10 mins'}})]}); const live = await probe.liveData(); const entry = live.find((l) => l.id === 'attraction_node-1'); expect(entry.status).toBe('OPERATING'); - expect(entry.queue.STANDBY.waitTime).toBe(10); + expect(entry.queue).toBeUndefined(); }); - test('a "0 mins" queueTime is OPERATING with a zero wait, not CLOSED', async () => { + test('a "0 mins" queueTime is OPERATING with no queue, not CLOSED', async () => { const probe = new Probe({attractions: [mkAttraction({queueTime: {text: ' 0 mins'}})]}); const live = await probe.liveData(); const entry = live.find((l) => l.id === 'attraction_node-1'); expect(entry.status).toBe('OPERATING'); - expect(entry.queue.STANDBY.waitTime).toBe(0); + expect(entry.queue).toBeUndefined(); + }); + + test.each([' 0 mins', ' 5 mins', ' 10 mins', ' 15 mins', '25 mins'])( + 'queueTime %j yields exactly an id and an OPERATING status, with no queue key at all', + async (text) => { + const probe = new Probe({attractions: [mkAttraction({queueTime: {text}})]}); + const live = await probe.liveData(); + const entry = live.find((l) => l.id === 'attraction_node-1'); + expect(entry).toEqual({id: 'attraction_node-1', status: 'OPERATING'}); + expect('queue' in entry).toBe(false); + }, + ); + + test('status does not depend on the minute value: 0 and 5 give the same status', async () => { + const statusFor = async (text: string) => { + const probe = new Probe({attractions: [mkAttraction({queueTime: {text}})]}); + return (await probe.liveData()).find((l) => l.id === 'attraction_node-1').status; + }; + expect(await statusFor(' 0 mins')).toBe(await statusFor(' 5 mins')); + expect(await statusFor(' 5 mins')).toBe('OPERATING'); }); test('an explicit null queueTime (no queue mechanic / no signal) is CLOSED with no queue', async () => { @@ -1552,6 +1575,35 @@ describe('buildLiveData', () => { expect(entry.queue).toBeUndefined(); }); + test.each([ + ['a missing queueTime', undefined], + ['a queueTime with no text', {}], + ['a queueTime with null text', {text: null}], + ['a queueTime whose text carries no minutes', {text: 'Closed'}], + ])('%s is CLOSED with no queue', async (_label, queueTime) => { + const probe = new Probe({attractions: [mkAttraction({queueTime})]}); + const live = await probe.liveData(); + const entry = live.find((l) => l.id === 'attraction_node-1'); + expect(entry).toEqual({id: 'attraction_node-1', status: 'CLOSED'}); + }); + + test('a mixed listing keeps per-ride status and publishes no queue for any ride', async () => { + const probe = new Probe({ + attractions: [ + mkAttraction({nodeId: 'a', queueTime: {text: ' 5 mins'}}), + mkAttraction({nodeId: 'b', queueTime: {text: ' 0 mins'}}), + mkAttraction({nodeId: 'c', queueTime: null}), + ], + }); + const rides = (await probe.liveData()).filter((l) => l.id.startsWith('attraction_')); + expect(rides.map((r) => [r.id, r.status])).toEqual([ + ['attraction_a', 'OPERATING'], + ['attraction_b', 'OPERATING'], + ['attraction_c', 'CLOSED'], + ]); + expect(rides.some((r) => 'queue' in r)).toBe(false); + }); + test('a show with only future showtimes today is OPERATING and lists them', async () => { const probe = new Probe({ scheduleItems: [{title: 'All Star Jam', timeSlot: ['13:00:00']}], // 1pm, after our noon "now" @@ -1597,7 +1649,7 @@ describe('buildLiveData', () => { expect(new Set(ids).size).toBe(ids.length); // no duplicate ids in the emitted array }); - test('a shows-fetch failure degrades to no show live data without losing attraction wait times', async () => { + test('a shows-fetch failure degrades to no show live data without losing attraction status', async () => { const probe = new Probe({attractions: [mkAttraction({queueTime: {text: ' 10 mins'}})]}); (probe as any).getDailyScheduleItems = () => Promise.reject(new Error('daily schedule route down')); @@ -1605,15 +1657,15 @@ describe('buildLiveData', () => { expect(live.find((l) => l.id.startsWith('show_'))).toBeUndefined(); const attraction = live.find((l) => l.id === 'attraction_node-1'); expect(attraction.status).toBe('OPERATING'); - expect(attraction.queue.STANDBY.waitTime).toBe(10); + expect(attraction.queue).toBeUndefined(); }); - test('a range timeSlot does not reject the whole live-data build and take wait times with it', async () => { + test('a range timeSlot does not reject the whole live-data build and take ride status with it', async () => { // Regression: Ocean Park's halloween-2026 tab publishes a window // ("11:00:00-17:00:00") in timeSlot instead of a start time. Handed // straight to constructDateTime() that produced an Invalid Date and a // RangeError that rejected buildLiveData() outright, so one event item - // zeroed out every attraction wait time as well as every show. + // zeroed out every attraction status as well as every show. const probe = new Probe({ attractions: [mkAttraction({queueTime: {text: ' 10 mins'}})], scheduleItems: [{title: 'Bulu Boo Trick-or-Treat Party', timeSlot: ['11:00:00-17:00:00']}], @@ -1622,7 +1674,7 @@ describe('buildLiveData', () => { const live = await probe.liveData(); const attraction = live.find((l) => l.id === 'attraction_node-1'); expect(attraction.status).toBe('OPERATING'); - expect(attraction.queue.STANDBY.waitTime).toBe(10); + expect(attraction.queue).toBeUndefined(); }); test('a range timeSlot still running is OPERATING with both a start and an end time', async () => { @@ -1659,7 +1711,7 @@ describe('buildLiveData', () => { expect(entry.showtimes[0].startTime.startsWith('2026-07-07T13:00')).toBe(true); }); - test('an attractions-fetch failure degrades to no attraction wait times without losing show live data', async () => { + test('an attractions-fetch failure degrades to no attraction status without losing show live data', async () => { const probe = new Probe({scheduleItems: [{title: 'All Star Jam', timeSlot: ['13:00:00']}]}); (probe as any).getAttractionItems = () => Promise.reject(new Error('attractions page down')); @@ -1927,6 +1979,8 @@ describe('show roster window', () => { }); const live = await probe.liveData(); expect(liveShows(live)).toEqual([]); - expect(live.find((l) => l.id === 'attraction_node-1').queue.STANDBY.waitTime).toBe(10); + const attraction = live.find((l) => l.id === 'attraction_node-1'); + expect(attraction.status).toBe('OPERATING'); + expect(attraction.queue).toBeUndefined(); }); }); diff --git a/src/parks/oceanpark/oceanpark.ts b/src/parks/oceanpark/oceanpark.ts index e80704abd..0ee2e4513 100644 --- a/src/parks/oceanpark/oceanpark.ts +++ b/src/parks/oceanpark/oceanpark.ts @@ -5,7 +5,7 @@ * along with the app itself — every endpoint now returns a permanent 502 from * Ocean Park's own gateway. All data is instead scraped from the public website * (www.oceanpark.com.hk), which server-renders attraction/dining data — - * including live wait times — as an embedded React Server Component (RSC) JSON + * including a per-ride queue figure — as an embedded React Server Component (RSC) JSON * payload. No auth, no token, same page anyone gets in a browser. * * Entity IDs changed as a result: the app's numeric IDs don't exist in the @@ -535,7 +535,12 @@ export function groupShowsBySlug( * Parse "10 mins" / " 0 mins" style queue text into minutes. Requires the * number to actually be adjacent to "min" so incidental digits elsewhere in * a status string (e.g. a future "Reopens at 5pm") can't be misread as a - * wait time. + * minute figure. + * + * The figure is a presence signal only. The site's queue text moves in coarse + * steps (mostly 0 and 5) and does not track the line, so callers use + * "is there a figure" to decide open or closed and never publish the number as + * a wait time. * * Returns null for missing/unparseable text — the website uses an explicit * `null` queueTime for attractions with no queue mechanic (walkthroughs, @@ -558,7 +563,7 @@ export function parseQueueMinutes(text: string | null | undefined): number | nul * halloween-2026 tab's Bulu Boo Trick-or-Treat Party), which is a range, not * a start time. Handing that string straight to constructDateTime() built an * Invalid Date and threw a bare RangeError out of buildLiveData(), so one - * event item took every attraction wait time down with it on every sync. + * event item took every attraction status down with it on every sync. * * Returns null for anything that is not a valid time or time range, so no * unvalidated feed text ever reaches date construction. A range whose end is @@ -791,7 +796,7 @@ export class OceanParkHongKong extends Destination { // ── HTTP Fetch Methods ──────────────────────────────────────────────────── - /** SSR attractions listing — embeds live queue times. Short cache since wait times refresh often. */ + /** SSR attractions listing — embeds a per-ride queue figure, used as the open/closed signal. Short cache since it refreshes often. */ @http({cacheSeconds: 60}) async fetchAttractionsPage(): Promise { return { @@ -869,7 +874,7 @@ export class OceanParkHongKong extends Destination { const html = await resp.text(); const items = extractRscArray(html, '"items":', firstItemHasKey('nodeId')) as OceanParkAttractionItem[] | null; if (!items) { - console.warn('[OceanPark] attractions page RSC data not found; entity list and wait times will be empty this cycle'); + console.warn('[OceanPark] attractions page RSC data not found; entity list and ride status will be empty this cycle'); return []; } return items; @@ -895,7 +900,7 @@ export class OceanParkHongKong extends Destination { // serving `items` as an object, a number or a bare `0`, and `0 ?? []` // keeps the zero. Both builders iterate this, and buildLiveData does so // after its per-source catch has already run, so a non-array here takes - // every attraction wait time down with it. + // every attraction status down with it. return Array.isArray(body?.items) ? body.items : []; } @@ -1228,7 +1233,7 @@ export class OceanParkHongKong extends Destination { // answer, a failed request is not. const [attractionItems, scheduleItems, showRoster] = await Promise.all([ this.getAttractionItems().catch((err: unknown) => { - console.warn(`[OceanPark] attractions fetch failed (${errMsg(err)}); no attraction wait times this cycle`); + console.warn(`[OceanPark] attractions fetch failed (${errMsg(err)}); no attraction status this cycle`); return [] as OceanParkAttractionItem[]; }), this.getDailyScheduleItems(today).catch((err: unknown) => { @@ -1244,20 +1249,21 @@ export class OceanParkHongKong extends Destination { const liveData: LiveData[] = []; for (const item of attractionItems.filter(isValidAttractionItem)) { - const wt = parseQueueMinutes(item.queueTime?.text); - - const ld: LiveData = { + // The website gives no explicit open/closed flag — queueTime is the + // only live signal. Its minute figure is not a measured queue: it moves + // in coarse steps (mostly 0 and 5) and does not track the + // line, so it is read only for whether a figure is present and is never + // published as a wait time. Any figure, 0 included, means the + // attraction is running. No figure (null) means either "no queue + // mechanic" or "currently closed" and we can't tell which, so it falls + // through to CLOSED rather than fabricating an OPERATING status with no + // evidence behind it. + const hasQueueReading = parseQueueMinutes(item.queueTime?.text) !== null; + + liveData.push({ id: `attraction_${item.nodeId}`, - // The website gives no explicit open/closed flag — queueTime is the - // only live signal. No signal (null) means either "no queue - // mechanic" or "currently closed" and we can't tell which, so it - // falls through to CLOSED rather than fabricating an OPERATING - // status with no evidence behind it. - status: wt !== null ? 'OPERATING' : 'CLOSED', - } as LiveData; - - if (wt !== null) ld.queue = {STANDBY: {waitTime: wt}}; - liveData.push(ld); + status: hasQueueReading ? 'OPERATING' : 'CLOSED', + } as LiveData); } // Shows — group today's programme entries by slug (same grouping