Skip to content
Merged
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
81 changes: 81 additions & 0 deletions src/parks/seaworld/__tests__/seaworld.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
SesamePlaceSanDiego,
aslBaseName,
mapAslShowsToBase,
mapShowAliases,
ASL_SHOWTIME_TYPE,
} from '../seaworld.js';

Expand Down Expand Up @@ -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
Expand Down
75 changes: 64 additions & 11 deletions src/parks/seaworld/seaworld.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
const byKey = new Map<string, string>();
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<string, string>;
duplicate: Record<string, string>;
} {
const canonical = new Map<string, string>();
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<string, string> = {};
const asl: Record<string, string> = {};
const duplicate: Record<string, string> = {};
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};
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string> = {};
// Same-name copy id -> canonical show id (see mapShowAliases).
let duplicateToCanonical: Record<string, string> = {};
try {
availability = await this.getAvailability(parkId, searchDate);
// Operating hours decide how to read the "no reading" state below.
Expand All @@ -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}, ` +
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string>();
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)));
}
}
Expand Down
Loading