From 3a9944489fd5ec1975ac538307b9d78830f3cbc2 Mon Sep 17 00:00:00 2001 From: Philip Peterson <1326208+philip-peterson@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:39:14 -0800 Subject: [PATCH] Split auction scenarios into one Cypress spec each Scenario steps move to cypress/scenarios/ and each gets a thin spec in cypress/e2e/, so the same steps can back more than one spec. Shared phones move to cypress/support/phones.ts, and "Run all specs" is enabled in the Cypress UI for playing every scenario back to back. Co-Authored-By: Claude Opus 5.5 --- cypress.config.ts | 2 + cypress/e2e/auction-deadline.cy.ts | 3 + cypress/e2e/auction.cy.ts | 168 ------------------- cypress/e2e/bid-capped-at-quote.cy.ts | 3 + cypress/e2e/every-driver-passes.cy.ts | 3 + cypress/e2e/lowest-bid-wins.cy.ts | 3 + cypress/e2e/one-ride-per-driver.cy.ts | 3 + cypress/e2e/rider-cancels.cy.ts | 3 + cypress/e2e/tie-goes-to-earlier-bid.cy.ts | 3 + cypress/scenarios/auction-deadline.ts | 17 ++ cypress/scenarios/bid-capped-at-quote.ts | 14 ++ cypress/scenarios/every-driver-passes.ts | 20 +++ cypress/scenarios/lowest-bid-wins.ts | 50 ++++++ cypress/scenarios/one-ride-per-driver.ts | 33 ++++ cypress/scenarios/rider-cancels.ts | 18 ++ cypress/scenarios/tie-goes-to-earlier-bid.ts | 15 ++ cypress/support/e2e.ts | 8 + cypress/support/phones.ts | 8 + docs/multi-phone-simulator.md | 41 ++++- 19 files changed, 239 insertions(+), 176 deletions(-) create mode 100644 cypress/e2e/auction-deadline.cy.ts delete mode 100644 cypress/e2e/auction.cy.ts create mode 100644 cypress/e2e/bid-capped-at-quote.cy.ts create mode 100644 cypress/e2e/every-driver-passes.cy.ts create mode 100644 cypress/e2e/lowest-bid-wins.cy.ts create mode 100644 cypress/e2e/one-ride-per-driver.cy.ts create mode 100644 cypress/e2e/rider-cancels.cy.ts create mode 100644 cypress/e2e/tie-goes-to-earlier-bid.cy.ts create mode 100644 cypress/scenarios/auction-deadline.ts create mode 100644 cypress/scenarios/bid-capped-at-quote.ts create mode 100644 cypress/scenarios/every-driver-passes.ts create mode 100644 cypress/scenarios/lowest-bid-wins.ts create mode 100644 cypress/scenarios/one-ride-per-driver.ts create mode 100644 cypress/scenarios/rider-cancels.ts create mode 100644 cypress/scenarios/tie-goes-to-earlier-bid.ts create mode 100644 cypress/support/phones.ts diff --git a/cypress.config.ts b/cypress.config.ts index b36b392..874b73d 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -17,5 +17,7 @@ export default defineConfig({ pageLoadTimeout: 120_000, defaultCommandTimeout: 10_000, video: false, + // Adds "Run all specs" to `npm run e2e:open`, to play every scenario back to back. + experimentalRunAllSpecs: true, }, }); diff --git a/cypress/e2e/auction-deadline.cy.ts b/cypress/e2e/auction-deadline.cy.ts new file mode 100644 index 0000000..e55bbcb --- /dev/null +++ b/cypress/e2e/auction-deadline.cy.ts @@ -0,0 +1,3 @@ +import scenario from '../scenarios/auction-deadline'; + +it(scenario.title, scenario.run); diff --git a/cypress/e2e/auction.cy.ts b/cypress/e2e/auction.cy.ts deleted file mode 100644 index 44bd523..0000000 --- a/cypress/e2e/auction.cy.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { tid, type PhoneSpec } from '../support/e2e'; - -// Multi-phone scenarios for the ride price auction. When a rider requests a ride, every -// online, idle driver gets a sealed-bid invitation capped at the rider's quoted fare. -// The lowest bid wins; the auction closes once every driver has answered or time runs out. -// -// The fixtures make every trip 5 km / 10 min, so UberX is quoted at $11.00. - -const alice: PhoneSpec = { role: 'rider', id: 'alice', name: 'Alice' }; -const bob: PhoneSpec = { role: 'rider', id: 'bob', name: 'Bob' }; -const maria: PhoneSpec = { role: 'driver', id: 'maria', name: 'Maria', car: 'Toyota Camry · Silver', plate: '7KDL294', rating: 4.95 }; -const james: PhoneSpec = { role: 'driver', id: 'james', name: 'James', car: 'Honda Accord · Black', plate: '8TRW115', rating: 4.88 }; -const aisha: PhoneSpec = { role: 'driver', id: 'aisha', name: 'Aisha', car: 'Tesla Model 3 · White', plate: '9EVA662', rating: 4.97 }; - -describe('ride price auction', () => { - it('awards the ride to the lowest bid and runs the trip end to end', () => { - cy.resetDispatch(); - cy.openPhones([alice, maria, james, aisha]); - - cy.requestRide('alice', 'ferry', 'Ferry Building'); - - // Every driver is invited, capped at the quoted fare. - for (const driver of ['maria', 'james', 'aisha']) { - cy.phone(driver).find(tid('offer-destination')).should('have.text', 'Alice → Ferry Building'); - cy.phone(driver).find(tid('offer-max-fare')).should('have.text', 'Rider pays up to $11.00'); - } - - cy.bid('maria', 10); - cy.phone('maria').find(tid('offer-my-bid')).should('contain.text', 'You bid $10.00'); - cy.phone('alice').find(tid('offer-count')).should('have.text', '1 offer so far'); - - cy.bid('james', 8.5); - cy.phone('alice').find(tid('offer-count')).should('have.text', '2 offers so far'); - - // The last driver answering closes the auction without waiting out the clock. - cy.pass('aisha'); - - cy.phone('alice').find(tid('ride-status')).should('have.text', 'Driver found'); - cy.phone('alice').find(tid('ride-fare')).should('have.text', '$8.50'); - cy.phone('alice').find(tid('driver-name')).should('contain.text', 'James'); - cy.phone('alice').find(tid('matched-driver')).should('contain.text', '8TRW115'); - - cy.phone('james').find(tid('job-fare')).should('have.text', 'Fare $8.50'); - cy.phone('maria').find(tid('driver-result')).should('have.text', 'Another driver won that ride'); - cy.phone('aisha').find(tid('driver-idle')).should('exist'); - cy.phone('aisha').find(tid('driver-result')).should('not.exist'); - - cy.dispatchState().then(({ rides }) => { - expect(rides).to.have.length(1); - expect(rides[0]).to.include({ status: 'matched', winnerId: 'james', fare: 8.5, maxFare: 11 }); - expect(rides[0].declined).to.deep.equal(['aisha']); - }); - - // The winning driver finishes the trip; the rider's phone follows. - cy.phone('james').find(tid('complete-trip')).click(); - cy.phone('james').find(tid('driver-result')).should('have.text', 'Trip complete · you earned $8.50'); - cy.phone('alice').find(tid('ride-status')).should('have.text', 'You’ve arrived'); - cy.phone('alice').should('contain.text', 'Thanks for riding with James.'); - - cy.phone('alice').find(tid('trip-done')).click(); - cy.phone('alice').find(tid('where-to')).should('exist'); - }); - - it('breaks a tie in favour of the earlier bid', () => { - cy.resetDispatch(); - cy.openPhones([alice, maria, james]); - cy.requestRide('alice', 'ferry', 'Ferry Building'); - - cy.bid('james', 9); - cy.phone('james').find(tid('offer-my-bid')).should('exist'); - cy.bid('maria', 9); - - cy.phone('alice').find(tid('driver-name')).should('contain.text', 'James'); - cy.phone('maria').find(tid('driver-result')).should('have.text', 'Another driver won that ride'); - }); - - it('does not let a driver bid above the quoted fare', () => { - cy.resetDispatch(); - cy.openPhones([alice, maria]); - cy.requestRide('alice', 'ferry', 'Ferry Building'); - - cy.phone('maria').find(tid('bid-input')).clear().type('11.01'); - cy.phone('maria').find(tid('submit-bid')).should('have.attr', 'aria-disabled', 'true'); - - cy.phone('maria').find(tid('bid-input')).clear().type('11'); - cy.phone('maria').find(tid('submit-bid')).should('not.have.attr', 'aria-disabled'); - }); - - it('closes on the deadline and awards whoever did bid', () => { - cy.resetDispatch({ auctionDurationMs: 4000 }); - cy.openPhones([alice, maria, james]); - cy.requestRide('alice', 'ferry', 'Ferry Building'); - - cy.bid('maria', 10.75); - // James never answers; the auction stays open until the window runs out. - cy.phone('alice').find(tid('ride-status')).should('have.text', 'Finding your driver'); - - cy.phone('alice').find(tid('ride-status'), { timeout: 8000 }).should('have.text', 'Driver found'); - cy.phone('alice').find(tid('ride-fare')).should('have.text', '$10.75'); - cy.phone('james').find(tid('driver-result')).should('have.text', 'That request closed before you answered'); - cy.phone('james').find(tid('offer')).should('not.exist'); - }); - - it('tells the rider when every driver passes', () => { - cy.resetDispatch(); - cy.openPhones([alice, maria, james]); - cy.requestRide('alice', 'ferry', 'Ferry Building'); - - cy.pass('maria'); - cy.pass('james'); - - cy.phone('alice').find(tid('ride-status')).should('have.text', 'No drivers available'); - cy.waitForDispatch((s) => s.rides[0]?.status === 'unmatched', 'ride unmatched'); - - // Going back lets the rider try again with a fresh auction. - cy.phone('alice').find(tid('unmatched-back')).click(); - cy.phone('alice').find(tid('confirm-ride')).click(); - cy.phone('maria').find(tid('offer')).should('have.length', 1); - cy.waitForDispatch((s) => s.rides.length === 2 && s.rides[1].status === 'auction', 'second auction open'); - }); - - it('withdraws the offer from every driver when the rider cancels', () => { - cy.resetDispatch(); - cy.openPhones([alice, maria, james]); - cy.requestRide('alice', 'ferry', 'Ferry Building'); - - cy.bid('maria', 9); - cy.phone('alice').find(tid('cancel-request')).click(); - cy.phone('alice').find(tid('confirm-ride')).should('exist'); - - for (const driver of ['maria', 'james']) { - cy.phone(driver).find(tid('offer')).should('not.exist'); - cy.phone(driver).find(tid('driver-result')).should('have.text', 'The rider cancelled their request'); - } - cy.waitForDispatch((s) => s.rides[0]?.status === 'cancelled', 'ride cancelled'); - }); - - it('never gives one driver two rides at once', () => { - cy.resetDispatch(); - cy.openPhones([alice, bob, maria, james]); - - cy.requestRide('alice', 'ferry', 'Ferry Building'); - cy.requestRide('bob', 'ferry', 'Ferry Building'); - cy.phone('maria').find(tid('offer')).should('have.length', 2); - - // Maria is the cheapest on both rides. - cy.bid('maria', 7, { rider: 'Alice' }); - cy.bid('maria', 6, { rider: 'Bob' }); - cy.bid('james', 9, { rider: 'Bob' }); - - // Bob's auction has every answer, so it closes first and Maria wins it. - cy.phone('bob').find(tid('driver-name')).should('contain.text', 'Maria'); - cy.phone('bob').find(tid('ride-fare')).should('have.text', '$6.00'); - - // Alice's auction is still waiting on James. Maria's lower bid no longer counts. - cy.phone('alice').find(tid('ride-status')).should('have.text', 'Finding your driver'); - cy.bid('james', 10, { rider: 'Alice' }); - - cy.phone('alice').find(tid('driver-name')).should('contain.text', 'James'); - cy.phone('alice').find(tid('ride-fare')).should('have.text', '$10.00'); - cy.waitForDispatch((s) => s.rides.every((r) => r.status === 'matched'), 'both rides matched').then(({ rides }) => { - expect(rides.map((r) => [r.riderId, r.winnerId])).to.deep.equal([ - ['alice', 'james'], - ['bob', 'maria'], - ]); - }); - }); -}); diff --git a/cypress/e2e/bid-capped-at-quote.cy.ts b/cypress/e2e/bid-capped-at-quote.cy.ts new file mode 100644 index 0000000..94103e6 --- /dev/null +++ b/cypress/e2e/bid-capped-at-quote.cy.ts @@ -0,0 +1,3 @@ +import scenario from '../scenarios/bid-capped-at-quote'; + +it(scenario.title, scenario.run); diff --git a/cypress/e2e/every-driver-passes.cy.ts b/cypress/e2e/every-driver-passes.cy.ts new file mode 100644 index 0000000..3f6ffea --- /dev/null +++ b/cypress/e2e/every-driver-passes.cy.ts @@ -0,0 +1,3 @@ +import scenario from '../scenarios/every-driver-passes'; + +it(scenario.title, scenario.run); diff --git a/cypress/e2e/lowest-bid-wins.cy.ts b/cypress/e2e/lowest-bid-wins.cy.ts new file mode 100644 index 0000000..eeb79bb --- /dev/null +++ b/cypress/e2e/lowest-bid-wins.cy.ts @@ -0,0 +1,3 @@ +import scenario from '../scenarios/lowest-bid-wins'; + +it(scenario.title, scenario.run); diff --git a/cypress/e2e/one-ride-per-driver.cy.ts b/cypress/e2e/one-ride-per-driver.cy.ts new file mode 100644 index 0000000..9aa3174 --- /dev/null +++ b/cypress/e2e/one-ride-per-driver.cy.ts @@ -0,0 +1,3 @@ +import scenario from '../scenarios/one-ride-per-driver'; + +it(scenario.title, scenario.run); diff --git a/cypress/e2e/rider-cancels.cy.ts b/cypress/e2e/rider-cancels.cy.ts new file mode 100644 index 0000000..921944b --- /dev/null +++ b/cypress/e2e/rider-cancels.cy.ts @@ -0,0 +1,3 @@ +import scenario from '../scenarios/rider-cancels'; + +it(scenario.title, scenario.run); diff --git a/cypress/e2e/tie-goes-to-earlier-bid.cy.ts b/cypress/e2e/tie-goes-to-earlier-bid.cy.ts new file mode 100644 index 0000000..5c05047 --- /dev/null +++ b/cypress/e2e/tie-goes-to-earlier-bid.cy.ts @@ -0,0 +1,3 @@ +import scenario from '../scenarios/tie-goes-to-earlier-bid'; + +it(scenario.title, scenario.run); diff --git a/cypress/scenarios/auction-deadline.ts b/cypress/scenarios/auction-deadline.ts new file mode 100644 index 0000000..dddeb8e --- /dev/null +++ b/cypress/scenarios/auction-deadline.ts @@ -0,0 +1,17 @@ +import { defineScenario, tid } from '../support/e2e'; +import { alice, maria, james } from '../support/phones'; + +export default defineScenario('closes on the deadline and awards whoever did bid', () => { + cy.resetDispatch({ auctionDurationMs: 4000 }); + cy.openPhones([alice, maria, james]); + cy.requestRide('alice', 'ferry', 'Ferry Building'); + + cy.bid('maria', 10.75); + // James never answers; the auction stays open until the window runs out. + cy.phone('alice').find(tid('ride-status')).should('have.text', 'Finding your driver'); + + cy.phone('alice').find(tid('ride-status'), { timeout: 8000 }).should('have.text', 'Driver found'); + cy.phone('alice').find(tid('ride-fare')).should('have.text', '$10.75'); + cy.phone('james').find(tid('driver-result')).should('have.text', 'That request closed before you answered'); + cy.phone('james').find(tid('offer')).should('not.exist'); +}); diff --git a/cypress/scenarios/bid-capped-at-quote.ts b/cypress/scenarios/bid-capped-at-quote.ts new file mode 100644 index 0000000..89034f3 --- /dev/null +++ b/cypress/scenarios/bid-capped-at-quote.ts @@ -0,0 +1,14 @@ +import { defineScenario, tid } from '../support/e2e'; +import { alice, maria } from '../support/phones'; + +export default defineScenario('does not let a driver bid above the quoted fare', () => { + cy.resetDispatch(); + cy.openPhones([alice, maria]); + cy.requestRide('alice', 'ferry', 'Ferry Building'); + + cy.phone('maria').find(tid('bid-input')).clear().type('11.01'); + cy.phone('maria').find(tid('submit-bid')).should('have.attr', 'aria-disabled', 'true'); + + cy.phone('maria').find(tid('bid-input')).clear().type('11'); + cy.phone('maria').find(tid('submit-bid')).should('not.have.attr', 'aria-disabled'); +}); diff --git a/cypress/scenarios/every-driver-passes.ts b/cypress/scenarios/every-driver-passes.ts new file mode 100644 index 0000000..d2a9613 --- /dev/null +++ b/cypress/scenarios/every-driver-passes.ts @@ -0,0 +1,20 @@ +import { defineScenario, tid } from '../support/e2e'; +import { alice, maria, james } from '../support/phones'; + +export default defineScenario('tells the rider when every driver passes', () => { + cy.resetDispatch(); + cy.openPhones([alice, maria, james]); + cy.requestRide('alice', 'ferry', 'Ferry Building'); + + cy.pass('maria'); + cy.pass('james'); + + cy.phone('alice').find(tid('ride-status')).should('have.text', 'No drivers available'); + cy.waitForDispatch((s) => s.rides[0]?.status === 'unmatched', 'ride unmatched'); + + // Going back lets the rider try again with a fresh auction. + cy.phone('alice').find(tid('unmatched-back')).click(); + cy.phone('alice').find(tid('confirm-ride')).click(); + cy.phone('maria').find(tid('offer')).should('have.length', 1); + cy.waitForDispatch((s) => s.rides.length === 2 && s.rides[1].status === 'auction', 'second auction open'); +}); diff --git a/cypress/scenarios/lowest-bid-wins.ts b/cypress/scenarios/lowest-bid-wins.ts new file mode 100644 index 0000000..4f3e55e --- /dev/null +++ b/cypress/scenarios/lowest-bid-wins.ts @@ -0,0 +1,50 @@ +import { defineScenario, tid } from '../support/e2e'; +import { alice, maria, james, aisha } from '../support/phones'; + +export default defineScenario('awards the ride to the lowest bid and runs the trip end to end', () => { + cy.resetDispatch(); + cy.openPhones([alice, maria, james, aisha]); + + cy.requestRide('alice', 'ferry', 'Ferry Building'); + + // Every driver is invited, capped at the quoted fare. + for (const driver of ['maria', 'james', 'aisha']) { + cy.phone(driver).find(tid('offer-destination')).should('have.text', 'Alice → Ferry Building'); + cy.phone(driver).find(tid('offer-max-fare')).should('have.text', 'Rider pays up to $11.00'); + } + + cy.bid('maria', 10); + cy.phone('maria').find(tid('offer-my-bid')).should('contain.text', 'You bid $10.00'); + cy.phone('alice').find(tid('offer-count')).should('have.text', '1 offer so far'); + + cy.bid('james', 8.5); + cy.phone('alice').find(tid('offer-count')).should('have.text', '2 offers so far'); + + // The last driver answering closes the auction without waiting out the clock. + cy.pass('aisha'); + + cy.phone('alice').find(tid('ride-status')).should('have.text', 'Driver found'); + cy.phone('alice').find(tid('ride-fare')).should('have.text', '$8.50'); + cy.phone('alice').find(tid('driver-name')).should('contain.text', 'James'); + cy.phone('alice').find(tid('matched-driver')).should('contain.text', '8TRW115'); + + cy.phone('james').find(tid('job-fare')).should('have.text', 'Fare $8.50'); + cy.phone('maria').find(tid('driver-result')).should('have.text', 'Another driver won that ride'); + cy.phone('aisha').find(tid('driver-idle')).should('exist'); + cy.phone('aisha').find(tid('driver-result')).should('not.exist'); + + cy.dispatchState().then(({ rides }) => { + expect(rides).to.have.length(1); + expect(rides[0]).to.include({ status: 'matched', winnerId: 'james', fare: 8.5, maxFare: 11 }); + expect(rides[0].declined).to.deep.equal(['aisha']); + }); + + // The winning driver finishes the trip; the rider's phone follows. + cy.phone('james').find(tid('complete-trip')).click(); + cy.phone('james').find(tid('driver-result')).should('have.text', 'Trip complete · you earned $8.50'); + cy.phone('alice').find(tid('ride-status')).should('have.text', 'You’ve arrived'); + cy.phone('alice').should('contain.text', 'Thanks for riding with James.'); + + cy.phone('alice').find(tid('trip-done')).click(); + cy.phone('alice').find(tid('where-to')).should('exist'); +}); diff --git a/cypress/scenarios/one-ride-per-driver.ts b/cypress/scenarios/one-ride-per-driver.ts new file mode 100644 index 0000000..6a74c1d --- /dev/null +++ b/cypress/scenarios/one-ride-per-driver.ts @@ -0,0 +1,33 @@ +import { defineScenario, tid } from '../support/e2e'; +import { alice, bob, maria, james } from '../support/phones'; + +export default defineScenario('never gives one driver two rides at once', () => { + cy.resetDispatch(); + cy.openPhones([alice, bob, maria, james]); + + cy.requestRide('alice', 'ferry', 'Ferry Building'); + cy.requestRide('bob', 'ferry', 'Ferry Building'); + cy.phone('maria').find(tid('offer')).should('have.length', 2); + + // Maria is the cheapest on both rides. + cy.bid('maria', 7, { rider: 'Alice' }); + cy.bid('maria', 6, { rider: 'Bob' }); + cy.bid('james', 9, { rider: 'Bob' }); + + // Bob's auction has every answer, so it closes first and Maria wins it. + cy.phone('bob').find(tid('driver-name')).should('contain.text', 'Maria'); + cy.phone('bob').find(tid('ride-fare')).should('have.text', '$6.00'); + + // Alice's auction is still waiting on James. Maria's lower bid no longer counts. + cy.phone('alice').find(tid('ride-status')).should('have.text', 'Finding your driver'); + cy.bid('james', 10, { rider: 'Alice' }); + + cy.phone('alice').find(tid('driver-name')).should('contain.text', 'James'); + cy.phone('alice').find(tid('ride-fare')).should('have.text', '$10.00'); + cy.waitForDispatch((s) => s.rides.every((r) => r.status === 'matched'), 'both rides matched').then(({ rides }) => { + expect(rides.map((r) => [r.riderId, r.winnerId])).to.deep.equal([ + ['alice', 'james'], + ['bob', 'maria'], + ]); + }); +}); diff --git a/cypress/scenarios/rider-cancels.ts b/cypress/scenarios/rider-cancels.ts new file mode 100644 index 0000000..bdc58c3 --- /dev/null +++ b/cypress/scenarios/rider-cancels.ts @@ -0,0 +1,18 @@ +import { defineScenario, tid } from '../support/e2e'; +import { alice, maria, james } from '../support/phones'; + +export default defineScenario('withdraws the offer from every driver when the rider cancels', () => { + cy.resetDispatch(); + cy.openPhones([alice, maria, james]); + cy.requestRide('alice', 'ferry', 'Ferry Building'); + + cy.bid('maria', 9); + cy.phone('alice').find(tid('cancel-request')).click(); + cy.phone('alice').find(tid('confirm-ride')).should('exist'); + + for (const driver of ['maria', 'james']) { + cy.phone(driver).find(tid('offer')).should('not.exist'); + cy.phone(driver).find(tid('driver-result')).should('have.text', 'The rider cancelled their request'); + } + cy.waitForDispatch((s) => s.rides[0]?.status === 'cancelled', 'ride cancelled'); +}); diff --git a/cypress/scenarios/tie-goes-to-earlier-bid.ts b/cypress/scenarios/tie-goes-to-earlier-bid.ts new file mode 100644 index 0000000..9b89d9a --- /dev/null +++ b/cypress/scenarios/tie-goes-to-earlier-bid.ts @@ -0,0 +1,15 @@ +import { defineScenario, tid } from '../support/e2e'; +import { alice, maria, james } from '../support/phones'; + +export default defineScenario('breaks a tie in favour of the earlier bid', () => { + cy.resetDispatch(); + cy.openPhones([alice, maria, james]); + cy.requestRide('alice', 'ferry', 'Ferry Building'); + + cy.bid('james', 9); + cy.phone('james').find(tid('offer-my-bid')).should('exist'); + cy.bid('maria', 9); + + cy.phone('alice').find(tid('driver-name')).should('contain.text', 'James'); + cy.phone('maria').find(tid('driver-result')).should('have.text', 'Another driver won that ride'); +}); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 3c84fc9..099e9c6 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -14,6 +14,14 @@ export type PhoneSpec = { rating?: number; }; +/** + * One multi-phone scenario. Scenarios live in cypress/scenarios/ and are registered by + * thin spec files in cypress/e2e/, so the same steps can back more than one spec. + */ +export type Scenario = { title: string; run: () => void }; + +export const defineScenario = (title: string, run: () => void): Scenario => ({ title, run }); + export type DispatchState = { config: { auctionDurationMs: number }; devices: (PhoneSpec & { online: boolean; busy: boolean })[]; diff --git a/cypress/support/phones.ts b/cypress/support/phones.ts new file mode 100644 index 0000000..637b369 --- /dev/null +++ b/cypress/support/phones.ts @@ -0,0 +1,8 @@ +import type { PhoneSpec } from './e2e'; + +// The cast shared by the auction scenarios. +export const alice: PhoneSpec = { role: 'rider', id: 'alice', name: 'Alice' }; +export const bob: PhoneSpec = { role: 'rider', id: 'bob', name: 'Bob' }; +export const maria: PhoneSpec = { role: 'driver', id: 'maria', name: 'Maria', car: 'Toyota Camry · Silver', plate: '7KDL294', rating: 4.95 }; +export const james: PhoneSpec = { role: 'driver', id: 'james', name: 'James', car: 'Honda Accord · Black', plate: '8TRW115', rating: 4.88 }; +export const aisha: PhoneSpec = { role: 'driver', id: 'aisha', name: 'Aisha', car: 'Tesla Model 3 · White', plate: '9EVA662', rating: 4.97 }; diff --git a/docs/multi-phone-simulator.md b/docs/multi-phone-simulator.md index 9790068..75fae4e 100644 --- a/docs/multi-phone-simulator.md +++ b/docs/multi-phone-simulator.md @@ -22,6 +22,8 @@ npm run e2e # start dispatch + web server, run every scenario headlessly, npm run e2e:open # same, but in the Cypress UI so you can watch the phones ``` +In the Cypress UI, each scenario is its own spec; pick one to watch it, or use **Run all specs** to play them back to back. To run one scenario headlessly, start the servers (below) and run `npx cypress run --spec cypress/e2e/lowest-bid-wins.cy.ts`. + To explore by hand, start the two servers yourself: ```bash @@ -71,8 +73,22 @@ What each driver sees afterwards: | `src/app/driver.tsx` | Driver screen: offers, bid input, current job, result banner. | | `src/components/RideMap.tsx` / `.web.tsx` | Native map, and a plain backdrop on web (`react-native-maps` has no web build). | | `public/sim.html` | The simulator page: phones as iframes plus the live dispatch panel. | -| `cypress/support/e2e.ts` | Custom commands and network fixtures. | -| `cypress/e2e/auction.cy.ts` | Auction scenarios. | +| `cypress/support/e2e.ts` | Custom commands, network fixtures, and `defineScenario`. | +| `cypress/support/phones.ts` | The shared cast: riders Alice and Bob; drivers Maria, James and Aisha. | +| `cypress/scenarios/.ts` | One scenario's steps and assertions. | +| `cypress/e2e/.cy.ts` | A thin spec that registers one scenario. | + +### Scenarios + +| Spec | Checks | +| --------------------------- | ------------------------------------------------------------------------------ | +| `lowest-bid-wins` | Lowest bid wins, the losers are told, and the trip runs to "You've arrived". | +| `tie-goes-to-earlier-bid` | Equal bids go to whoever bid first. | +| `bid-capped-at-quote` | A bid above the rider's quoted fare can't be submitted. | +| `auction-deadline` | The auction closes on its deadline and a driver who never answered is told. | +| `every-driver-passes` | The rider sees "No drivers available" and can request again. | +| `rider-cancels` | Cancelling withdraws the offer from every driver. | +| `one-ride-per-driver` | A driver who wins one ride isn't awarded another at the same time. | ### Protocol @@ -111,14 +127,14 @@ Environment variables: `PORT` (default 8787) and `AUCTION_MS` (default auction w ## Writing scenarios +A scenario's steps live in `cypress/scenarios/`, apart from the spec file that runs them, so the same steps can back more than one spec. Add both files: + ```ts -import { tid, type PhoneSpec } from '../support/e2e'; +// cypress/scenarios/lowest-bid-wins.ts +import { defineScenario, tid } from '../support/e2e'; +import { alice, james, maria } from '../support/phones'; -const alice: PhoneSpec = { role: 'rider', id: 'alice', name: 'Alice' }; -const maria: PhoneSpec = { role: 'driver', id: 'maria', name: 'Maria', plate: '7KDL294' }; -const james: PhoneSpec = { role: 'driver', id: 'james', name: 'James' }; - -it('lowest bid wins', () => { +export default defineScenario('lowest bid wins', () => { cy.resetDispatch(); // always first: dispatch state outlives page loads cy.openPhones([alice, maria, james]); // waits until every phone is rendered and connected @@ -132,6 +148,15 @@ it('lowest bid wins', () => { }); ``` +```ts +// cypress/e2e/lowest-bid-wins.cy.ts +import scenario from '../scenarios/lowest-bid-wins'; + +it(scenario.title, scenario.run); +``` + +Each scenario starts with `cy.resetDispatch()` and opens its own phones, so any spec can run alone or in any order. + ### Commands | Command | Does |