Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 65 additions & 11 deletions src/parks/oceanpark/__tests__/oceanpark.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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"
Expand Down Expand Up @@ -1597,23 +1649,23 @@ 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'));

const live = await probe.liveData();
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']}],
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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'));

Expand Down Expand Up @@ -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();
});
});
46 changes: 26 additions & 20 deletions src/parks/oceanpark/oceanpark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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<HTTPObj> {
return {
Expand Down Expand Up @@ -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;
Expand All @@ -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 : [];
}

Expand Down Expand Up @@ -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) => {
Expand All @@ -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
Expand Down
Loading