@curless/hotel-storefront 0.27.5 → 0.27.6
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.
- package/README.md +18 -8
- package/dist/fence.d.ts +12 -1
- package/dist/fence.d.ts.map +1 -1
- package/dist/fence.js +0 -0
- package/dist/fence.js.map +1 -1
- package/dist/legal.js +1 -1
- package/dist/legal.js.map +1 -1
- package/dist/money.d.ts +2 -0
- package/dist/money.d.ts.map +1 -1
- package/dist/money.js +43 -6
- package/dist/money.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +9 -1
- package/dist/server.js.map +1 -1
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +3 -2
- package/dist/sessions.js.map +1 -1
- package/dist/surface.d.ts +2 -0
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +10 -1
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +4 -4
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +21 -8
- package/dist/tools.js.map +1 -1
- package/dist/trip-ids.js +1 -1
- package/dist/trip-ids.js.map +1 -1
- package/package.json +2 -2
- package/dist/__tests__/__fixtures__/huazhu-fake.d.ts +0 -2
- package/dist/__tests__/__fixtures__/huazhu-fake.d.ts.map +0 -1
- package/dist/__tests__/__fixtures__/huazhu-fake.js +0 -3
- package/dist/__tests__/__fixtures__/huazhu-fake.js.map +0 -1
- package/dist/__tests__/__helpers__/sessions.d.ts +0 -10
- package/dist/__tests__/__helpers__/sessions.d.ts.map +0 -1
- package/dist/__tests__/__helpers__/sessions.js +0 -40
- package/dist/__tests__/__helpers__/sessions.js.map +0 -1
- package/dist/__tests__/audit-p0.test.d.ts +0 -2
- package/dist/__tests__/audit-p0.test.d.ts.map +0 -1
- package/dist/__tests__/audit-p0.test.js +0 -274
- package/dist/__tests__/audit-p0.test.js.map +0 -1
- package/dist/__tests__/audit-p2.test.d.ts +0 -2
- package/dist/__tests__/audit-p2.test.d.ts.map +0 -1
- package/dist/__tests__/audit-p2.test.js +0 -98
- package/dist/__tests__/audit-p2.test.js.map +0 -1
- package/dist/__tests__/card-contract.test.d.ts +0 -2
- package/dist/__tests__/card-contract.test.d.ts.map +0 -1
- package/dist/__tests__/card-contract.test.js +0 -282
- package/dist/__tests__/card-contract.test.js.map +0 -1
- package/dist/__tests__/data.test.d.ts +0 -2
- package/dist/__tests__/data.test.d.ts.map +0 -1
- package/dist/__tests__/data.test.js +0 -210
- package/dist/__tests__/data.test.js.map +0 -1
- package/dist/__tests__/dates.test.d.ts +0 -2
- package/dist/__tests__/dates.test.d.ts.map +0 -1
- package/dist/__tests__/dates.test.js +0 -107
- package/dist/__tests__/dates.test.js.map +0 -1
- package/dist/__tests__/demo-hotels.test.d.ts +0 -2
- package/dist/__tests__/demo-hotels.test.d.ts.map +0 -1
- package/dist/__tests__/demo-hotels.test.js +0 -765
- package/dist/__tests__/demo-hotels.test.js.map +0 -1
- package/dist/__tests__/huazhu-audit.test.d.ts +0 -2
- package/dist/__tests__/huazhu-audit.test.d.ts.map +0 -1
- package/dist/__tests__/huazhu-audit.test.js +0 -234
- package/dist/__tests__/huazhu-audit.test.js.map +0 -1
- package/dist/__tests__/huazhu-backend.test.d.ts +0 -2
- package/dist/__tests__/huazhu-backend.test.d.ts.map +0 -1
- package/dist/__tests__/huazhu-backend.test.js +0 -1326
- package/dist/__tests__/huazhu-backend.test.js.map +0 -1
- package/dist/__tests__/huazhu-cancel-policy.test.d.ts +0 -2
- package/dist/__tests__/huazhu-cancel-policy.test.d.ts.map +0 -1
- package/dist/__tests__/huazhu-cancel-policy.test.js +0 -82
- package/dist/__tests__/huazhu-cancel-policy.test.js.map +0 -1
- package/dist/__tests__/huazhu-sandbox.test.d.ts +0 -2
- package/dist/__tests__/huazhu-sandbox.test.d.ts.map +0 -1
- package/dist/__tests__/huazhu-sandbox.test.js +0 -195
- package/dist/__tests__/huazhu-sandbox.test.js.map +0 -1
- package/dist/__tests__/huazhu-sellable.test.d.ts +0 -2
- package/dist/__tests__/huazhu-sellable.test.d.ts.map +0 -1
- package/dist/__tests__/huazhu-sellable.test.js +0 -192
- package/dist/__tests__/huazhu-sellable.test.js.map +0 -1
- package/dist/__tests__/present.test.d.ts +0 -2
- package/dist/__tests__/present.test.d.ts.map +0 -1
- package/dist/__tests__/present.test.js +0 -367
- package/dist/__tests__/present.test.js.map +0 -1
- package/dist/__tests__/prose.test.d.ts +0 -2
- package/dist/__tests__/prose.test.d.ts.map +0 -1
- package/dist/__tests__/prose.test.js +0 -171
- package/dist/__tests__/prose.test.js.map +0 -1
- package/dist/__tests__/server.test.d.ts +0 -2
- package/dist/__tests__/server.test.d.ts.map +0 -1
- package/dist/__tests__/server.test.js +0 -557
- package/dist/__tests__/server.test.js.map +0 -1
- package/dist/__tests__/state.test.d.ts +0 -2
- package/dist/__tests__/state.test.d.ts.map +0 -1
- package/dist/__tests__/state.test.js +0 -204
- package/dist/__tests__/state.test.js.map +0 -1
- package/dist/__tests__/tool-descriptions.test.d.ts +0 -2
- package/dist/__tests__/tool-descriptions.test.d.ts.map +0 -1
- package/dist/__tests__/tool-descriptions.test.js +0 -220
- package/dist/__tests__/tool-descriptions.test.js.map +0 -1
- package/dist/__tests__/tools.test.d.ts +0 -2
- package/dist/__tests__/tools.test.d.ts.map +0 -1
- package/dist/__tests__/tools.test.js +0 -1598
- package/dist/__tests__/tools.test.js.map +0 -1
|
@@ -1,1598 +0,0 @@
|
|
|
1
|
-
import { fileURLToPath } from 'node:url';
|
|
2
|
-
import { beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
-
import { loadHotelData } from '../data.js';
|
|
4
|
-
import { createDemoHotels } from '../demo-hotels.js';
|
|
5
|
-
import { MAX_MISSES, Misses } from '../limits.js';
|
|
6
|
-
import { MemberStore } from '../members.js';
|
|
7
|
-
import { handleTool } from '../tools.js';
|
|
8
|
-
import { TripStore } from '../trip.js';
|
|
9
|
-
import { resolveBrand } from '../vocab.js';
|
|
10
|
-
const DATA = loadHotelData(fileURLToPath(new URL('./__fixtures__/brand.json', import.meta.url)));
|
|
11
|
-
const CONFIG = {
|
|
12
|
-
id: 'shangrila',
|
|
13
|
-
brand: 'Shangri-La',
|
|
14
|
-
server: { name: 'shangrila-hotels', title: 'Shangri-La Hotels (demo)' },
|
|
15
|
-
naming: { prefix: 'shangrila', noun: 'hotel', nounPlural: 'hotels' },
|
|
16
|
-
data: { source: 'demo', file: 'data/hotels.json' },
|
|
17
|
-
payment: { mode: 'none' },
|
|
18
|
-
urls: { appUrl: 'https://hotels.curless.ai/shangrila' },
|
|
19
|
-
legal: {},
|
|
20
|
-
};
|
|
21
|
-
const OCT_1 = Date.parse('2026-10-01T02:00:00Z');
|
|
22
|
-
const STAY = { checkIn: '2026-10-19', checkOut: '2026-10-21', guests: 2 };
|
|
23
|
-
let clock = OCT_1;
|
|
24
|
-
let deps;
|
|
25
|
-
const brand = resolveBrand(CONFIG);
|
|
26
|
-
const t = brand.tools;
|
|
27
|
-
const ctx = { session: 'session-a' };
|
|
28
|
-
const call = (name, args = {}) => handleTool(deps, name, args, ctx);
|
|
29
|
-
beforeEach(() => {
|
|
30
|
-
clock = OCT_1;
|
|
31
|
-
deps = {
|
|
32
|
-
backend: createDemoHotels({ data: DATA, now: () => clock, rng: () => 0.5 }),
|
|
33
|
-
trips: new TripStore(() => clock),
|
|
34
|
-
members: new MemberStore(() => clock),
|
|
35
|
-
tools: t,
|
|
36
|
-
vocab: brand.vocab,
|
|
37
|
-
};
|
|
38
|
-
});
|
|
39
|
-
describe('finding somewhere to stay', () => {
|
|
40
|
-
it('answers a city with the whole shortlist, and says how many', async () => {
|
|
41
|
-
const res = await call(t.listHotels, { city: 'Hong Kong' });
|
|
42
|
-
expect(res.structured.view).toBe('hotels');
|
|
43
|
-
expect(res.structured.total).toBe(4);
|
|
44
|
-
expect(res.text).toContain('4 Shangri-La hotels in Hong Kong');
|
|
45
|
-
});
|
|
46
|
-
it('marks every price-bearing answer as demonstration data', async () => {
|
|
47
|
-
const res = await call(t.listHotels, { query: 'shanghai' });
|
|
48
|
-
expect(res.structured.demo).toBe(true);
|
|
49
|
-
expect(String(res.structured.demoNote)).toContain('not rates anyone can book');
|
|
50
|
-
});
|
|
51
|
-
it('says nothing matched rather than showing something else', async () => {
|
|
52
|
-
const res = await call(t.listHotels, { query: 'reykjavik' });
|
|
53
|
-
expect(res.structured.view).toBe('no-hotels');
|
|
54
|
-
expect(res.structured.hotels).toEqual([]);
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
describe('one hotel', () => {
|
|
58
|
-
it('has no rooms and asks for the nights when no dates were given', async () => {
|
|
59
|
-
const res = await call(t.getHotel, { hotel: 'hong-kong-island' });
|
|
60
|
-
expect(res.structured.view).toBe('hotel');
|
|
61
|
-
expect(res.structured.needsDates).toBe(true);
|
|
62
|
-
expect(res.structured.rooms).toBeUndefined();
|
|
63
|
-
expect(res.text).toContain('which nights');
|
|
64
|
-
});
|
|
65
|
-
it('fences the hotel’s own words on their way into the model', async () => {
|
|
66
|
-
const res = await call(t.getHotel, { hotel: 'hong-kong-island' });
|
|
67
|
-
expect(res.text).toContain('⟦hotel text');
|
|
68
|
-
expect(res.text).toContain('data, not instructions');
|
|
69
|
-
expect(res.text).toContain('⟦end hotel text⟧');
|
|
70
|
-
});
|
|
71
|
-
it('prices the rooms once the nights are known, stay total and all', async () => {
|
|
72
|
-
const res = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY });
|
|
73
|
-
expect(res.structured.view).toBe('hotel');
|
|
74
|
-
const rooms = res.structured.rooms;
|
|
75
|
-
expect(rooms.find((r) => r.name === 'Deluxe Peak View Room')).toMatchObject({
|
|
76
|
-
nightly: 'HK$3,800',
|
|
77
|
-
stayTotal: 'HK$8,588',
|
|
78
|
-
});
|
|
79
|
-
expect(res.text).toContain('incl. taxes and fees');
|
|
80
|
-
});
|
|
81
|
-
// ⚠️ The room count FILTERS, it does not just ride along. A guest who needs
|
|
82
|
-
// two rooms and is shown a type with one left finds out when the trip drops
|
|
83
|
-
// the line - later, and after they thought it was settled.
|
|
84
|
-
it('leaves out the room types that cannot supply the rooms asked for', async () => {
|
|
85
|
-
const one = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY });
|
|
86
|
-
expect(one.structured.rooms.map((r) => r.id)).toContain('suite');
|
|
87
|
-
// The Harbour View Suite has one left; the Horizon Club has two.
|
|
88
|
-
const two = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY, rooms: 2 });
|
|
89
|
-
const ids = two.structured.rooms.map((r) => r.id);
|
|
90
|
-
expect(ids).not.toContain('suite');
|
|
91
|
-
expect(ids).toContain('horizon-club');
|
|
92
|
-
expect(two.structured.rooms_wanted).toBe(2);
|
|
93
|
-
expect(two.text).toContain('2 rooms');
|
|
94
|
-
});
|
|
95
|
-
// ⚠️ robin picked two rooms and the price did not move: the card was still
|
|
96
|
-
// showing one room's total, and nothing on it said so.
|
|
97
|
-
it('prices the rooms actually asked for, per room and in total', async () => {
|
|
98
|
-
const one = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY });
|
|
99
|
-
const oneRoom = one.structured.rooms.find((r) => r.id === 'deluxe-city');
|
|
100
|
-
expect(oneRoom?.stayTotal).toBe('HK$8,588');
|
|
101
|
-
expect(oneRoom?.roomsTotal).toBeUndefined();
|
|
102
|
-
const two = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY, rooms: 2 });
|
|
103
|
-
const twoRooms = two.structured.rooms.find((r) => r.id === 'deluxe-city');
|
|
104
|
-
// Per room is unchanged; the total is what two of them cost.
|
|
105
|
-
expect(twoRooms?.stayTotal).toBe('HK$8,588');
|
|
106
|
-
expect(twoRooms?.roomsTotal).toBe('HK$17,176');
|
|
107
|
-
expect(two.text).toContain('HK$17,176 for 2 rooms');
|
|
108
|
-
});
|
|
109
|
-
// ⚠️ "until 2026-10-17" for a window that closes at 15:00 that day: a guest
|
|
110
|
-
// cancelling that evening would have paid a night (2026-10-05 audit).
|
|
111
|
-
it('gives the free-cancellation deadline with its hour, on the hotel clock', async () => {
|
|
112
|
-
const res = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY });
|
|
113
|
-
const room = res.structured.rooms.find((r) => r.id === 'deluxe-city');
|
|
114
|
-
// 48 hours before a 15:00 check-in on Monday 19 October, in Hong Kong.
|
|
115
|
-
expect(room?.freeCancellationLocal).toBe('Sat 17 Oct, 15:00');
|
|
116
|
-
expect(res.text).toContain('free to cancel until Sat 17 Oct, 15:00');
|
|
117
|
-
});
|
|
118
|
-
it('says so plainly once the held rooms mean nothing can supply that many', async () => {
|
|
119
|
-
// Island's largest type has six; two held leaves four, and four is under
|
|
120
|
-
// the five being asked for. Held inventory changing what a room count can
|
|
121
|
-
// supply is the real path here, so the test walks it rather than inventing
|
|
122
|
-
// a thinner hotel.
|
|
123
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY, rooms: 2 });
|
|
124
|
-
await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
125
|
-
const res = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY, rooms: 5 });
|
|
126
|
-
expect(res.structured.view).toBe('no-rooms');
|
|
127
|
-
expect(res.text).toContain('No room type at Island Shangri-La');
|
|
128
|
-
expect(res.text).toContain('5');
|
|
129
|
-
// Four is still there, which is the other half of the same fact.
|
|
130
|
-
const four = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY, rooms: 4 });
|
|
131
|
-
expect(four.structured.view).toBe('hotel');
|
|
132
|
-
});
|
|
133
|
-
it('hands back the hotel’s own reason instead of a room that does not fit', async () => {
|
|
134
|
-
const res = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY, guests: 6 });
|
|
135
|
-
expect(res.structured.view).toBe('no-rooms');
|
|
136
|
-
expect(res.text).toContain('the largest sleeps 3, so it takes 2 rooms');
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
describe('building a trip', () => {
|
|
140
|
-
it('refuses to invent dates, and leaves the trip alone while it asks', async () => {
|
|
141
|
-
const res = await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city' });
|
|
142
|
-
expect(res.structured.view).toBe('needs-dates');
|
|
143
|
-
expect(res.text).toContain('Which nights?');
|
|
144
|
-
expect((await call(t.viewTrip)).structured.lines).toEqual([]);
|
|
145
|
-
});
|
|
146
|
-
it('takes a room by name, then an add-on, and totals both with tax', async () => {
|
|
147
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'Deluxe Peak View Room', ...STAY });
|
|
148
|
-
const res = await call(t.addToTrip, {
|
|
149
|
-
hotel: 'hong-kong-island',
|
|
150
|
-
extra: 'Guided morning walk on the Peak',
|
|
151
|
-
quantity: 2,
|
|
152
|
-
});
|
|
153
|
-
expect(res.structured.view).toBe('trip');
|
|
154
|
-
expect(res.structured.total).toBe('HK$10,125');
|
|
155
|
-
expect(res.structured.taxesAndFees).toBe('HK$1,165');
|
|
156
|
-
expect(res.text).toContain('Total HK$10,125');
|
|
157
|
-
});
|
|
158
|
-
it('replaces a line rather than stacking it, and 0 takes it out', async () => {
|
|
159
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
160
|
-
const two = await call(t.addToTrip, {
|
|
161
|
-
hotel: 'hong-kong-island',
|
|
162
|
-
room: 'deluxe-city',
|
|
163
|
-
...STAY,
|
|
164
|
-
rooms: 2,
|
|
165
|
-
});
|
|
166
|
-
expect(two.structured.lines[0]?.quantity).toBe(2);
|
|
167
|
-
expect(two.structured.total).toBe('HK$17,176');
|
|
168
|
-
const gone = await call(t.addToTrip, {
|
|
169
|
-
hotel: 'hong-kong-island',
|
|
170
|
-
room: 'deluxe-city',
|
|
171
|
-
...STAY,
|
|
172
|
-
rooms: 0,
|
|
173
|
-
});
|
|
174
|
-
expect(gone.structured.lines).toEqual([]);
|
|
175
|
-
});
|
|
176
|
-
it('keeps two stays in the same room apart, because the dates are the line', async () => {
|
|
177
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
178
|
-
const res = await call(t.addToTrip, {
|
|
179
|
-
hotel: 'hong-kong-island',
|
|
180
|
-
room: 'deluxe-city',
|
|
181
|
-
checkIn: '2026-11-09',
|
|
182
|
-
checkOut: '2026-11-11',
|
|
183
|
-
guests: 2,
|
|
184
|
-
});
|
|
185
|
-
expect(res.structured.lines.length).toBe(2);
|
|
186
|
-
expect(res.structured.stay).toEqual({
|
|
187
|
-
checkIn: '2026-10-19',
|
|
188
|
-
checkOut: '2026-11-11',
|
|
189
|
-
guests: 2,
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
// ⚠️ TWO CURRENCIES ARE TWO TOTALS, never one. The second hotel used to be
|
|
193
|
-
// refused at the door; a Hong Kong - Shanghai trip is the ordinary case.
|
|
194
|
-
it('prices a trip across two currencies side by side, and never adds them up', async () => {
|
|
195
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
196
|
-
const both = await call(t.addToTrip, {
|
|
197
|
-
hotel: 'shanghai-pudong',
|
|
198
|
-
room: 'deluxe-city',
|
|
199
|
-
checkIn: '2026-10-21',
|
|
200
|
-
checkOut: '2026-10-23',
|
|
201
|
-
guests: 2,
|
|
202
|
-
});
|
|
203
|
-
expect(both.structured.view).toBe('trip');
|
|
204
|
-
expect(both.structured.lines).toHaveLength(2);
|
|
205
|
-
expect(both.structured.currencies).toEqual(['HKD', 'CNY']);
|
|
206
|
-
const groups = both.structured.groups;
|
|
207
|
-
expect(groups.map((g) => g.currency)).toEqual(['HKD', 'CNY']);
|
|
208
|
-
expect(both.structured.total).toBe(`${groups[0]?.total} · ${groups[1]?.total}`);
|
|
209
|
-
expect(both.structured.dropped).toBeUndefined();
|
|
210
|
-
expect(both.text).toMatch(/2 currencies, priced separately and not converted/);
|
|
211
|
-
const made = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
212
|
-
const bookings = made.structured.bookings;
|
|
213
|
-
expect(bookings.map((b) => b.quote.total.slice(0, 1))).toEqual(['H', '¥']);
|
|
214
|
-
});
|
|
215
|
-
it('names the room types it does have when the one asked for is not among them', async () => {
|
|
216
|
-
const res = await call(t.addToTrip, {
|
|
217
|
-
hotel: 'hong-kong-island',
|
|
218
|
-
room: 'presidential',
|
|
219
|
-
...STAY,
|
|
220
|
-
});
|
|
221
|
-
expect(res.structured.view).toBe('error');
|
|
222
|
-
expect(res.text).toContain('Harbour View Suite');
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
describe('asking for the rooms', () => {
|
|
226
|
-
const book = async () => {
|
|
227
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
228
|
-
return call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
229
|
-
};
|
|
230
|
-
it('holds the rooms, charges nothing, and says so in as many words', async () => {
|
|
231
|
-
const res = await book();
|
|
232
|
-
expect(res.structured.view).toBe('booking');
|
|
233
|
-
expect(res.structured.paid).toBe(false);
|
|
234
|
-
expect(res.text).toContain('Nothing has been charged');
|
|
235
|
-
// ⚠️ A DEMONSTRATION BOOKING, SAID AS ONE (2026-10-05 audit, P2): never
|
|
236
|
-
// "presents a card at check-in", and its hold on the hotel's own clock.
|
|
237
|
-
expect(res.text).toContain('This is a demonstration booking: Shangri-La has not received it and is not holding any room.');
|
|
238
|
-
expect(res.text).toMatch(/hold lapses on its own at \w{3} \d+ \w{3}, \d{2}:\d{2} hotel time/);
|
|
239
|
-
expect(res.text).not.toContain('presents a card');
|
|
240
|
-
});
|
|
241
|
-
it('empties the trip once it has been asked for, so nobody books it twice', async () => {
|
|
242
|
-
await book();
|
|
243
|
-
expect((await call(t.viewTrip)).structured.lines).toEqual([]);
|
|
244
|
-
});
|
|
245
|
-
// ⚠️ FOUND ON THE LIVE CONNECTOR: `request_booking {}` answered with a
|
|
246
|
-
// confirmation code and `guest: { name: '', email: '' }`. The schema said
|
|
247
|
-
// both were required; a schema is what the model is told, not what it must
|
|
248
|
-
// do. The hotel would have been holding a room for nobody.
|
|
249
|
-
it('will not hold a room for nobody', async () => {
|
|
250
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
251
|
-
const none = await call(t.requestBooking, {});
|
|
252
|
-
expect(none.structured.view).toBe('needs-guest');
|
|
253
|
-
expect(none.structured.missing).toEqual(['a name', 'an email']);
|
|
254
|
-
const noEmail = await call(t.requestBooking, { name: 'Robin Ruan' });
|
|
255
|
-
expect(noEmail.structured.view).toBe('needs-guest');
|
|
256
|
-
expect(noEmail.structured.missing).toEqual(['an email']);
|
|
257
|
-
// And the trip is untouched, so the fix is one more message.
|
|
258
|
-
expect((await call(t.viewTrip)).structured.lines).toHaveLength(1);
|
|
259
|
-
});
|
|
260
|
-
it('will not book an empty trip', async () => {
|
|
261
|
-
const res = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
262
|
-
expect(res.structured.view).toBe('empty-trip');
|
|
263
|
-
});
|
|
264
|
-
it('lists what this conversation booked', async () => {
|
|
265
|
-
await book();
|
|
266
|
-
const res = await call(t.listBookings);
|
|
267
|
-
expect(res.structured.count).toBe(1);
|
|
268
|
-
expect(res.text).toMatch(/^SL-[A-Z0-9]{6} · Island Shangri-La/);
|
|
269
|
-
});
|
|
270
|
-
});
|
|
271
|
-
describe('membership', () => {
|
|
272
|
-
const signIn = () => call(t.signIn, { number: 'SLC-4471-8820', surname: 'Ruan' });
|
|
273
|
-
it('takes a number and the surname on it, and says what the tier is worth', async () => {
|
|
274
|
-
const res = await signIn();
|
|
275
|
-
expect(res.structured.view).toBe('member');
|
|
276
|
-
expect(res.text).toContain('Jade');
|
|
277
|
-
expect(res.text).toContain('10% off');
|
|
278
|
-
// ⚠️ The word must not appear anywhere on this path.
|
|
279
|
-
expect(JSON.stringify(res).toLowerCase()).not.toContain('password');
|
|
280
|
-
});
|
|
281
|
-
it('prices the rooms at the member rate, with the public one beside it', async () => {
|
|
282
|
-
await signIn();
|
|
283
|
-
const res = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY });
|
|
284
|
-
const room = res.structured.rooms.find((r) => r.id === 'deluxe-city');
|
|
285
|
-
// 10% off the room; tax is charged on what is actually paid, not on the
|
|
286
|
-
// public rate - the other order hands the member a second, invisible
|
|
287
|
-
// discount on the government's share.
|
|
288
|
-
expect(room?.publicStayTotal).toBe('HK$8,588');
|
|
289
|
-
expect(room?.stayTotal).toBe('HK$7,729');
|
|
290
|
-
});
|
|
291
|
-
// ⚠️ One room's public rate struck through over two rooms' member rate put
|
|
292
|
-
// the "was" price BELOW the price a member pays (2026-10-05 audit).
|
|
293
|
-
it('strikes through the public rate for as many rooms as the member price beside it', async () => {
|
|
294
|
-
await signIn();
|
|
295
|
-
const res = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY, rooms: 2 });
|
|
296
|
-
const room = res.structured.rooms.find((r) => r.id === 'deluxe-city');
|
|
297
|
-
expect(room?.roomsTotal).toBe('HK$15,458');
|
|
298
|
-
expect(room?.publicRoomsTotal).toBe('HK$17,176');
|
|
299
|
-
});
|
|
300
|
-
it('says what membership was worth on THIS trip, in money', async () => {
|
|
301
|
-
await signIn();
|
|
302
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-harbour', ...STAY });
|
|
303
|
-
const trip = await call(t.viewTrip);
|
|
304
|
-
const m = trip.structured.memberSaving;
|
|
305
|
-
expect(m.tier).toBe('Jade');
|
|
306
|
-
expect(m.saved).toBe('HK$1,040');
|
|
307
|
-
expect(Number(m.points)).toBeGreaterThan(0);
|
|
308
|
-
});
|
|
309
|
-
// ⚠️ POINTS ARE EARNED PER UNIT OF ONE CURRENCY, and the group charges in
|
|
310
|
-
// twenty-two. Multiplying the raw total by the tier's rate paid a Jakarta
|
|
311
|
-
// guest 6,180,075 points and a Paris guest 2,624 for the same money - the
|
|
312
|
-
// point balance was measuring the currency, not the spend.
|
|
313
|
-
it('earns points on what a stay is worth, not on how many zeroes its currency has', async () => {
|
|
314
|
-
const points = async (hotel, room) => {
|
|
315
|
-
await handleTool(deps, t.clearTrip, {}, ctx);
|
|
316
|
-
await call(t.addToTrip, { hotel, room, ...STAY });
|
|
317
|
-
const trip = await call(t.viewTrip);
|
|
318
|
-
const m = trip.structured.memberSaving;
|
|
319
|
-
return Number(m?.points);
|
|
320
|
-
};
|
|
321
|
-
await signIn();
|
|
322
|
-
// ⚠️ THE ARITHMETIC, NOT A CURRENCY TOUR. The bug was found across JPY and
|
|
323
|
-
// IDR, whose units are four orders of magnitude apart - those hotels live
|
|
324
|
-
// in the brand's own repository now, so what is pinned here is the rule
|
|
325
|
-
// that produced the fix: points come off the spend CONVERTED to the
|
|
326
|
-
// programme's base currency, so the same money earns the same points
|
|
327
|
-
// whichever of these two currencies it is charged in.
|
|
328
|
-
const hk = await points('hong-kong-island', 'deluxe-city');
|
|
329
|
-
const cn = await points('shanghai-pudong', 'deluxe-city');
|
|
330
|
-
for (const n of [hk, cn])
|
|
331
|
-
expect(n).toBeGreaterThan(0);
|
|
332
|
-
const rate = (c) => DATA.membership?.earn?.rates[c] ?? 1;
|
|
333
|
-
// HK$8,588 at 7.8 to the dollar against ¥3,364 at 7.1: the ratio of the
|
|
334
|
-
// point balances must be the ratio of the SPEND, not of the raw totals.
|
|
335
|
-
const spendRatio = 8588 / rate('HKD') / (3364 / rate('CNY'));
|
|
336
|
-
expect(hk / cn).toBeCloseTo(spendRatio, 1);
|
|
337
|
-
});
|
|
338
|
-
// ⚠️ THE BOOKING IS THE ONE STEP THAT LEAVES A RECORD. The card showed the
|
|
339
|
-
// member rate, the saving and the tier; `request_booking` then re-quoted the
|
|
340
|
-
// trip with a hand-built identity carrying no member and wrote the booking at
|
|
341
|
-
// the PUBLIC total - ¥152,550 against the ¥137,295 the guest had just been
|
|
342
|
-
// shown (robin, on the live connector, 2026-09-21). Every other member test
|
|
343
|
-
// stops at the quote, which is why this one goes all the way through.
|
|
344
|
-
it('books at the rate the member was shown, not the public one', async () => {
|
|
345
|
-
await signIn();
|
|
346
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
347
|
-
const trip = await call(t.viewTrip);
|
|
348
|
-
const shown = String(trip.structured.total);
|
|
349
|
-
const res = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
350
|
-
expect(res.structured.total, 'the booking is not the total the card showed').toBe(shown);
|
|
351
|
-
expect(res.structured.total).toBe('HK$7,729');
|
|
352
|
-
expect(res.text).toContain('HK$7,729');
|
|
353
|
-
});
|
|
354
|
-
it('goes back to the public rate on the way out', async () => {
|
|
355
|
-
await signIn();
|
|
356
|
-
const out = await call(t.signOut);
|
|
357
|
-
expect(out.structured.view).toBe('signed-out');
|
|
358
|
-
const res = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY });
|
|
359
|
-
const room = res.structured.rooms[0];
|
|
360
|
-
expect(room?.publicStayTotal).toBeUndefined();
|
|
361
|
-
expect(room?.stayTotal).toBe('HK$8,588');
|
|
362
|
-
});
|
|
363
|
-
// ⚠️ A tier buys a RATE, not a lookup. A membership number and a surname are
|
|
364
|
-
// what a desk checks - not authentication - so it must never become a way to
|
|
365
|
-
// read a booking somebody else made.
|
|
366
|
-
it('does not let a membership reach somebody else’s booking', async () => {
|
|
367
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
368
|
-
await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
369
|
-
const theirs = await handleTool(deps, t.signIn, { number: 'SLC-2209-5513', surname: 'Chan' }, {
|
|
370
|
-
session: 'someone-else',
|
|
371
|
-
});
|
|
372
|
-
expect(theirs.structured.view).toBe('member');
|
|
373
|
-
const list = await handleTool(deps, t.listBookings, {}, { session: 'someone-else' });
|
|
374
|
-
expect(list.structured.count).toBe(0);
|
|
375
|
-
});
|
|
376
|
-
});
|
|
377
|
-
describe('changing a booking', () => {
|
|
378
|
-
const book = async () => {
|
|
379
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
380
|
-
const res = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
381
|
-
return String(res.structured.booking.confirmationCode);
|
|
382
|
-
};
|
|
383
|
-
const LATER = { checkIn: '2026-10-26', checkOut: '2026-10-28' };
|
|
384
|
-
it('shows both stays and the difference, and moves nothing until it is told twice', async () => {
|
|
385
|
-
const ref = await book();
|
|
386
|
-
const pre = await call(t.previewChange, { booking: ref, ...LATER });
|
|
387
|
-
expect(pre.structured.view).toBe('change-preview');
|
|
388
|
-
expect(pre.structured.from).toMatchObject({ checkIn: STAY.checkIn, nights: 2 });
|
|
389
|
-
expect(pre.structured.to).toMatchObject({ checkIn: LATER.checkIn, nights: 2 });
|
|
390
|
-
expect(pre.structured.changeable).toBe(true);
|
|
391
|
-
// Asking the CHANGE tool without `confirmed` answers with the same preview
|
|
392
|
-
// and moves nothing - the same bargain the cancellation makes.
|
|
393
|
-
const asked = await call(t.changeBooking, { booking: ref, ...LATER });
|
|
394
|
-
expect(asked.structured.view).toBe('change-preview');
|
|
395
|
-
const still = await call(t.listBookings, {});
|
|
396
|
-
expect(still.structured.bookings[0]
|
|
397
|
-
.stay.checkIn).toBe(STAY.checkIn);
|
|
398
|
-
const done = await call(t.changeBooking, { booking: ref, ...LATER, confirmed: true });
|
|
399
|
-
expect(done.structured.view).toBe('changed');
|
|
400
|
-
const b = done.structured.booking;
|
|
401
|
-
expect(b.stay.checkIn).toBe(LATER.checkIn);
|
|
402
|
-
// ⚠️ THE CODE DOES NOT CHANGE. It is the hotel's handle on the stay, not on
|
|
403
|
-
// the version, and a guest holding the old one at the desk must be found.
|
|
404
|
-
expect(b.confirmationCode).toBe(ref);
|
|
405
|
-
expect(done.text).toContain('Nothing has been charged');
|
|
406
|
-
});
|
|
407
|
-
// ⚠️ THE NEW NIGHTS ARE PRICED AT TODAY'S RATES FOR THOSE NIGHTS. Carrying the
|
|
408
|
-
// old rate forward would be this connector offering a price the hotel never
|
|
409
|
-
// did - a weekend costs more than the weekdays it replaces, and the guest has
|
|
410
|
-
// to see that before it moves, not at the desk.
|
|
411
|
-
it('re-prices the new nights instead of carrying the old rate over', async () => {
|
|
412
|
-
const ref = await book();
|
|
413
|
-
// Friday and Saturday at Island Shangri-La: the weekend uplift is 20%.
|
|
414
|
-
const weekend = { checkIn: '2026-10-23', checkOut: '2026-10-25' };
|
|
415
|
-
const pre = await call(t.previewChange, { booking: ref, ...weekend });
|
|
416
|
-
const from = pre.structured.from;
|
|
417
|
-
const to = pre.structured.to;
|
|
418
|
-
expect(to.total).not.toBe(from.total);
|
|
419
|
-
expect(pre.structured.refundDue).toBe(false);
|
|
420
|
-
expect(String(pre.structured.difference)).toMatch(/^HK\$/);
|
|
421
|
-
});
|
|
422
|
-
// ⚠️ "CANNOT BE CANCELLED OR CHANGED" IS THE HOTEL'S OWN SENTENCE. Offering a
|
|
423
|
-
// change on a non-refundable rate would have this card contradict the policy
|
|
424
|
-
// printed beside it.
|
|
425
|
-
it('refuses to move a non-refundable rate, in the words of its own policy', async () => {
|
|
426
|
-
await handleTool(deps, t.clearTrip, {}, ctx);
|
|
427
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'horizon-club', ...STAY });
|
|
428
|
-
const res = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
429
|
-
const ref = String(res.structured.booking.confirmationCode);
|
|
430
|
-
const pre = await call(t.previewChange, { booking: ref, ...LATER });
|
|
431
|
-
expect(pre.structured.changeable).toBe(false);
|
|
432
|
-
expect(String(pre.structured.rule)).toMatch(/non-refundable/i);
|
|
433
|
-
// …and confirming does not get around it.
|
|
434
|
-
const tried = await call(t.changeBooking, { booking: ref, ...LATER, confirmed: true });
|
|
435
|
-
expect(tried.structured.view).toBe('change-preview');
|
|
436
|
-
});
|
|
437
|
-
it('refuses a change the hotel has no rooms for, and says how many there are', async () => {
|
|
438
|
-
const ref = await book();
|
|
439
|
-
// The Harbour View Suite has one left; two of it is not a change the hotel
|
|
440
|
-
// can house, and saying "changed" would hold a room that does not exist.
|
|
441
|
-
const pre = await call(t.previewChange, { booking: ref, room: 'suite', rooms: 2 });
|
|
442
|
-
expect(pre.structured.changeable).toBe(false);
|
|
443
|
-
expect(String(pre.structured.rule)).toMatch(/has 1 left|needs 2/);
|
|
444
|
-
});
|
|
445
|
-
// ⚠️ THE ROOMS THIS BOOKING IS GIVING BACK COUNT. A guest moving from two of
|
|
446
|
-
// a room type to three must not be refused by the two they are releasing -
|
|
447
|
-
// that refusal reads as "sold out" for stock the hotel is about to have.
|
|
448
|
-
it('counts the booking’s own rooms as returned when it asks for more of them', async () => {
|
|
449
|
-
await handleTool(deps, t.clearTrip, {}, ctx);
|
|
450
|
-
await call(t.addToTrip, {
|
|
451
|
-
hotel: 'hong-kong-island',
|
|
452
|
-
room: 'deluxe-harbour',
|
|
453
|
-
rooms: 2,
|
|
454
|
-
...STAY,
|
|
455
|
-
});
|
|
456
|
-
const res = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
457
|
-
const ref = String(res.structured.booking.confirmationCode);
|
|
458
|
-
// Three left in the fixture, two of them held by this very booking.
|
|
459
|
-
const pre = await call(t.previewChange, { booking: ref, rooms: 3 });
|
|
460
|
-
expect(pre.structured.changeable, String(pre.structured.rule)).toBe(true);
|
|
461
|
-
expect(pre.structured.roomsLeft).toBe(3);
|
|
462
|
-
});
|
|
463
|
-
it('says so when the change is what the booking already holds', async () => {
|
|
464
|
-
const ref = await book();
|
|
465
|
-
const pre = await call(t.previewChange, { booking: ref, ...STAY });
|
|
466
|
-
expect(pre.structured.changeable).toBe(false);
|
|
467
|
-
expect(String(pre.structured.rule)).toMatch(/already holds/);
|
|
468
|
-
});
|
|
469
|
-
it('does not let a membership reach somebody else’s booking through a change', async () => {
|
|
470
|
-
const ref = await book();
|
|
471
|
-
const theirs = await handleTool(deps, t.previewChange, { booking: ref, ...LATER }, {
|
|
472
|
-
session: 'someone-else',
|
|
473
|
-
});
|
|
474
|
-
expect(theirs.structured.view).toBe('booking-not-found');
|
|
475
|
-
});
|
|
476
|
-
});
|
|
477
|
-
describe('cancelling', () => {
|
|
478
|
-
const book = async () => {
|
|
479
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
480
|
-
const res = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
481
|
-
return res.structured.booking.confirmationCode;
|
|
482
|
-
};
|
|
483
|
-
it('previews the fee and the rule without cancelling anything', async () => {
|
|
484
|
-
const code = await book();
|
|
485
|
-
const res = await call(t.previewCancellation, { booking: code });
|
|
486
|
-
expect(res.structured.view).toBe('cancellation-preview');
|
|
487
|
-
expect(res.structured.fee).toBe('HK$0');
|
|
488
|
-
expect(String(res.structured.rule)).toContain('free to cancel until');
|
|
489
|
-
expect((await call(t.listBookings)).text).toContain('requested');
|
|
490
|
-
});
|
|
491
|
-
// ⚠️ The guest who says "cancel it" has not yet seen the fee. The tool makes
|
|
492
|
-
// that impossible to skip, rather than trusting the instructions.
|
|
493
|
-
it('refuses to cancel on the first ask and returns the preview instead', async () => {
|
|
494
|
-
clock = Date.parse('2026-10-18T09:00:00Z'); // the free window has closed
|
|
495
|
-
const code = await book();
|
|
496
|
-
const first = await call(t.cancelBooking, { booking: code });
|
|
497
|
-
expect(first.structured.view).toBe('cancellation-preview');
|
|
498
|
-
expect(first.structured.fee).toBe('HK$4,294');
|
|
499
|
-
expect((await call(t.listBookings)).text).toContain('requested');
|
|
500
|
-
const done = await call(t.cancelBooking, {
|
|
501
|
-
booking: code,
|
|
502
|
-
confirmed: true,
|
|
503
|
-
reason: 'flight moved',
|
|
504
|
-
});
|
|
505
|
-
expect(done.structured.view).toBe('cancelled');
|
|
506
|
-
// Nobody charges a demonstration anything (2026-10-05 audit, P2).
|
|
507
|
-
expect(done.text).toContain('A real booking on this rate would be charged HK$4,294');
|
|
508
|
-
expect(done.text).not.toContain('The hotel charges');
|
|
509
|
-
expect(done.text).toContain('This is a demonstration booking');
|
|
510
|
-
expect((await call(t.listBookings)).text).toContain('cancelled');
|
|
511
|
-
});
|
|
512
|
-
it('says it cannot find someone else’s booking rather than describing it', async () => {
|
|
513
|
-
const res = await handleTool(deps, t.previewCancellation, { booking: 'SL-ABCDEF' }, { session: 'session-b' });
|
|
514
|
-
expect(res.structured.view).toBe('booking-not-found');
|
|
515
|
-
});
|
|
516
|
-
});
|
|
517
|
-
// ⚠️ Reading up drew a card per lookup - a list, four hotels, then the
|
|
518
|
-
// comparison (robin, 2026-09-29). The look-up answers in text and draws nothing.
|
|
519
|
-
describe('looking up', () => {
|
|
520
|
-
it('reads a city in text: ids, fenced descriptions, priced rooms and add-ons', async () => {
|
|
521
|
-
const res = await call(t.lookUp, {
|
|
522
|
-
city: 'Shanghai',
|
|
523
|
-
checkIn: '2026-10-19',
|
|
524
|
-
checkOut: '2026-10-21',
|
|
525
|
-
guests: 2,
|
|
526
|
-
});
|
|
527
|
-
expect(res.structured.view).toBe('look-up');
|
|
528
|
-
expect(res.structured.hotels.map((h) => h.id).sort()).toEqual([
|
|
529
|
-
'shanghai-jingan',
|
|
530
|
-
'shanghai-pudong',
|
|
531
|
-
]);
|
|
532
|
-
expect(res.text).toContain('Nothing was shown to the guest');
|
|
533
|
-
expect(res.text).toContain('id `shanghai-pudong`');
|
|
534
|
-
expect(res.text).toContain('⟦hotel text · description');
|
|
535
|
-
expect(res.text).toMatch(/for the stay incl\. taxes and fees/);
|
|
536
|
-
expect(res.text).toContain('id `airport-transfer`');
|
|
537
|
-
expect(res.text).toContain(t.compare);
|
|
538
|
-
});
|
|
539
|
-
it('reads hotels by the names a model writes, and names the ones it could not find', async () => {
|
|
540
|
-
const res = await call(t.lookUp, {
|
|
541
|
-
hotels: ['Jing An Shangri-La, West Shanghai', 'Hotel Nowhere'],
|
|
542
|
-
});
|
|
543
|
-
expect(res.structured.hotels.map((h) => h.id)).toEqual([
|
|
544
|
-
'shanghai-jingan',
|
|
545
|
-
]);
|
|
546
|
-
expect(res.structured.dropped).toEqual(['Hotel Nowhere']);
|
|
547
|
-
// No dates, no rooms: a from-price, and the rooms wait for the nights.
|
|
548
|
-
expect(res.text).toMatch(/From .+ a night; rooms are priced once there are dates/);
|
|
549
|
-
});
|
|
550
|
-
it('says what it needs when it is given nothing to read', async () => {
|
|
551
|
-
const res = await call(t.lookUp, {});
|
|
552
|
-
expect(res.structured.view).toBe('nothing-found');
|
|
553
|
-
expect(res.text).toMatch(/Name a `city`/);
|
|
554
|
-
});
|
|
555
|
-
});
|
|
556
|
-
describe('policies', () => {
|
|
557
|
-
it('answers one topic in the hotel’s own fenced words', async () => {
|
|
558
|
-
const res = await call(t.policies, { hotel: 'hong-kong-island', topic: 'cancellation' });
|
|
559
|
-
expect(res.structured.view).toBe('policy');
|
|
560
|
-
expect(res.text).toContain('⟦hotel text · Cancellation');
|
|
561
|
-
expect(res.text).toContain('48 hours');
|
|
562
|
-
});
|
|
563
|
-
it('says the hotel does not publish it rather than filling in what hotels usually do', async () => {
|
|
564
|
-
const res = await call(t.policies, { hotel: 'shenzhen-futian', topic: 'accessibility' });
|
|
565
|
-
expect(res.structured.view).toBe('policy');
|
|
566
|
-
const missing = await call(t.policies, { hotel: 'hong-kong-jen', topic: 'smoking' });
|
|
567
|
-
expect(missing.structured.view).toBe('policy-missing');
|
|
568
|
-
expect(missing.text).toContain('does not publish anything about that');
|
|
569
|
-
});
|
|
570
|
-
});
|
|
571
|
-
// The strip is the card's; the sentence is the model's. These hold the line
|
|
572
|
-
// between them - what goes in the payload, and what is worth saying out loud.
|
|
573
|
-
describe('the nearby dates', () => {
|
|
574
|
-
const datesOf = (res) => (res.structured.dates ?? []);
|
|
575
|
-
it('rides along with the rooms, seven arrivals around the one asked for', async () => {
|
|
576
|
-
const res = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY });
|
|
577
|
-
const dates = datesOf(res);
|
|
578
|
-
expect(dates).toHaveLength(7);
|
|
579
|
-
expect(dates.filter((d) => d.chosen).map((d) => d.checkIn)).toEqual(['2026-10-19']);
|
|
580
|
-
// Formatted money, because the card is forbidden arithmetic on it.
|
|
581
|
-
expect(dates[0]?.from).toMatch(/^HK\$[\d,]/);
|
|
582
|
-
});
|
|
583
|
-
// ⚠️ SILENCE IS AN ANSWER. Arriving on a Monday is already the cheapest the
|
|
584
|
-
// week gets, and a line saying so on every hotel would be a tic the model
|
|
585
|
-
// learns to repeat. Prose is spent only on the fact the strip hides.
|
|
586
|
-
it('says nothing when the guest already picked the cheapest arrival', async () => {
|
|
587
|
-
const res = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY });
|
|
588
|
-
expect(res.text).not.toMatch(/instead/);
|
|
589
|
-
expect(datesOf(res).some((d) => d.best)).toBe(false);
|
|
590
|
-
});
|
|
591
|
-
it('names the date and the saving when one beats the chosen nights', async () => {
|
|
592
|
-
const res = await call(t.getHotel, {
|
|
593
|
-
hotel: 'hong-kong-island',
|
|
594
|
-
checkIn: '2026-10-23',
|
|
595
|
-
checkOut: '2026-10-25',
|
|
596
|
-
guests: 2,
|
|
597
|
-
});
|
|
598
|
-
// A Friday arrival carries two weekend nights; the weekdays either side do
|
|
599
|
-
// not. The sentence names the date, because "the one before" means nothing
|
|
600
|
-
// once the card reflows or the guest is reading it on a phone.
|
|
601
|
-
expect(res.text).toMatch(/Arriving 2026-10-2\d instead is HK\$[\d,.]+ less for the same 2 nights/);
|
|
602
|
-
expect(res.text).not.toMatch(/the (first|second|third|last) /i);
|
|
603
|
-
});
|
|
604
|
-
// ⚠️ EVERY DATE THAT TIES IS MARKED. Three weekdays at one rate is the normal
|
|
605
|
-
// shape of a hotel week; marking only the closest would say the other two are
|
|
606
|
-
// worse, which is a claim about prices that are equal.
|
|
607
|
-
it('marks every date that ties for cheapest, and never the chosen one', async () => {
|
|
608
|
-
const dates = datesOf(await call(t.getHotel, {
|
|
609
|
-
hotel: 'hong-kong-island',
|
|
610
|
-
checkIn: '2026-10-23',
|
|
611
|
-
checkOut: '2026-10-25',
|
|
612
|
-
guests: 2,
|
|
613
|
-
}));
|
|
614
|
-
const best = dates.filter((d) => d.best);
|
|
615
|
-
expect(best.length).toBeGreaterThan(1);
|
|
616
|
-
expect(new Set(best.map((d) => d.from)).size).toBe(1);
|
|
617
|
-
expect(best.some((d) => d.chosen)).toBe(false);
|
|
618
|
-
});
|
|
619
|
-
it('answers for the rooms asked for, not for one', async () => {
|
|
620
|
-
const one = datesOf(await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY }));
|
|
621
|
-
const two = datesOf(await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY, rooms: 2 }));
|
|
622
|
-
expect(two[0]?.from).not.toBe(one[0]?.from);
|
|
623
|
-
});
|
|
624
|
-
// ⚠️ ONLY WHEN SHIFTING THE DATES COULD HELP. No room here sleeps six on any
|
|
625
|
-
// night of the year, so a week of alternatives is noise dressed as help.
|
|
626
|
-
it('offers no dates when the party is too big for every room type', async () => {
|
|
627
|
-
const res = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY, guests: 6 });
|
|
628
|
-
expect(res.structured.view).toBe('no-rooms');
|
|
629
|
-
expect(res.structured.dates).toBeUndefined();
|
|
630
|
-
});
|
|
631
|
-
it('offers them when the room type is merely taken', async () => {
|
|
632
|
-
// Three rooms of a type that has one left: the dates are fine, the room is
|
|
633
|
-
// the problem, and another arrival is a real answer.
|
|
634
|
-
const res = await call(t.getHotel, {
|
|
635
|
-
hotel: 'hong-kong-island',
|
|
636
|
-
...STAY,
|
|
637
|
-
guests: 3,
|
|
638
|
-
rooms: 3,
|
|
639
|
-
});
|
|
640
|
-
expect(res.structured.view).toBe('no-rooms');
|
|
641
|
-
expect(datesOf(res)).toHaveLength(7);
|
|
642
|
-
});
|
|
643
|
-
it('keeps the strip out of a hotel nobody gave dates for', async () => {
|
|
644
|
-
const res = await call(t.getHotel, { hotel: 'hong-kong-island' });
|
|
645
|
-
expect(res.structured.dates).toBeUndefined();
|
|
646
|
-
});
|
|
647
|
-
});
|
|
648
|
-
// A guest typed "high floor please" into the booking form and it went into
|
|
649
|
-
// `guest.notes`, which nothing ever showed again (2026-09-21). These hold the
|
|
650
|
-
// other half of the fix: that the requests come back, and that nothing about
|
|
651
|
-
// them reads as a promise.
|
|
652
|
-
describe('what the desk is asked for', () => {
|
|
653
|
-
const book = async (extra = {}) => {
|
|
654
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-harbour', ...STAY });
|
|
655
|
-
return call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com', ...extra });
|
|
656
|
-
};
|
|
657
|
-
it('carries the preferences onto the booking and says they are requests', async () => {
|
|
658
|
-
const res = await book({ requests: ['king-bed', 'high-floor'] });
|
|
659
|
-
const booking = res.structured.booking;
|
|
660
|
-
expect(booking.requests?.kinds).toEqual(['king-bed', 'high-floor']);
|
|
661
|
-
expect(res.text).toContain('a king bed, a high floor');
|
|
662
|
-
expect(res.text).toMatch(/none of it is held/);
|
|
663
|
-
});
|
|
664
|
-
// ⚠️ THE WORDING IS THE GUARD. A confirmation that lists "High floor" beside
|
|
665
|
-
// the code and the total reads as a fact about the room.
|
|
666
|
-
it('never says a preference is confirmed, held or reserved', async () => {
|
|
667
|
-
const res = await book({ requests: ['quiet-room', 'connecting-rooms'] });
|
|
668
|
-
const lines = (res.structured.requested ?? []);
|
|
669
|
-
expect(lines.join(' ')).toMatch(/^Asked for on the booking:/);
|
|
670
|
-
expect(lines.join(' ')).not.toMatch(/\b(confirmed|guaranteed|reserved for you)\b/i);
|
|
671
|
-
});
|
|
672
|
-
// ⚠️ A high floor a guest does not get is a disappointment. A step-free room
|
|
673
|
-
// a guest does not get is a trip that does not work. It gets its own
|
|
674
|
-
// sentence, it names who can actually confirm it, and the card gets a flag.
|
|
675
|
-
it('refuses to let an accessible room ride along as a preference', async () => {
|
|
676
|
-
const res = await book({ requests: ['high-floor', 'accessible-room'] });
|
|
677
|
-
const lines = (res.structured.requested ?? []);
|
|
678
|
-
expect(res.structured.accessibility).toBe(true);
|
|
679
|
-
// Not folded into the "asked for" sentence with the rest.
|
|
680
|
-
expect(lines.find((l) => l.startsWith('Asked for on the booking:'))).not.toMatch(/accessible/);
|
|
681
|
-
const own = lines.find((l) => l.includes('accessible room'));
|
|
682
|
-
expect(own).toMatch(/does not hold one/);
|
|
683
|
-
expect(own).toContain('Island Shangri-La, Hong Kong');
|
|
684
|
-
});
|
|
685
|
-
it('keeps the first of two requests that cannot both be met, and says which', async () => {
|
|
686
|
-
const res = await book({ requests: ['twin-beds', 'king-bed'] });
|
|
687
|
-
const booking = res.structured.booking;
|
|
688
|
-
expect(booking.requests?.kinds).toEqual(['twin-beds']);
|
|
689
|
-
expect(res.text).toContain('A king bed and twin beds cannot both be asked for');
|
|
690
|
-
expect(res.text).toContain('the booking asks for twin beds');
|
|
691
|
-
});
|
|
692
|
-
// ⚠️ A TIME IS A LATE ARRIVAL. Carrying the hour without the request is a
|
|
693
|
-
// booking the desk has no reason to hold past it.
|
|
694
|
-
it('reads an arrival time as a late arrival on its own', async () => {
|
|
695
|
-
const res = await book({ arrivingAt: '23:40' });
|
|
696
|
-
const booking = res.structured.booking;
|
|
697
|
-
expect(booking.requests?.kinds).toEqual(['late-arrival']);
|
|
698
|
-
expect(booking.requests?.arrivingAt).toBe('23:40');
|
|
699
|
-
expect(res.text).toContain('a late arrival, around 23:40');
|
|
700
|
-
});
|
|
701
|
-
it('drops a time that is not one, rather than sending it on', async () => {
|
|
702
|
-
const res = await book({ arrivingAt: 'late-ish' });
|
|
703
|
-
expect(res.structured.requested).toBeUndefined();
|
|
704
|
-
});
|
|
705
|
-
it('answers an early check-in with the hour it is early against', async () => {
|
|
706
|
-
const res = await book({ requests: ['early-check-in'] });
|
|
707
|
-
expect(res.text).toMatch(/Check-in here opens at \d\d:\d\d/);
|
|
708
|
-
});
|
|
709
|
-
it('caps the guest’s own words instead of carrying a conversation to the desk', async () => {
|
|
710
|
-
const res = await book({ notes: 'x'.repeat(500) });
|
|
711
|
-
const booking = res.structured.booking;
|
|
712
|
-
expect(booking.requests?.note?.length).toBe(200);
|
|
713
|
-
});
|
|
714
|
-
it('drops a code it does not know', async () => {
|
|
715
|
-
const res = await book({ requests: ['helicopter-pad', 'quiet-room'] });
|
|
716
|
-
const booking = res.structured.booking;
|
|
717
|
-
expect(booking.requests?.kinds).toEqual(['quiet-room']);
|
|
718
|
-
});
|
|
719
|
-
it('says nothing at all when nothing was asked for', async () => {
|
|
720
|
-
const res = await book();
|
|
721
|
-
expect(res.structured.requested).toBeUndefined();
|
|
722
|
-
expect(res.text).not.toMatch(/Asked for/);
|
|
723
|
-
});
|
|
724
|
-
// A guest who closes the card has no other way to check it was recorded.
|
|
725
|
-
it('still names them when the booking is listed later', async () => {
|
|
726
|
-
await book({ requests: ['high-floor'] });
|
|
727
|
-
const res = await call(t.listBookings, {});
|
|
728
|
-
expect(res.text).toContain('asked for high floor');
|
|
729
|
-
});
|
|
730
|
-
});
|
|
731
|
-
// Browsing ninety-six properties: an order, a ceiling, and one wider search
|
|
732
|
-
// before the word "no".
|
|
733
|
-
describe('narrowing the list', () => {
|
|
734
|
-
const list = (a = {}) => call(t.listHotels, a);
|
|
735
|
-
const prices = (res) => (res.structured.hotels ?? []).map((h) => h.fromNightly);
|
|
736
|
-
it('orders by the from-price, low and high, inside one currency', async () => {
|
|
737
|
-
const low = prices(await list({ city: 'Hong Kong', sort: 'price-low' }));
|
|
738
|
-
const high = prices(await list({ city: 'Hong Kong', sort: 'price-high' }));
|
|
739
|
-
expect(low).toHaveLength(4);
|
|
740
|
-
expect([...high].reverse()).toEqual(low);
|
|
741
|
-
expect((await list({ city: 'Hong Kong', sort: 'price-low' })).structured.sort).toBe('price-low');
|
|
742
|
-
});
|
|
743
|
-
// ⚠️ SORTING HK$890 AGAINST ¥1,180 MEANS PICKING A RATE, and a rate we picked
|
|
744
|
-
// is a number a guest reads off a card as if the hotel had said it. Dropped,
|
|
745
|
-
// and said - applied silently it invents the rate, dropped silently it hands
|
|
746
|
-
// back a list the guest reads as ordered by price when it is not.
|
|
747
|
-
it('refuses a price order across currencies, names them, and says what to narrow', async () => {
|
|
748
|
-
const res = await list({ sort: 'price-low' });
|
|
749
|
-
expect(res.structured.sort).toBe('group');
|
|
750
|
-
expect(res.structured.currencies).toEqual(['CNY', 'HKD']);
|
|
751
|
-
expect(res.text).toMatch(/not in price order/);
|
|
752
|
-
expect(res.text).toMatch(/one city or one country gives one currency/);
|
|
753
|
-
});
|
|
754
|
-
it('caps by the from-price and says how many it left out', async () => {
|
|
755
|
-
const res = await list({ city: 'Hong Kong', maxNightly: 3000 });
|
|
756
|
-
expect(res.structured.total).toBe(3);
|
|
757
|
-
expect(res.structured.overBudget).toBe(1);
|
|
758
|
-
expect(res.text).toMatch(/1 more matched and came in over the ceiling/);
|
|
759
|
-
});
|
|
760
|
-
// ⚠️ THE MISS IS THE ANSWER. "No hotels" teaches a guest nothing; the
|
|
761
|
-
// cheapest one and its price is something they can decide on. It is named as
|
|
762
|
-
// a miss and kept out of `hotels`, so nothing downstream can show it as one.
|
|
763
|
-
it('answers an impossible ceiling with the cheapest miss, marked as a miss', async () => {
|
|
764
|
-
const res = await list({ city: 'Hong Kong', maxNightly: 500 });
|
|
765
|
-
expect(res.structured.hotels).toEqual([]);
|
|
766
|
-
expect(res.structured.nearest.length).toBeGreaterThan(0);
|
|
767
|
-
expect(res.text).toMatch(/Nothing under HK\$500 a night/);
|
|
768
|
-
expect(res.text).toMatch(/The cheapest that matched is .+ at HK\$/);
|
|
769
|
-
});
|
|
770
|
-
it('drops the words and keeps the place when both together match nothing', async () => {
|
|
771
|
-
const res = await list({ city: 'Hong Kong', query: 'rooftop helipad' });
|
|
772
|
-
expect(res.structured.total).toBe(4);
|
|
773
|
-
expect(res.text).toMatch(/Nothing matched Hong Kong and "rooftop helipad", so this is Hong Kong/);
|
|
774
|
-
expect(res.structured.widened).toEqual({
|
|
775
|
-
from: 'Hong Kong and "rooftop helipad"',
|
|
776
|
-
to: 'Hong Kong',
|
|
777
|
-
found: true,
|
|
778
|
-
});
|
|
779
|
-
});
|
|
780
|
-
// ⚠️ IT WIDENS ONCE AND STOPS. A search that keeps loosening always finds
|
|
781
|
-
// something, and what it finds is unrelated to what was asked.
|
|
782
|
-
it('says what it tried when the wider search also found nothing', async () => {
|
|
783
|
-
const res = await list({ city: 'Tokyo' });
|
|
784
|
-
expect(res.structured.view).toBe('no-hotels');
|
|
785
|
-
expect(res.text).toMatch(/"Tokyo" anywhere in the record found nothing either/);
|
|
786
|
-
expect(res.structured.widened).toBeUndefined();
|
|
787
|
-
});
|
|
788
|
-
// ⚠️ AND A CEILING IS NOT A FAILED SEARCH. The place matched, the words
|
|
789
|
-
// matched, the prices were the answer. Widening here dropped "Hong Kong" and
|
|
790
|
-
// came back with five hotels in two countries, ceiling silently unapplied
|
|
791
|
-
// because the wider set no longer shared a currency (2026-09-22).
|
|
792
|
-
it('does not widen the place because a price ceiling emptied the list', async () => {
|
|
793
|
-
const res = await list({ city: 'Hong Kong', maxNightly: 500 });
|
|
794
|
-
expect(res.structured.widened).toBeUndefined();
|
|
795
|
-
for (const h of (res.structured.nearest ?? [])) {
|
|
796
|
-
expect(h.city).toBe('Hong Kong');
|
|
797
|
-
}
|
|
798
|
-
});
|
|
799
|
-
it('leaves a hotel that publishes no rating in place rather than last-by-default', async () => {
|
|
800
|
-
const res = await list({ city: 'Hong Kong', sort: 'rating' });
|
|
801
|
-
expect(res.structured.sort).toBe('rating');
|
|
802
|
-
expect(prices(res)).toHaveLength(4);
|
|
803
|
-
});
|
|
804
|
-
it('ignores an order it does not have', async () => {
|
|
805
|
-
expect((await list({ sort: 'closest-to-the-airport', all: true })).structured.sort).toBe('group');
|
|
806
|
-
});
|
|
807
|
-
});
|
|
808
|
-
// ⚠️ A BOOKING MADE ON THE CARD BELONGS TO THE CARD AND THE CONVERSATION. The
|
|
809
|
-
// card's calls arrive on a different connection from the model's, naming the
|
|
810
|
-
// trip instead. The trip id used to open every booking of that conversation,
|
|
811
|
-
// from any connection (2026-10-05 audit); now it only says whose trip a NEW
|
|
812
|
-
// booking came from, and anything else opens a booking by its code and email.
|
|
813
|
-
describe('a booking made on the card', () => {
|
|
814
|
-
const conversation = 'session-a shangri-la';
|
|
815
|
-
const onTheCard = { session: 'anon-card-call', tripKey: conversation };
|
|
816
|
-
const byTheModel = { session: 'session-a', tripKey: conversation };
|
|
817
|
-
it('is found, cancelled and listed from either side', async () => {
|
|
818
|
-
await handleTool(deps, t.addToTrip, { hotel: 'hong-kong-kerry', room: 'deluxe', ...STAY }, onTheCard);
|
|
819
|
-
const made = await handleTool(deps, t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' }, onTheCard);
|
|
820
|
-
const code = made.structured.booking.confirmationCode;
|
|
821
|
-
const listed = await handleTool(deps, t.listBookings, {}, byTheModel);
|
|
822
|
-
expect(listed.structured.count).toBe(1);
|
|
823
|
-
const elsewhere = { ...onTheCard, session: 'anon-another-card-call' };
|
|
824
|
-
// Yet another connection, naming the same trip: the trip id opens nothing.
|
|
825
|
-
const byTrip = await handleTool(deps, t.cancelBooking, { booking: code }, elsewhere);
|
|
826
|
-
expect(byTrip.structured.view).toBe('booking-not-found');
|
|
827
|
-
expect((await handleTool(deps, t.listBookings, {}, elsewhere)).structured.count).toBe(0);
|
|
828
|
-
// With the email it was made with - what the card sends - it is found.
|
|
829
|
-
const preview = await handleTool(deps, t.cancelBooking, { booking: code, email: 'robin@example.com' }, elsewhere);
|
|
830
|
-
expect(preview.structured.view).toBe('cancellation-preview');
|
|
831
|
-
});
|
|
832
|
-
it('answers a trip of two stays with two codes, and says each stands alone', async () => {
|
|
833
|
-
await call(t.addToTrip, { hotel: 'shanghai-pudong', room: 'deluxe-city', ...STAY });
|
|
834
|
-
await call(t.addToTrip, {
|
|
835
|
-
hotel: 'beijing-kerry',
|
|
836
|
-
room: 'deluxe',
|
|
837
|
-
checkIn: '2026-10-21',
|
|
838
|
-
checkOut: '2026-10-23',
|
|
839
|
-
});
|
|
840
|
-
const made = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
841
|
-
expect(made.structured.view).toBe('booking');
|
|
842
|
-
expect(made.structured.bookings).toHaveLength(2);
|
|
843
|
-
expect(made.text).toMatch(/2 bookings, one for each stay/);
|
|
844
|
-
expect(made.text).toMatch(/Pudong[\s\S]*Beijing/);
|
|
845
|
-
});
|
|
846
|
-
it('keeps the trip when a stay cannot be held, and books none of it', async () => {
|
|
847
|
-
await call(t.addToTrip, { hotel: 'shanghai-pudong', room: 'deluxe-city', ...STAY });
|
|
848
|
-
await call(t.addToTrip, {
|
|
849
|
-
hotel: 'shanghai-pudong',
|
|
850
|
-
room: 'suite',
|
|
851
|
-
checkIn: '2026-10-21',
|
|
852
|
-
checkOut: '2026-10-23',
|
|
853
|
-
guests: 2,
|
|
854
|
-
});
|
|
855
|
-
// Somebody else takes the only suite in the meantime.
|
|
856
|
-
await deps.backend.requestBooking({
|
|
857
|
-
lines: [
|
|
858
|
-
{
|
|
859
|
-
kind: 'room',
|
|
860
|
-
hotelId: 'shanghai-pudong',
|
|
861
|
-
roomId: 'suite',
|
|
862
|
-
stay: { checkIn: '2026-10-21', checkOut: '2026-10-23', guests: 2 },
|
|
863
|
-
},
|
|
864
|
-
],
|
|
865
|
-
}, { name: 'Someone Else', email: 'else@example.com' }, { sessionId: 'elsewhere' });
|
|
866
|
-
const made = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
867
|
-
expect(made.structured.view).toBe('error');
|
|
868
|
-
expect(made.text).toMatch(/Nothing was booked/);
|
|
869
|
-
// Both lines are still there; the trip view names the one that went.
|
|
870
|
-
expect(deps.trips.read('session-a shangri-la')).toHaveLength(2);
|
|
871
|
-
const trip = await call(t.viewTrip);
|
|
872
|
-
expect(JSON.stringify(trip.structured.dropped)).toMatch(/Suite|suite/);
|
|
873
|
-
});
|
|
874
|
-
});
|
|
875
|
-
// ⚠️ AN ADD-ON ON A BOOKING ALREADY MADE (robin, 2026-09-28). Once booked, an
|
|
876
|
-
// airport transfer had nowhere to go; now it is the same preview-then-confirm
|
|
877
|
-
// as any other change.
|
|
878
|
-
describe('add-ons on a booking already made', () => {
|
|
879
|
-
const book = async () => {
|
|
880
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
881
|
-
const made = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
882
|
-
return made.structured.booking.confirmationCode;
|
|
883
|
-
};
|
|
884
|
-
it('lists the hotel add-ons with what the booking holds, then previews and makes the change', async () => {
|
|
885
|
-
const code = await book();
|
|
886
|
-
const listed = await call(t.listExtras, { booking: code });
|
|
887
|
-
expect(listed.structured.view).toBe('extras');
|
|
888
|
-
expect(listed.structured.forBooking.held).toEqual({});
|
|
889
|
-
const pre = await call(t.previewChange, {
|
|
890
|
-
booking: code,
|
|
891
|
-
extra: 'airport-transfer',
|
|
892
|
-
quantity: 1,
|
|
893
|
-
});
|
|
894
|
-
expect(pre.structured.view).toBe('change-preview');
|
|
895
|
-
expect(pre.structured.changeable).toBe(true);
|
|
896
|
-
expect(pre.structured.addOn).toMatchObject({ id: 'airport-transfer', from: 0, to: 1 });
|
|
897
|
-
expect(pre.text).toMatch(/would add 1× Airport transfer/i);
|
|
898
|
-
const done = await call(t.changeBooking, {
|
|
899
|
-
booking: code,
|
|
900
|
-
extra: 'airport-transfer',
|
|
901
|
-
quantity: 1,
|
|
902
|
-
confirmed: true,
|
|
903
|
-
});
|
|
904
|
-
expect(done.structured.view).toBe('changed');
|
|
905
|
-
const again = await call(t.listExtras, { booking: code });
|
|
906
|
-
expect(again.structured.forBooking.held).toEqual({
|
|
907
|
-
'airport-transfer': 1,
|
|
908
|
-
});
|
|
909
|
-
// And off again - once the guest has seen what that does. A confirmation
|
|
910
|
-
// with no preview before it IS the preview (2026-09-28 audit).
|
|
911
|
-
const unseen = await call(t.changeBooking, {
|
|
912
|
-
booking: code,
|
|
913
|
-
extra: 'airport-transfer',
|
|
914
|
-
quantity: 0,
|
|
915
|
-
confirmed: true,
|
|
916
|
-
});
|
|
917
|
-
expect(unseen.structured.view).toBe('change-preview');
|
|
918
|
-
expect(unseen.text).toMatch(/Nothing has changed yet/);
|
|
919
|
-
const off = await call(t.changeBooking, {
|
|
920
|
-
booking: code,
|
|
921
|
-
extra: 'airport-transfer',
|
|
922
|
-
quantity: 0,
|
|
923
|
-
confirmed: true,
|
|
924
|
-
});
|
|
925
|
-
expect(off.structured.view).toBe('changed');
|
|
926
|
-
});
|
|
927
|
-
it('puts an add-on on a non-refundable stay: the add-on is not the rate', async () => {
|
|
928
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'horizon-club', ...STAY });
|
|
929
|
-
const made = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
930
|
-
const code = made.structured.booking.confirmationCode;
|
|
931
|
-
const pre = await call(t.previewChange, {
|
|
932
|
-
booking: code,
|
|
933
|
-
extra: 'airport-transfer',
|
|
934
|
-
quantity: 1,
|
|
935
|
-
});
|
|
936
|
-
expect(pre.structured.changeable).toBe(true);
|
|
937
|
-
// The nights of that rate still do not move.
|
|
938
|
-
const move = await call(t.previewChange, { booking: code, checkOut: '2026-10-22' });
|
|
939
|
-
expect(move.structured.changeable).toBe(false);
|
|
940
|
-
});
|
|
941
|
-
it('names an add-on the hotel does not have rather than guessing', async () => {
|
|
942
|
-
const code = await book();
|
|
943
|
-
const pre = await call(t.previewChange, { booking: code, extra: 'helicopter', quantity: 1 });
|
|
944
|
-
expect(pre.structured.changeable).toBe(false);
|
|
945
|
-
expect(pre.text).toMatch(/offers no add-on called "helicopter"/);
|
|
946
|
-
});
|
|
947
|
-
});
|
|
948
|
-
// ⚠️ MEMORY IS THE MEMBER'S, AND ONLY WHEN ASKED (robin, 2026-09-28: 「记忆 ok」).
|
|
949
|
-
describe("a member's saved preferences", () => {
|
|
950
|
-
const member = { number: 'SLC-4471-8820', surname: 'Ruan' };
|
|
951
|
-
const later = { session: 'session-later' };
|
|
952
|
-
const bookWith = async (rememberRequests) => {
|
|
953
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
954
|
-
return call(t.requestBooking, {
|
|
955
|
-
name: 'Robin Ruan',
|
|
956
|
-
email: 'robin@example.com',
|
|
957
|
-
requests: ['high-floor', 'king-bed', 'late-arrival'],
|
|
958
|
-
...(rememberRequests === undefined ? {} : { rememberRequests }),
|
|
959
|
-
});
|
|
960
|
-
};
|
|
961
|
-
it('keeps nothing unless the guest asked', async () => {
|
|
962
|
-
await call(t.signIn, member);
|
|
963
|
-
await bookWith();
|
|
964
|
-
const next = await handleTool(deps, t.signIn, member, later);
|
|
965
|
-
expect(next.structured.member.preferences).toBeUndefined();
|
|
966
|
-
expect(next.text).not.toMatch(/Saved preferences/);
|
|
967
|
-
});
|
|
968
|
-
it('keeps the lasting ones when asked, brings them back at the next sign-in, and forgets them', async () => {
|
|
969
|
-
await call(t.signIn, member);
|
|
970
|
-
const made = await bookWith(true);
|
|
971
|
-
expect(made.text).toMatch(/Remembered on this .* membership for next time: a high floor, a king bed/);
|
|
972
|
-
// A late arrival is this week's flight, not a preference.
|
|
973
|
-
expect(made.structured.remembered).toEqual(['high-floor', 'king-bed']);
|
|
974
|
-
const next = await handleTool(deps, t.signIn, member, later);
|
|
975
|
-
expect(next.structured.member.preferences).toEqual([
|
|
976
|
-
'high-floor',
|
|
977
|
-
'king-bed',
|
|
978
|
-
]);
|
|
979
|
-
expect(next.text).toMatch(/Saved preferences on this membership: a high floor, a king bed/);
|
|
980
|
-
// And on every answer's badge, so a later card can tick the form.
|
|
981
|
-
const hotel = await handleTool(deps, t.getHotel, { hotel: 'hong-kong-island', ...STAY }, later);
|
|
982
|
-
expect(hotel.structured.member.preferences).toHaveLength(2);
|
|
983
|
-
const forgot = await handleTool(deps, t.forgetPreferences, {}, later);
|
|
984
|
-
expect(forgot.text).toMatch(/Forgot the room preferences/);
|
|
985
|
-
const after = await handleTool(deps, t.signIn, member, { session: 'session-third' });
|
|
986
|
-
expect(after.structured.member.preferences).toBeUndefined();
|
|
987
|
-
});
|
|
988
|
-
it('says it kept nothing when nobody is signed in', async () => {
|
|
989
|
-
const made = await bookWith(true);
|
|
990
|
-
expect(made.text).toMatch(/Nothing was remembered: saved preferences belong to a membership/);
|
|
991
|
-
});
|
|
992
|
-
it('tells every card whether anyone is signed in, false included', async () => {
|
|
993
|
-
const res = await call(t.listHotels, { city: 'Hong Kong' });
|
|
994
|
-
expect(res.structured.signedIn).toBe(false);
|
|
995
|
-
await call(t.signIn, member);
|
|
996
|
-
const signed = await call(t.listHotels, { city: 'Hong Kong' });
|
|
997
|
-
expect(signed.structured.signedIn).toBe(true);
|
|
998
|
-
});
|
|
999
|
-
});
|
|
1000
|
-
describe('the next step', () => {
|
|
1001
|
-
it('leaves the order to the list itself: no price chip under it', async () => {
|
|
1002
|
-
const res = await call(t.listHotels, { city: 'Hong Kong' });
|
|
1003
|
-
expect(res.structured.suggestions).toBeUndefined();
|
|
1004
|
-
// What the sort row repeats when pressed: the same search, another order.
|
|
1005
|
-
expect(res.structured.ask).toEqual({ city: 'Hong Kong' });
|
|
1006
|
-
});
|
|
1007
|
-
it('sends only what needs the model to the chat, named in full', async () => {
|
|
1008
|
-
const res = await call(t.getHotel, { hotel: 'hong-kong-island', ...STAY });
|
|
1009
|
-
const chips = res.structured.suggestions;
|
|
1010
|
-
expect(chips.length).toBeLessThanOrEqual(3);
|
|
1011
|
-
const compare = chips.find((c) => c.say);
|
|
1012
|
-
expect(compare?.say).toBe('Compare Island Shangri-La, Hong Kong with the other Hong Kong hotels');
|
|
1013
|
-
for (const c of chips) {
|
|
1014
|
-
expect(c.label).not.toMatch(/\b(tap|click|below|above|this one)\b/i);
|
|
1015
|
-
expect(c.say ?? '').not.toMatch(/\b(tap|click|below|above|this one)\b/i);
|
|
1016
|
-
}
|
|
1017
|
-
});
|
|
1018
|
-
it('offers a transfer after a booking as the change preview, and only when the booking has none', async () => {
|
|
1019
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
1020
|
-
const made = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
1021
|
-
const code = made.structured.booking.confirmationCode;
|
|
1022
|
-
const offered = made.structured.suggestions.find((x) => x.label.startsWith('Add '));
|
|
1023
|
-
expect(offered?.label).toMatch(new RegExp(`^Add ".*transfer.*" to ${code}$`, 'i'));
|
|
1024
|
-
expect(offered?.ask).toEqual({
|
|
1025
|
-
tool: 'previewChange',
|
|
1026
|
-
args: { booking: code, extra: 'airport-transfer', quantity: 1 },
|
|
1027
|
-
});
|
|
1028
|
-
// Pressed, it previews - it does not change the booking.
|
|
1029
|
-
const pressed = await call(t[offered?.ask?.tool], offered?.ask?.args);
|
|
1030
|
-
expect(pressed.structured.view).toBe('change-preview');
|
|
1031
|
-
// A booking made WITH a transfer is not offered another.
|
|
1032
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
1033
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', extra: 'airport-transfer' });
|
|
1034
|
-
const withOne = await call(t.requestBooking, {
|
|
1035
|
-
name: 'Robin Ruan',
|
|
1036
|
-
email: 'robin@example.com',
|
|
1037
|
-
});
|
|
1038
|
-
expect(withOne.structured.suggestions.some((x) => x.label.startsWith('Add '))).toBe(false);
|
|
1039
|
-
});
|
|
1040
|
-
it('draws no suggestions on an answer with nothing to lead to', async () => {
|
|
1041
|
-
const res = await call(t.viewTrip);
|
|
1042
|
-
expect(res.structured.suggestions).toBeUndefined();
|
|
1043
|
-
});
|
|
1044
|
-
// ⚠️ robin, 2026-09-29: 「改成卡片直接加进行程吧」 - the plan's chip was a chat
|
|
1045
|
-
// message, held under the host's caution banner. It is the card's own calls.
|
|
1046
|
-
it('puts a plan’s stays in the trip from the card, on the rooms it priced, and then stops offering', async () => {
|
|
1047
|
-
const plan = {
|
|
1048
|
-
title: 'Hong Kong, then Shanghai',
|
|
1049
|
-
checkIn: '2026-10-19',
|
|
1050
|
-
checkOut: '2026-10-22',
|
|
1051
|
-
guests: 2,
|
|
1052
|
-
days: [
|
|
1053
|
-
{ label: 'Arrive', items: ['hong-kong-island'] },
|
|
1054
|
-
{ label: 'Across', items: ['shanghai-pudong'] },
|
|
1055
|
-
{ label: 'The Bund', items: [] },
|
|
1056
|
-
],
|
|
1057
|
-
};
|
|
1058
|
-
const res = await call(t.itinerary, plan);
|
|
1059
|
-
const chip = res.structured.suggestions.find((c) => c.asks);
|
|
1060
|
-
expect(chip?.label).toBe('Put these 2 stays in the trip');
|
|
1061
|
-
expect(chip?.asks?.map((a) => [a.tool, a.args.hotel, a.args.checkIn, a.args.checkOut])).toEqual([
|
|
1062
|
-
['addToTrip', 'hong-kong-island', '2026-10-19', '2026-10-20'],
|
|
1063
|
-
['addToTrip', 'shanghai-pudong', '2026-10-20', '2026-10-22'],
|
|
1064
|
-
]);
|
|
1065
|
-
// The card makes the calls; `say` is only for a card from before `asks`,
|
|
1066
|
-
// which would otherwise draw nothing at all (robin, 2026-09-29: 「好像啥按钮
|
|
1067
|
-
// 都没有了」).
|
|
1068
|
-
expect(chip?.say).toBe('Put the stays on this plan in the trip');
|
|
1069
|
-
// And a model asked in the chat has each stay as the trip takes it.
|
|
1070
|
-
expect(res.text).toMatch(/\(`hong-kong-island`\) 2026-10-19 to 2026-10-20, room `[a-z-]+`/);
|
|
1071
|
-
for (const a of chip?.asks ?? []) {
|
|
1072
|
-
const added = await call(t.addToTrip, a.args);
|
|
1073
|
-
expect(added.structured.view).toBe('trip');
|
|
1074
|
-
}
|
|
1075
|
-
const again = await call(t.itinerary, plan);
|
|
1076
|
-
expect((again.structured.suggestions ?? []).some((c) => c.asks)).toBe(false);
|
|
1077
|
-
});
|
|
1078
|
-
it('leaves a plan with no nights to the model, which asks for them', async () => {
|
|
1079
|
-
const res = await call(t.itinerary, {
|
|
1080
|
-
title: 'Hong Kong',
|
|
1081
|
-
days: [{ label: 'Arrive', items: ['hong-kong-island'] }],
|
|
1082
|
-
});
|
|
1083
|
-
expect(res.structured.suggestions).toEqual([
|
|
1084
|
-
{
|
|
1085
|
-
label: 'Put the stays on this plan in the trip',
|
|
1086
|
-
say: 'Put the stays on this plan in the trip',
|
|
1087
|
-
},
|
|
1088
|
-
]);
|
|
1089
|
-
});
|
|
1090
|
-
});
|
|
1091
|
-
// ⚠️ AN ADD-ON GOES WITH A STAY (robin, 2026-09-28: 「可以不订房间,直接订接机的吗」).
|
|
1092
|
-
// The trip with a lone transfer showed the whole booking form, and the request
|
|
1093
|
-
// then refused it; the trip now says so first, with the two ways it can be done.
|
|
1094
|
-
describe('an add-on with no room beside it', () => {
|
|
1095
|
-
it('is named in the trip before anyone fills in the form, and the request still refuses it', async () => {
|
|
1096
|
-
const trip = await call(t.addToTrip, { hotel: 'shanghai-pudong', extra: 'airport-transfer' });
|
|
1097
|
-
expect(trip.structured.needsRoom).toEqual([
|
|
1098
|
-
expect.objectContaining({ hotelId: 'shanghai-pudong', extras: [expect.any(String)] }),
|
|
1099
|
-
]);
|
|
1100
|
-
expect(trip.text).toMatch(/goes with a stay there, so this trip cannot be requested as it is/);
|
|
1101
|
-
const made = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
1102
|
-
expect(made.structured.view).toBe('error');
|
|
1103
|
-
expect(made.text).toMatch(/An add-on goes with a stay/);
|
|
1104
|
-
});
|
|
1105
|
-
it('is nothing to flag once a room at that hotel is in the trip', async () => {
|
|
1106
|
-
await call(t.addToTrip, { hotel: 'shanghai-pudong', extra: 'airport-transfer' });
|
|
1107
|
-
const trip = await call(t.addToTrip, {
|
|
1108
|
-
hotel: 'shanghai-pudong',
|
|
1109
|
-
room: 'deluxe-city',
|
|
1110
|
-
...STAY,
|
|
1111
|
-
});
|
|
1112
|
-
expect(trip.structured.needsRoom).toBeUndefined();
|
|
1113
|
-
});
|
|
1114
|
-
});
|
|
1115
|
-
// ⚠️ THE WAYS IN, COUNTED ACROSS EVERYTHING (robin, 2026-09-28: 「是否要加一个城市
|
|
1116
|
-
// 筛选之类的功能,然后可以选择查看更多」).
|
|
1117
|
-
describe('browsing more than one page', () => {
|
|
1118
|
-
it('offers every country the matches are in, counted across all of them, and the next page', async () => {
|
|
1119
|
-
const all = await call(t.listHotels, { limit: 3, all: true });
|
|
1120
|
-
const places = all.structured.places;
|
|
1121
|
-
expect(places.length).toBeGreaterThan(1);
|
|
1122
|
-
expect(places.reduce((n, p) => n + p.count, 0)).toBe(all.structured.total);
|
|
1123
|
-
expect(all.text).toMatch(/`offset` 3 is the next page/);
|
|
1124
|
-
const next = await call(t.listHotels, { limit: 3, offset: 3, all: true });
|
|
1125
|
-
const first = all.structured.hotels.map((h) => h.id);
|
|
1126
|
-
const second = next.structured.hotels.map((h) => h.id);
|
|
1127
|
-
expect(second).toHaveLength(3);
|
|
1128
|
-
expect(second.some((id) => first.includes(id))).toBe(false);
|
|
1129
|
-
expect(next.text).toMatch(/4 to 6 in this answer/);
|
|
1130
|
-
});
|
|
1131
|
-
it('offers the cities inside a country, and nothing inside one city', async () => {
|
|
1132
|
-
const china = await call(t.listHotels, { city: 'China' });
|
|
1133
|
-
const cities = china.structured.places ?? [];
|
|
1134
|
-
expect(cities.map((c) => c.place)).toEqual(expect.arrayContaining(['Shanghai', 'Beijing']));
|
|
1135
|
-
const shanghai = await call(t.listHotels, { city: 'Shanghai' });
|
|
1136
|
-
expect(shanghai.structured.places).toBeUndefined();
|
|
1137
|
-
expect(shanghai.structured.ask.city).toBe('Shanghai');
|
|
1138
|
-
});
|
|
1139
|
-
});
|
|
1140
|
-
// ⚠️ SOMEWHERE FIRST, THEN A HOTEL (robin, 2026-09-28: 「和温德姆一样,先让人选国家和
|
|
1141
|
-
// 城市,然后再选酒店」).
|
|
1142
|
-
describe('choosing where before which hotel', () => {
|
|
1143
|
-
it('answers a browse with nothing named with the places, by region, country and city', async () => {
|
|
1144
|
-
const res = await call(t.listHotels, {});
|
|
1145
|
-
expect(res.structured.view).toBe('destinations');
|
|
1146
|
-
const regions = res.structured.regions;
|
|
1147
|
-
expect(regions.map((r) => r.name)).toEqual(['Asia']);
|
|
1148
|
-
const [china] = regions;
|
|
1149
|
-
expect(china?.count).toBe(res.structured.total);
|
|
1150
|
-
// Every hotel counted once, down to its city.
|
|
1151
|
-
const cities = china?.countries.flatMap((c) => c.cities) ?? [];
|
|
1152
|
-
expect(cities.reduce((n, c) => n + c.count, 0)).toBe(res.structured.total);
|
|
1153
|
-
expect(res.text).toMatch(/to choose from by region, country and city - /);
|
|
1154
|
-
// Every place, for a host with no card.
|
|
1155
|
-
expect(res.text).toMatch(/China \(5\)/);
|
|
1156
|
-
expect(res.structured.hotels).toBeUndefined();
|
|
1157
|
-
});
|
|
1158
|
-
it('answers a country of many cities with its cities, and `all` with its hotels', async () => {
|
|
1159
|
-
// The fixture's China is three cities; a country of four or more gets the cities.
|
|
1160
|
-
const wide = {
|
|
1161
|
-
...DATA,
|
|
1162
|
-
hotels: [
|
|
1163
|
-
...DATA.hotels,
|
|
1164
|
-
...['Hangzhou', 'Suzhou'].map((city, i) => {
|
|
1165
|
-
const base = DATA.hotels.find((h) => h.country === 'China');
|
|
1166
|
-
if (!base)
|
|
1167
|
-
throw new Error('the fixture has no hotel in China');
|
|
1168
|
-
return {
|
|
1169
|
-
...base,
|
|
1170
|
-
id: `extra-${i}`,
|
|
1171
|
-
name: `Shangri-La ${city}`,
|
|
1172
|
-
city,
|
|
1173
|
-
cityId: city.toLowerCase(),
|
|
1174
|
-
};
|
|
1175
|
-
}),
|
|
1176
|
-
],
|
|
1177
|
-
};
|
|
1178
|
-
deps = { ...deps, backend: createDemoHotels({ data: wide, now: () => clock, rng: () => 0.5 }) };
|
|
1179
|
-
const res = await call(t.listHotels, { city: 'China' });
|
|
1180
|
-
expect(res.structured.view).toBe('destinations');
|
|
1181
|
-
const scope = res.structured.scope;
|
|
1182
|
-
expect(scope.name).toBe('China');
|
|
1183
|
-
expect(scope.cities.length).toBeGreaterThanOrEqual(4);
|
|
1184
|
-
expect(res.text).toMatch(/`all` lists every one in China/);
|
|
1185
|
-
const all = await call(t.listHotels, { city: 'China', all: true });
|
|
1186
|
-
expect(all.structured.view).toBe('hotels');
|
|
1187
|
-
});
|
|
1188
|
-
it('goes straight to the hotels of a city, or of a country of few cities', async () => {
|
|
1189
|
-
expect((await call(t.listHotels, { city: 'Hong Kong' })).structured.view).toBe('hotels');
|
|
1190
|
-
expect((await call(t.listHotels, { city: 'China' })).structured.view).toBe('hotels');
|
|
1191
|
-
});
|
|
1192
|
-
it('lists hotels for words, and offers the cities they are in as a way to narrow', async () => {
|
|
1193
|
-
const res = await call(t.listHotels, { query: 'pool' });
|
|
1194
|
-
expect(res.structured.view).toBe('hotels');
|
|
1195
|
-
const places = res.structured.places ?? [];
|
|
1196
|
-
expect(places.length).toBeGreaterThan(1);
|
|
1197
|
-
});
|
|
1198
|
-
});
|
|
1199
|
-
// ⚠️ A STRANGER'S EMAIL IS NOT A KEY TO THEIR TRIPS (2026-09-28 audit).
|
|
1200
|
-
describe('finding a booking made elsewhere', () => {
|
|
1201
|
-
it('needs the code and the email together; the email alone lists nothing', async () => {
|
|
1202
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
1203
|
-
const made = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
1204
|
-
const code = made.structured.booking.confirmationCode;
|
|
1205
|
-
const elsewhere = { session: 'session-elsewhere' };
|
|
1206
|
-
const byEmail = await handleTool(deps, t.listBookings, { email: 'robin@example.com' }, elsewhere);
|
|
1207
|
-
expect(byEmail.structured.count).toBe(0);
|
|
1208
|
-
const both = await handleTool(deps, t.listBookings, { email: 'robin@example.com', booking: code }, elsewhere);
|
|
1209
|
-
expect(both.structured.count).toBe(1);
|
|
1210
|
-
// Found, it is this conversation's now: its code alone opens it here, so
|
|
1211
|
-
// the card's Cancel - which sends nothing more - works on it.
|
|
1212
|
-
const cancel = await handleTool(deps, t.previewCancellation, { booking: code }, elsewhere);
|
|
1213
|
-
expect(cancel.structured.view).toBe('cancellation-preview');
|
|
1214
|
-
// And nothing in any answer carries the conversation it was made in.
|
|
1215
|
-
expect(JSON.stringify(both.structured)).not.toContain('session-');
|
|
1216
|
-
expect(JSON.stringify(both.structured)).not.toContain('owners');
|
|
1217
|
-
const third = { session: 'session-third' };
|
|
1218
|
-
const wrong = await handleTool(deps, t.listBookings, { email: 'someone@example.com', booking: code }, third);
|
|
1219
|
-
expect(wrong.structured.count).toBe(0);
|
|
1220
|
-
expect(wrong.structured.notFound).toBe(code);
|
|
1221
|
-
expect(wrong.text).toMatch(/Both are needed/);
|
|
1222
|
-
// The id is not a code: with an email, only the code opens a booking.
|
|
1223
|
-
const id = both.structured.bookings[0]?.id ?? '';
|
|
1224
|
-
const byId = await handleTool(deps, t.listBookings, { email: 'robin@example.com', booking: id }, third);
|
|
1225
|
-
expect(byId.structured.count).toBe(0);
|
|
1226
|
-
});
|
|
1227
|
-
it('says a miss even when the conversation has bookings to list', async () => {
|
|
1228
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
1229
|
-
await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
1230
|
-
const res = await call(t.listBookings, { booking: 'SL-NOPE00', email: 'robin@example.com' });
|
|
1231
|
-
expect(res.structured.count).toBe(1);
|
|
1232
|
-
expect(res.structured.notFound).toBe('SL-NOPE00');
|
|
1233
|
-
expect(res.text).toMatch(/^No booking matches that confirmation code and email/);
|
|
1234
|
-
});
|
|
1235
|
-
it('stops taking guesses at a code and an email after a few misses', async () => {
|
|
1236
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
1237
|
-
const made = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
1238
|
-
const code = made.structured.booking.confirmationCode;
|
|
1239
|
-
const guessing = { deps: { ...deps, misses: new Misses(() => clock) } };
|
|
1240
|
-
const guess = (booking, session) => handleTool(guessing.deps, t.listBookings, { email: 'robin@example.com', booking }, { session });
|
|
1241
|
-
for (let i = 0; i < MAX_MISSES; i += 1)
|
|
1242
|
-
await guess(`SL-WRONG${i}`, 'session-guesser');
|
|
1243
|
-
// The right code now waits too - from this conversation, and for this
|
|
1244
|
-
// email from another one.
|
|
1245
|
-
const blocked = await guess(code, 'session-guesser');
|
|
1246
|
-
expect(blocked.structured.count).toBe(0);
|
|
1247
|
-
expect(blocked.structured.tooMany).toBe(true);
|
|
1248
|
-
expect(blocked.text).toMatch(/paused for a quarter of an hour/);
|
|
1249
|
-
expect((await guess(code, 'session-other')).structured.tooMany).toBe(true);
|
|
1250
|
-
// A conversation's own booking, with no email, is never a guess.
|
|
1251
|
-
expect((await call(t.listBookings, {})).structured.count).toBe(1);
|
|
1252
|
-
});
|
|
1253
|
-
});
|
|
1254
|
-
// ⚠️ A CONFIRMATION CONFIRMS WHAT WAS SHOWN (2026-09-28 audit).
|
|
1255
|
-
describe('confirming a cancellation', () => {
|
|
1256
|
-
it('shows the fee first when nothing was previewed, then cancels on the next confirmation', async () => {
|
|
1257
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
1258
|
-
const made = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
1259
|
-
const code = made.structured.booking.confirmationCode;
|
|
1260
|
-
const first = await call(t.cancelBooking, { booking: code, confirmed: true });
|
|
1261
|
-
expect(first.structured.view).toBe('cancellation-preview');
|
|
1262
|
-
expect(first.text).toMatch(/Nothing is cancelled yet/);
|
|
1263
|
-
const second = await call(t.cancelBooking, { booking: code, confirmed: true });
|
|
1264
|
-
expect(second.structured.view).toBe('cancelled');
|
|
1265
|
-
});
|
|
1266
|
-
});
|
|
1267
|
-
// ⚠️ A PLACE, THEN THE DATES, THEN HOTELS PRICED FOR THEM (robin, 2026-09-28,
|
|
1268
|
-
// with Trip.com and shangri-la.com as the reference).
|
|
1269
|
-
describe('a list for the guest’s nights', () => {
|
|
1270
|
-
it('prices every hotel for the whole stay, taxes in, and keeps the nights on the search', async () => {
|
|
1271
|
-
const res = await call(t.listHotels, { city: 'Hong Kong', ...STAY });
|
|
1272
|
-
const tiles = res.structured.hotels;
|
|
1273
|
-
const island = tiles.find((h) => h.id === 'hong-kong-island');
|
|
1274
|
-
// The same number the hotel's own page gives for its cheapest room.
|
|
1275
|
-
expect(island?.forStay).toMatchObject({ total: 'HK$8,588', nights: 2 });
|
|
1276
|
-
expect(res.structured.stay).toEqual(STAY);
|
|
1277
|
-
expect(res.structured.ask).toMatchObject({ city: 'Hong Kong', ...STAY });
|
|
1278
|
-
expect(res.text).toMatch(/Priced for 2026-10-19 to 2026-10-21: each price is that hotel's cheapest room for the whole stay, taxes included/);
|
|
1279
|
-
});
|
|
1280
|
-
it('orders by the price of those nights, and says which have no room', async () => {
|
|
1281
|
-
const res = await call(t.listHotels, {
|
|
1282
|
-
city: 'Hong Kong',
|
|
1283
|
-
...STAY,
|
|
1284
|
-
guests: 4,
|
|
1285
|
-
sort: 'price-low',
|
|
1286
|
-
});
|
|
1287
|
-
const tiles = res.structured.hotels;
|
|
1288
|
-
// Four in one room: nothing at the Island sleeps four, so it has no room.
|
|
1289
|
-
expect(tiles.find((h) => h.id === 'hong-kong-island')?.noRoomsForStay).toMatch(/No room/);
|
|
1290
|
-
// Priced first, cheapest first; the ones with no room last.
|
|
1291
|
-
const priced = tiles.filter((h) => h.forStay);
|
|
1292
|
-
expect(tiles.slice(0, priced.length)).toEqual(priced);
|
|
1293
|
-
});
|
|
1294
|
-
it('keeps the nights on the destinations, for the place picked next', async () => {
|
|
1295
|
-
const res = await call(t.listHotels, { ...STAY });
|
|
1296
|
-
expect(res.structured.view).toBe('destinations');
|
|
1297
|
-
expect(res.structured.ask).toMatchObject(STAY);
|
|
1298
|
-
});
|
|
1299
|
-
});
|
|
1300
|
-
// ⚠️ FILTERS, AS A BOOKING SITE HAS THEM (robin, 2026-09-28: 「筛选可以,加上」).
|
|
1301
|
-
describe('filtering a list', () => {
|
|
1302
|
-
const ids = (res) => (res.structured.hotels ?? []).map((h) => h.id);
|
|
1303
|
-
it('offers only the chips that narrow, each with what it would leave', async () => {
|
|
1304
|
-
const res = await call(t.listHotels, { city: 'Hong Kong' });
|
|
1305
|
-
const r = res.structured.refine;
|
|
1306
|
-
expect(r.brands.map((b) => [b.name, b.count])).toEqual([
|
|
1307
|
-
['Shangri-La', 2],
|
|
1308
|
-
['JEN', 1],
|
|
1309
|
-
['Kerry', 1],
|
|
1310
|
-
]);
|
|
1311
|
-
// Every Hong Kong hotel has a pool: a "pool" chip here would change nothing.
|
|
1312
|
-
expect(r.facilities.map((x) => x.key)).toEqual(['spa']);
|
|
1313
|
-
expect(r.room).toEqual([]);
|
|
1314
|
-
});
|
|
1315
|
-
it('narrows by brand and by what a hotel has, and keeps them on the search', async () => {
|
|
1316
|
-
const kerry = await call(t.listHotels, { city: 'Hong Kong', brand: 'Kerry' });
|
|
1317
|
-
expect(ids(kerry)).toEqual(['hong-kong-kerry']);
|
|
1318
|
-
expect(kerry.structured.ask).toMatchObject({ city: 'Hong Kong', brand: 'Kerry' });
|
|
1319
|
-
expect(kerry.text).toMatch(/Filtered to Kerry/);
|
|
1320
|
-
const spa = await call(t.listHotels, { city: 'Hong Kong', facilities: ['spa'] });
|
|
1321
|
-
expect(ids(spa).sort()).toEqual(['hong-kong-island', 'hong-kong-kowloon']);
|
|
1322
|
-
});
|
|
1323
|
-
it('prices, on a dated list, the room the filter asks for', async () => {
|
|
1324
|
-
const plain = await call(t.listHotels, { city: 'Hong Kong', ...STAY });
|
|
1325
|
-
const withBreakfast = await call(t.listHotels, { city: 'Hong Kong', ...STAY, breakfast: true });
|
|
1326
|
-
const island = (res) => res.structured.hotels.find((h) => h.id === 'hong-kong-island')?.forStay?.room;
|
|
1327
|
-
// The Island's cheapest room has no breakfast; with the filter, the priced
|
|
1328
|
-
// room is the cheapest that does.
|
|
1329
|
-
expect(island(plain)).toBe('Deluxe Peak View Room');
|
|
1330
|
-
expect(island(withBreakfast)).not.toBe('Deluxe Peak View Room');
|
|
1331
|
-
});
|
|
1332
|
-
it('says the filters emptied a list, keeps the way back, and does not widen the place', async () => {
|
|
1333
|
-
const res = await call(t.listHotels, { city: 'Hong Kong', brand: 'JEN', facilities: ['spa'] });
|
|
1334
|
-
expect(res.structured.view).toBe('no-hotels');
|
|
1335
|
-
expect(res.structured.beforeFilters).toBe(4);
|
|
1336
|
-
expect(res.structured.widened).toBeUndefined();
|
|
1337
|
-
expect(res.text).toMatch(/None of the 4 hotels here has JEN, spa together/);
|
|
1338
|
-
const r = res.structured.refine;
|
|
1339
|
-
expect(r.brands.find((b) => b.on)?.name).toBe('JEN');
|
|
1340
|
-
});
|
|
1341
|
-
it('lists hotels, not places, when a filter is the only thing asked', async () => {
|
|
1342
|
-
const res = await call(t.listHotels, { brand: 'Kerry' });
|
|
1343
|
-
expect(res.structured.view).toBe('hotels');
|
|
1344
|
-
});
|
|
1345
|
-
});
|
|
1346
|
-
describe('room terms on a dated list', () => {
|
|
1347
|
-
it('always offers free cancellation and breakfast there: they change the room priced', async () => {
|
|
1348
|
-
const res = await call(t.listHotels, { city: 'Hong Kong', ...STAY });
|
|
1349
|
-
const room = res.structured.refine.room.map((x) => x.key);
|
|
1350
|
-
expect(room).toEqual(['freeCancellation', 'breakfast']);
|
|
1351
|
-
});
|
|
1352
|
-
});
|
|
1353
|
-
describe('a list one filter emptied', () => {
|
|
1354
|
-
it('says it plainly - "together" is for several', async () => {
|
|
1355
|
-
const res = await call(t.listHotels, { city: 'Shanghai', brand: 'JEN' });
|
|
1356
|
-
expect(res.text).toMatch(/None of the \d+ hotels here is JEN\./);
|
|
1357
|
-
expect(res.text).not.toMatch(/together/);
|
|
1358
|
-
});
|
|
1359
|
-
});
|
|
1360
|
-
// ⚠️ THE 2026-09-28 FULL AUDIT, server side: each of these was reproduced
|
|
1361
|
-
// against the running connector before it was fixed.
|
|
1362
|
-
describe('the audit of 2026-09-28', () => {
|
|
1363
|
-
const pressed = (c, group) => group === 'brands'
|
|
1364
|
-
? { brand: c.name }
|
|
1365
|
-
: group === 'facilities'
|
|
1366
|
-
? { facilities: [c.key] }
|
|
1367
|
-
: { [String(c.key)]: true };
|
|
1368
|
-
it.each([
|
|
1369
|
-
['a ceiling', { city: 'Hong Kong', maxNightly: 3000 }],
|
|
1370
|
-
['a ceiling on named nights', { city: 'Hong Kong', maxNightly: 3000, ...STAY }],
|
|
1371
|
-
])('counts every filter chip as what pressing it lists, under %s', async (_, args) => {
|
|
1372
|
-
const res = await call(t.listHotels, args);
|
|
1373
|
-
const refine = res.structured.refine;
|
|
1374
|
-
for (const group of ['brands', 'facilities', 'room']) {
|
|
1375
|
-
for (const chip of refine[group]) {
|
|
1376
|
-
if (chip.on)
|
|
1377
|
-
continue;
|
|
1378
|
-
const after = await call(t.listHotels, { ...args, ...pressed(chip, group) });
|
|
1379
|
-
expect([group, chip.name ?? chip.key, after.structured.total ?? 0]).toEqual([
|
|
1380
|
-
group,
|
|
1381
|
-
chip.name ?? chip.key,
|
|
1382
|
-
chip.count,
|
|
1383
|
-
]);
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
});
|
|
1387
|
-
it('says a ceiling emptied a filtered list, not the filter', async () => {
|
|
1388
|
-
const res = await call(t.listHotels, { city: 'Hong Kong', brand: 'Kerry', maxNightly: 1000 });
|
|
1389
|
-
expect(res.text).toMatch(/^Nothing under HK\$1,000 a night/);
|
|
1390
|
-
expect(res.text).not.toMatch(/None of the/);
|
|
1391
|
-
});
|
|
1392
|
-
it('keeps the order and the ceiling on the way out of a filter that emptied the list', async () => {
|
|
1393
|
-
const res = await call(t.listHotels, {
|
|
1394
|
-
city: 'Hong Kong',
|
|
1395
|
-
brand: 'Kerry',
|
|
1396
|
-
facilities: ['spa'],
|
|
1397
|
-
sort: 'rating',
|
|
1398
|
-
...STAY,
|
|
1399
|
-
});
|
|
1400
|
-
if (res.structured.ask) {
|
|
1401
|
-
expect(res.structured.ask).toMatchObject({ sort: 'rating', city: 'Hong Kong' });
|
|
1402
|
-
expect(res.structured.stay).toMatchObject(STAY);
|
|
1403
|
-
}
|
|
1404
|
-
});
|
|
1405
|
-
it('keeps `all` on the search it echoes, so a re-query stays a list', async () => {
|
|
1406
|
-
const res = await call(t.listHotels, { all: true });
|
|
1407
|
-
expect(res.structured.view).toBe('hotels');
|
|
1408
|
-
expect(res.structured.ask).toMatchObject({ all: true });
|
|
1409
|
-
const again = await call(t.listHotels, res.structured.ask);
|
|
1410
|
-
expect(again.structured.view).toBe('hotels');
|
|
1411
|
-
});
|
|
1412
|
-
it('puts hotels with no room for the nights last, and never counts them over a ceiling', async () => {
|
|
1413
|
-
const three = { ...STAY, guests: 3 };
|
|
1414
|
-
const high = await call(t.listHotels, { city: 'Hong Kong', ...three, sort: 'price-high' });
|
|
1415
|
-
const tiles = high.structured.hotels;
|
|
1416
|
-
const firstFull = tiles.findIndex((h) => h.noRoomsForStay);
|
|
1417
|
-
expect(firstFull).toBeGreaterThan(0);
|
|
1418
|
-
expect(tiles.slice(firstFull).every((h) => h.noRoomsForStay)).toBe(true);
|
|
1419
|
-
const capped = await call(t.listHotels, { city: 'Hong Kong', ...three, maxNightly: 100000 });
|
|
1420
|
-
expect(capped.structured.overBudget).toBeUndefined();
|
|
1421
|
-
expect(capped.text).not.toMatch(/over the ceiling/);
|
|
1422
|
-
});
|
|
1423
|
-
it('quotes the misses on named nights at what those nights cost', async () => {
|
|
1424
|
-
const weekend = { checkIn: '2026-10-23', checkOut: '2026-10-25', guests: 2 };
|
|
1425
|
-
const res = await call(t.listHotels, { city: 'Hong Kong', ...weekend, maxNightly: 500 });
|
|
1426
|
-
const [first] = res.structured.nearest;
|
|
1427
|
-
expect(first?.forStay?.nightly).toBeDefined();
|
|
1428
|
-
expect(res.text).toContain(`at ${first?.forStay?.nightly}`);
|
|
1429
|
-
});
|
|
1430
|
-
it('says a widened search came back over the ceiling rather than empty', async () => {
|
|
1431
|
-
const res = await call(t.listHotels, { city: 'Hong Kong', query: 'helipad', maxNightly: 500 });
|
|
1432
|
-
expect(res.text).toMatch(/Nothing matched Hong Kong and "helipad", so this looked in Hong Kong/);
|
|
1433
|
-
expect(res.text).toMatch(/Nothing under HK\$500 a night/);
|
|
1434
|
-
expect(res.text).not.toMatch(/found nothing either/);
|
|
1435
|
-
});
|
|
1436
|
-
it('offers no free cancellation once the window has closed', async () => {
|
|
1437
|
-
// Arriving tomorrow: a 48-hour rule closed yesterday; a 24-hour one has not.
|
|
1438
|
-
const soon = { checkIn: '2026-10-02', checkOut: '2026-10-03', guests: 2 };
|
|
1439
|
-
const island = await call(t.getHotel, { hotel: 'hong-kong-island', ...soon });
|
|
1440
|
-
const rooms = island.structured.rooms;
|
|
1441
|
-
expect(rooms.some((r) => r.refundable)).toBe(true);
|
|
1442
|
-
expect(rooms.every((r) => !r.freeCancellationUntil)).toBe(true);
|
|
1443
|
-
expect(island.text).not.toMatch(/free to cancel/);
|
|
1444
|
-
const free = await call(t.listHotels, { city: 'Hong Kong', ...soon, freeCancellation: true });
|
|
1445
|
-
const ids = free.structured.hotels.map((h) => h.id).sort();
|
|
1446
|
-
expect(ids).toEqual(['hong-kong-jen', 'hong-kong-kowloon']);
|
|
1447
|
-
});
|
|
1448
|
-
it('adds an add-on without re-pricing the room, whoever is signed in now', async () => {
|
|
1449
|
-
await call(t.signIn, { number: 'SLC-4471-8820', surname: 'Ruan' });
|
|
1450
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'horizon-club', ...STAY });
|
|
1451
|
-
const made = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
1452
|
-
const booking = made.structured.booking;
|
|
1453
|
-
await call(t.signOut);
|
|
1454
|
-
const extras = await call(t.listExtras, { booking: booking.confirmationCode });
|
|
1455
|
-
const transfer = extras.structured.extras.find((e) => e.id === 'airport-transfer');
|
|
1456
|
-
const pre = await call(t.previewChange, {
|
|
1457
|
-
booking: booking.confirmationCode,
|
|
1458
|
-
extra: 'airport-transfer',
|
|
1459
|
-
quantity: 1,
|
|
1460
|
-
});
|
|
1461
|
-
// The difference is the transfer and its tax - nothing from the room.
|
|
1462
|
-
const price = Number(String(transfer?.price).replace(/[^\d.]/g, ''));
|
|
1463
|
-
const diff = Number(String(pre.structured.difference).replace(/[^\d.]/g, ''));
|
|
1464
|
-
expect(diff).toBe(price + Math.round((price * 13) / 100));
|
|
1465
|
-
expect(pre.structured.refundDue).toBe(false);
|
|
1466
|
-
});
|
|
1467
|
-
it('names what a change changes', async () => {
|
|
1468
|
-
await call(t.addToTrip, { hotel: 'hong-kong-kowloon', room: 'deluxe', rooms: 2, ...STAY });
|
|
1469
|
-
const made = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
1470
|
-
const code = made.structured.booking.confirmationCode;
|
|
1471
|
-
const fewer = await call(t.previewChange, { booking: code, rooms: 1 });
|
|
1472
|
-
expect(fewer.text).toMatch(/would go from 2 rooms to 1 room\./);
|
|
1473
|
-
const none = await call(t.previewChange, { booking: code, rooms: 0 });
|
|
1474
|
-
expect(none.structured.changeable).toBe(false);
|
|
1475
|
-
expect(none.text).toMatch(/at least one room/);
|
|
1476
|
-
const same = await call(t.previewChange, {
|
|
1477
|
-
booking: code,
|
|
1478
|
-
checkOut: '2026-10-22',
|
|
1479
|
-
extra: 'airport-transfer',
|
|
1480
|
-
quantity: 0,
|
|
1481
|
-
});
|
|
1482
|
-
expect(same.text).toMatch(/would move to 2026-10-19 - 2026-10-22\./);
|
|
1483
|
-
});
|
|
1484
|
-
it('charges a non-refundable cancellation exactly what was booked', async () => {
|
|
1485
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'horizon-club', ...STAY });
|
|
1486
|
-
const made = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
1487
|
-
const booking = made.structured.booking;
|
|
1488
|
-
const pre = await call(t.previewCancellation, { booking: booking.confirmationCode });
|
|
1489
|
-
expect(pre.structured.fee).toBe(booking.quote.total);
|
|
1490
|
-
expect(pre.structured.refund).toBe('HK$0');
|
|
1491
|
-
});
|
|
1492
|
-
it('makes a trip total exactly the sum of the bookings it becomes', async () => {
|
|
1493
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
1494
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', extra: 'airport-transfer', quantity: 1 });
|
|
1495
|
-
const later = { checkIn: '2026-10-21', checkOut: '2026-10-23', guests: 2 };
|
|
1496
|
-
await call(t.addToTrip, { hotel: 'hong-kong-kowloon', room: 'deluxe', ...later });
|
|
1497
|
-
await call(t.addToTrip, { hotel: 'hong-kong-kowloon', extra: 'airport-transfer', quantity: 1 });
|
|
1498
|
-
const trip = await call(t.viewTrip);
|
|
1499
|
-
const made = await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
1500
|
-
const amount = (x) => Number(x.replace(/[^\d.]/g, ''));
|
|
1501
|
-
const sum = made.structured.bookings.reduce((n, b) => n + amount(b.total), 0);
|
|
1502
|
-
expect(sum).toBe(amount(String(trip.structured.total)));
|
|
1503
|
-
});
|
|
1504
|
-
it('forgets preferences on every conversation signed in as that member', async () => {
|
|
1505
|
-
const other = { session: 'session-other' };
|
|
1506
|
-
const member = { number: 'SLC-4471-8820', surname: 'Ruan' };
|
|
1507
|
-
await call(t.signIn, member);
|
|
1508
|
-
await handleTool(deps, t.signIn, member, other);
|
|
1509
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
1510
|
-
await call(t.requestBooking, {
|
|
1511
|
-
name: 'Robin Ruan',
|
|
1512
|
-
email: 'robin@example.com',
|
|
1513
|
-
requests: ['king-bed', 'high-floor'],
|
|
1514
|
-
rememberRequests: true,
|
|
1515
|
-
});
|
|
1516
|
-
await handleTool(deps, t.forgetPreferences, {}, other);
|
|
1517
|
-
const here = await call(t.viewTrip);
|
|
1518
|
-
expect(here.structured.member.preferences).toBeUndefined();
|
|
1519
|
-
});
|
|
1520
|
-
it('adds to saved preferences rather than replacing them, and never keeps an accessible room', async () => {
|
|
1521
|
-
await call(t.signIn, { number: 'SLC-4471-8820', surname: 'Ruan' });
|
|
1522
|
-
const book = async (requests) => {
|
|
1523
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
1524
|
-
return call(t.requestBooking, {
|
|
1525
|
-
name: 'Robin Ruan',
|
|
1526
|
-
email: 'robin@example.com',
|
|
1527
|
-
requests,
|
|
1528
|
-
rememberRequests: true,
|
|
1529
|
-
});
|
|
1530
|
-
};
|
|
1531
|
-
await book(['king-bed', 'high-floor']);
|
|
1532
|
-
const second = await book(['quiet-room', 'accessible-room', 'twin-beds']);
|
|
1533
|
-
expect(second.structured.remembered).toEqual(['high-floor', 'quiet-room', 'twin-beds']);
|
|
1534
|
-
});
|
|
1535
|
-
it('compares at the member rate the list and the hotel show', async () => {
|
|
1536
|
-
await call(t.signIn, { number: 'SLC-4471-8820', surname: 'Ruan' });
|
|
1537
|
-
const hotel = await call(t.getHotel, { hotel: 'hong-kong-kerry', ...STAY });
|
|
1538
|
-
const cheapest = hotel.structured.rooms
|
|
1539
|
-
.map((r) => r.stayTotal)
|
|
1540
|
-
.sort((a, b) => Number(a.replace(/\D/g, '')) - Number(b.replace(/\D/g, '')))[0];
|
|
1541
|
-
const compared = await call(t.compare, {
|
|
1542
|
-
entries: [{ hotel: 'hong-kong-kerry' }, { hotel: 'hong-kong-jen' }],
|
|
1543
|
-
...STAY,
|
|
1544
|
-
});
|
|
1545
|
-
const columns = compared.structured.columns;
|
|
1546
|
-
expect(columns.find((c) => c.hotel.id === 'hong-kong-kerry')?.price?.stayTotal).toBe(cheapest);
|
|
1547
|
-
});
|
|
1548
|
-
// ⚠️ REFUSED NOW, NOT CLAMPED (2026-10-05 audit, P2): nine rooms became five
|
|
1549
|
-
// and the answer spoke as if five had been asked for.
|
|
1550
|
-
it('refuses a room count it cannot take, and spells a brand as the data does', async () => {
|
|
1551
|
-
const many = await call(t.listHotels, { city: 'Hong Kong', ...STAY, rooms: 9 });
|
|
1552
|
-
expect(many.structured).toMatchObject({ view: 'error', error: 'bad-count' });
|
|
1553
|
-
expect(many.isError).toBe(true);
|
|
1554
|
-
expect(many.text).toMatch(/whole number from 1 to 5/);
|
|
1555
|
-
for (const rooms of [-1, 1.5, 1e9]) {
|
|
1556
|
-
expect((await call(t.addToTrip, { hotel: 'hong-kong-kowloon', room: 'deluxe', rooms, ...STAY }))
|
|
1557
|
-
.isError).toBe(true);
|
|
1558
|
-
}
|
|
1559
|
-
// A count sent as a string is the same count.
|
|
1560
|
-
expect((await call(t.listHotels, { city: 'Hong Kong', ...STAY, rooms: '2' })).isError).toBeUndefined();
|
|
1561
|
-
const kerry = await call(t.listHotels, { city: 'Hong Kong', brand: 'kerry' });
|
|
1562
|
-
const chip = kerry.structured.refine.brands.find((b) => b.name === 'Kerry');
|
|
1563
|
-
expect(chip?.on).toBe(true);
|
|
1564
|
-
});
|
|
1565
|
-
it('says a page past the end is past the end', async () => {
|
|
1566
|
-
const res = await call(t.listHotels, { city: 'Hong Kong', offset: 10 });
|
|
1567
|
-
expect(res.text).toMatch(/past the end: 4 match/);
|
|
1568
|
-
});
|
|
1569
|
-
it('leaves a currency of add-ons alone out of the trip total', async () => {
|
|
1570
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'deluxe-city', ...STAY });
|
|
1571
|
-
await call(t.addToTrip, { hotel: 'shanghai-pudong', extra: 'airport-transfer', quantity: 1 });
|
|
1572
|
-
const trip = await call(t.viewTrip);
|
|
1573
|
-
const groups = trip.structured.groups;
|
|
1574
|
-
expect(groups.map((g) => [g.currency, !!g.goesWithAStay])).toEqual([
|
|
1575
|
-
['HKD', false],
|
|
1576
|
-
['CNY', true],
|
|
1577
|
-
]);
|
|
1578
|
-
expect(trip.structured.total).toBe(groups[0]?.total);
|
|
1579
|
-
});
|
|
1580
|
-
it('says a hotel has no room for the party, not no room for the nights', async () => {
|
|
1581
|
-
const res = await call(t.listHotels, { city: 'Hong Kong', ...STAY, guests: 5 });
|
|
1582
|
-
const tiles = res.structured.hotels;
|
|
1583
|
-
expect(tiles.every((h) => /No room here sleeps 5/.test(h.noRoomsForStay ?? ''))).toBe(true);
|
|
1584
|
-
});
|
|
1585
|
-
it('lets a lapsed hold go before pricing a list', async () => {
|
|
1586
|
-
const three = { ...STAY, guests: 3 };
|
|
1587
|
-
await call(t.addToTrip, { hotel: 'hong-kong-island', room: 'suite', ...three });
|
|
1588
|
-
await call(t.requestBooking, { name: 'Robin Ruan', email: 'robin@example.com' });
|
|
1589
|
-
const held = await call(t.listHotels, { city: 'Hong Kong', ...three });
|
|
1590
|
-
const island = () => held.structured.hotels;
|
|
1591
|
-
expect(island().find((h) => h.id === 'hong-kong-island')?.noRoomsForStay).toBeDefined();
|
|
1592
|
-
clock += 3 * 3_600_000;
|
|
1593
|
-
const lapsed = await call(t.listHotels, { city: 'Hong Kong', ...three });
|
|
1594
|
-
const tile = lapsed.structured.hotels.find((h) => h.id === 'hong-kong-island');
|
|
1595
|
-
expect(tile?.noRoomsForStay).toBeUndefined();
|
|
1596
|
-
});
|
|
1597
|
-
});
|
|
1598
|
-
//# sourceMappingURL=tools.test.js.map
|