diff --git a/src/parks/seaworld/__tests__/seaworld.test.ts b/src/parks/seaworld/__tests__/seaworld.test.ts index 3a1bad8b6..e6c01f951 100644 --- a/src/parks/seaworld/__tests__/seaworld.test.ts +++ b/src/parks/seaworld/__tests__/seaworld.test.ts @@ -19,6 +19,7 @@ import { SesamePlaceSanDiego, aslBaseName, mapAslShowsToBase, + mapShowAliases, ASL_SHOWTIME_TYPE, } from '../seaworld.js'; @@ -1583,6 +1584,86 @@ describe('ASL-interpreted performances', () => { expect(live.find((r: any) => r.id === 'asl-fiends')).toBeUndefined(); }); + describe('same-name shows', () => { + // Real 2026-09-27 shapes: SeaWorld Orlando publishes "Sea Lions: Flippers, Facts & + // Fun" twice (one copy with the day's showtimes, one with none) and Sesame Place + // Philadelphia "Meet Dinger" three times, nothing else telling them apart. + const DUP_SHOWS = [ + {Id: 'b5efd31b', Name: 'Sea Lions: Flippers, Facts & Fun', Type: 'Shows'}, + {Id: '774ef09d', Name: 'Sea Lions: Flippers, Facts & Fun', Type: 'Shows'}, + {Id: 'asl-sl', Name: 'Sea Lions: Flippers, Facts & Fun - ASL Saturday', Type: 'Shows'}, + {Id: 'd46174e2', Name: 'Meet Dinger', Type: 'Shows'}, + {Id: 'abb187b7', Name: 'Meet Dinger', Type: 'Shows'}, + {Id: 'b8be46b3', Name: 'MEET DINGER!', Type: 'Shows'}, + {Id: 'solo', Name: 'Pets Rule!', Type: 'Shows'}, + ]; + + function dup(showRows: any[], waitRows: any[] = [], hours: any[] = []) { + const park = new BuschGardensWilliamsburg(); + const mainId = (park as any).resortIds[0]; + (park as any).getParkDetail = async (id: string) => ({ + Id: id, park_Name: 'P', TimeZone: 'America/New_York', + POIs: id === mainId ? {Shows: DUP_SHOWS} : {Shows: []}, open_hours: id === mainId ? hours : [], + }) as any; + (park as any).getAvailability = async (id: string) => + (id === mainId ? {WaitTimes: waitRows, ShowTimes: showRows} : {WaitTimes: [], ShowTimes: []}) as any; + return park; + } + + it('maps every copy but the lowest id to the lowest id, and ASL to that canonical id', () => { + expect(mapShowAliases(DUP_SHOWS)).toEqual({ + asl: {'asl-sl': '774ef09d'}, + duplicate: {'b5efd31b': '774ef09d', 'd46174e2': 'abb187b7', 'b8be46b3': 'abb187b7'}, + }); + }); + + it('is stable whatever order the feed lists the copies in', () => { + const reversed = [...DUP_SHOWS].reverse(); + expect(mapShowAliases(reversed)).toEqual(mapShowAliases(DUP_SHOWS)); + }); + + it('emits one entity per show name, on the canonical id', async () => { + const ids = (await (dup([]) as any).buildEntityList()).filter((e: any) => e.entityType === 'SHOW').map((e: any) => e.id); + expect(ids.sort()).toEqual(['774ef09d', 'abb187b7', 'solo']); + }); + + it('merges a copy\'s showtimes into the canonical show, once each', async () => { + const a = slot('2026-09-27T11:30:00', '2026-09-27T12:00:00'); + const b = slot('2026-09-27T14:00:00', '2026-09-27T14:30:00'); + const live = await (dup([ + {Id: 'b5efd31b', ShowTimes: [a]}, + {Id: '774ef09d', ShowTimes: [a, b]}, // the same 11:30 slot on both copies + {Id: 'asl-sl', ShowTimes: [a]}, // and its interpreted performance + ]) as any).buildLiveData(); + expect(live.find((r: any) => r.id === 'b5efd31b')).toBeUndefined(); + const sl = live.find((r: any) => r.id === '774ef09d'); + expect(sl.showtimes.map((t: any) => [t.startTime.slice(11, 16), t.type])).toEqual([ + ['11:30', 'Performance'], + ['11:30', ASL_SHOWTIME_TYPE], + ['14:00', 'Performance'], + ]); + }); + + it('never merges the same name across two parks of one destination', async () => { + const park = new BuschGardensWilliamsburg(); + const [main, water] = (park as any).resortIds; + (park as any).getParkDetail = async (id: string) => ({ + Id: id, park_Name: id, TimeZone: 'America/New_York', open_hours: [], + POIs: {Shows: [{Id: id === main ? 'z-in-main' : 'a-in-water', Name: 'Character Meet', Type: 'Shows'}]}, + }) as any; + const ents = (await (park as any).buildEntityList()).filter((e: any) => e.entityType === 'SHOW'); + expect(ents.map((e: any) => [e.id, e.parentId]).sort()).toEqual([['a-in-water', water], ['z-in-main', main]]); + }); + + it('ignores a copy\'s closure instead of shutting the canonical show', async () => { + const live = await (dup([], [ + {Id: 'b5efd31b', Minutes: 0, Status: 'Closed For The Day', StatusDisplay: null, Title: 'x', LastUpDateTime: '2026-09-27T08:00:00'}, + ]) as any).buildLiveData(); + expect(live.find((r: any) => r.id === 'b5efd31b')).toBeUndefined(); + expect(live.find((r: any) => r.id === '774ef09d')).toBeUndefined(); // no row invented either + }); + }); + describe('closures, with the park open', () => { // Every test above runs with no operating hours, so the park never reads as // open and every show is CLOSED regardless of the closure logic. These pin diff --git a/src/parks/seaworld/seaworld.ts b/src/parks/seaworld/seaworld.ts index 45625df37..0b20ff2ba 100644 --- a/src/parks/seaworld/seaworld.ts +++ b/src/parks/seaworld/seaworld.ts @@ -116,20 +116,53 @@ function showNameKey(name: string): string { /** * For one park's show POIs: ASL listing id -> base show id, for every ASL * listing whose base show is in the same list. JSON-safe (plain object). + * When the base show itself is duplicated, the listing maps to its canonical id. */ export function mapAslShowsToBase(shows: Array<{Id: string; Name: string}>): Record { - const byKey = new Map(); + return mapShowAliases(shows).asl; +} + +/** + * Show POIs that are not entities of their own, for one park: + * + * - `asl`: ASL listing id -> base show id (see aslBaseName). Their performances + * are folded into the base show as ASL_SHOWTIME_TYPE. + * - `duplicate`: id -> canonical id, for shows the feed publishes more than once + * under the same name. SeaWorld Orlando lists "Sea Lions: Flippers, Facts & + * Fun" twice and Sesame Place Philadelphia lists "Meet Dinger" three times, + * with nothing to tell the copies apart (same category and subtype, no tag, + * no coordinates); one copy carries the day's showtimes, another none. The + * canonical copy is the lowest id, which stays the same for as long as the + * copies exist, so the entity id does not move between days. The others' + * showtimes merge into it as ordinary performances. + * + * Name matching uses showNameKey (case, punctuation and "ALL-NEW!" insensitive). + * JSON-safe (plain objects). + */ +export function mapShowAliases(shows: Array<{Id: string; Name: string}>): { + asl: Record; + duplicate: Record; +} { + const canonical = new Map(); for (const s of shows) { - if (aslBaseName(s.Name) === null) byKey.set(showNameKey(s.Name), s.Id); + if (aslBaseName(s.Name) !== null) continue; + const key = showNameKey(s.Name); + const cur = canonical.get(key); + if (cur === undefined || s.Id < cur) canonical.set(key, s.Id); } - const out: Record = {}; + const asl: Record = {}; + const duplicate: Record = {}; for (const s of shows) { const base = aslBaseName(s.Name); - if (base === null) continue; - const baseId = byKey.get(showNameKey(base)); - if (baseId && baseId !== s.Id) out[s.Id] = baseId; + if (base !== null) { + const baseId = canonical.get(showNameKey(base)); + if (baseId && baseId !== s.Id) asl[s.Id] = baseId; + continue; + } + const id = canonical.get(showNameKey(s.Name)); + if (id && id !== s.Id) duplicate[s.Id] = id; } - return out; + return {asl, duplicate}; } // --------------------------------------------------------------------------- @@ -550,9 +583,10 @@ export class SeaworldDestination extends Destination { const shows = this.getAllPoisOfTypes(parkDetail, ['Shows']); // Interpreted performances are folded into their base show's showtimes in // buildLiveData, so they are not entities of their own. See aslBaseName(). - const aslToBase = mapAslShowsToBase(shows); + const aliases = mapShowAliases(shows); for (const poi of shows) { - if (aslToBase[poi.Id]) continue; + // ASL listings and same-name copies are folded into another show. + if (aliases.asl[poi.Id] || aliases.duplicate[poi.Id]) continue; // A walk-through filed under Shows keeps its id and moves to // ATTRACTION. attractionType is set explicitly: RIDE is the type every // other walk-through in this library already carries (haunt mazes, @@ -669,6 +703,8 @@ export class SeaworldDestination extends Destination { // rows carry only an Id, no name, so they cannot be recognised without // the park detail. let aslToBase: Record = {}; + // Same-name copy id -> canonical show id (see mapShowAliases). + let duplicateToCanonical: Record = {}; try { availability = await this.getAvailability(parkId, searchDate); // Operating hours decide how to read the "no reading" state below. @@ -678,7 +714,9 @@ export class SeaworldDestination extends Destination { try { const parkDetail = await this.getParkDetail(parkId); parkIsOpen = this.isParkOpenNow(parkDetail); - aslToBase = mapAslShowsToBase(this.getAllPoisOfTypes(parkDetail, ['Shows'])); + const aliases = mapShowAliases(this.getAllPoisOfTypes(parkDetail, ['Shows'])); + aslToBase = aliases.asl; + duplicateToCanonical = aliases.duplicate; } catch (err: any) { console.warn( `[${this.constructor.name}] operating hours unavailable for park ${parkId}, ` + @@ -799,6 +837,11 @@ export class SeaworldDestination extends Destination { if (closureText) closedAslListings.add(wt.Id); continue; } + // A same-name copy is not an entity either. Its status row is ignored, not + // applied to the canonical show: a leftover copy saying "Closed" must not + // shut a show its twin is listing performances for. Its showtimes still + // merge in below. + if (duplicateToCanonical[wt.Id]) continue; const entry = getOrCreate(wt.Id); // Only trust an actual number. Number() maps null, '', ' ' and [] to 0, @@ -873,7 +916,7 @@ export class SeaworldDestination extends Destination { // getOrCreate so it neither adds slots to the base show nor creates a // base row on a day the base show has no row of its own. if (baseId && closedAslListings.has(st.Id)) continue; - const targetId = baseId ?? st.Id; + const targetId = baseId ?? duplicateToCanonical[st.Id] ?? st.Id; const entry = getOrCreate(targetId); if (st.ShowTimes && st.ShowTimes.length > 0) { @@ -903,7 +946,17 @@ export class SeaworldDestination extends Destination { } sources.set(st.Id, times); // Same park, same offset, so the ISO strings sort chronologically. + // Same-name copies can list the same slot; publish it once. The type is + // part of the key: an ASL-interpreted performance beside the regular one + // at the same time is two listings by design. + const seen = new Set(); entry.showtimes = [...sources.values()].flat() + .filter((t) => { + const key = `${t.startTime}|${t.endTime}|${t.type}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) .sort((a, b) => String(a.startTime).localeCompare(String(b.startTime))); } }