Bridge Training

Testing

makeDeal, chainable builders with random defaults and named scenarios for writing bridge fixtures across the monorepo.

Import from @workspace/bridge/testing. This is the fixture toolkit every package and app in the monorepo should use instead of hand-written literals. It is built on the factories and the domain functions, so a fixture can never drift from the package's own parsing rules.

makeDeal or a builder?

  • Use makeDeal for a whole deal in one call. It takes partial overrides of the default deal, or a callback that configures a DealBuilder.
  • Use DealBuilder when the deal is reused, rotated, projected to PBN or typed with comment generics. Its withHands, withAuction, withPlay and withMetadata take a value or a callback receiving the matching module builder, so a complete deal needs a single import.
  • Use a module builder (HandsBuilder, AuctionBuilder, ContractBuilder, PlayBuilder, TrickBuilder, MetadataBuilder, PBNDealBuilder, PBNSourceBuilder) for a value on its own: new AuctionBuilder().fromCompact("N / 1H pass 2H AP").toSequence().
  • Use the raw factories from @workspace/bridge/factories in production code that builds the same shapes without the test-oriented defaults.

The compact grammar is shared: "<Player> / <tokens>" where the player is the dealer, leader or declarer depending on the builder. Bids accept pass, p, X, Dbl, XX, Rdbl, a * suffix for an alert and AP for "all pass".

testing.ts
const anyDeal = makeDeal(); // random hands, dealer South, empty auction and play

const northDeal = makeDeal({ player: "N" }); // partial overrides of the default deal

const deal = makeDeal((d) =>
  d
    .withHands((h) => h.fromCompact("AKQ2.KQ3.A54.K32 JT98.JT9.KQJ.QJ9 7654.A87.T98.AT8 3.6542.7632.7654"))
    .withAuction((a) => a.fromCompact("S / 1H pass 2H pass 2NT* pass 3H AP"))
    .withPlay((p) => p.fromCompact("W / DA DK D9 D3"))
    .withMetadata((m) => m.withVulnerable("ALL"))
);

const southOnly = makeDeal((d) => d.withHands((h) => h.withHand("S", "AKQ2.KQ3.A54.K32")));
// South is pinned, the other 39 cards are dealt at random
FunctionSignatureDescription
makeDeal<A, P, R>(input?: Partial<Deal<A, P, R>> | ((d: DealBuilder<A, P, R>) => DealBuilder<A, P, R>)) => Deal<A, P, R>Defaults: random hands, dealer South, empty auction and play, player South.
testing.ts
const hands = new HandsBuilder().fromCompact("AKQ2.KQ3.A54.K32 JT98.JT9.KQJ.QJ9 7654.A87.T98.AT8 3.6542.7632.7654").build();
const contract = new ContractBuilder().fromCompact("N / 2HXX").build();
// => { level: 2, suit: "H", trump: "H", declarer: "N", doubled: "XX" }
const sequence = new AuctionBuilder().fromCompact("N / 1H pass 2H AP").build();
const trick = new TrickBuilder().fromCompact("W / HA H3 H5 H2").withWinner("W").build();
const pbnDeal = new PBNDealBuilder().withOverrides({ dealer: "N", vuln: "All" }).build();

Random by default

Hands are random unless a test sets them. A fixture states only what its assertions depend on: write the cards as a compact literal when the test checks them, and leave them random otherwise.

HandsBuilder, AuctionBuilder, ContractBuilder, PlayBuilder, DealBuilder and PBNDealBuilder take an optional Faker instance from @faker-js/faker in their constructor and default to the shared faker instance; MetadataBuilder and TrickBuilder draw nothing and take none. faker.seed(n) in a test, or a seeded instance passed to the constructor, makes a draw reproducible, and builders derived from another one (toPBN, fromContract) keep its instance. @faker-js/faker is a dependency of @workspace/bridge; consumers of /testing do not declare it.

The package's own test setup seeds the shared faker before each test and prints the seed when a test fails; rerun with FAKER_SEED=<seed> pnpm test to replay that draw.

Reduced decks

Some teaching modes play with fewer cards: "petit-bridge" deals 10 cards per hand from a 40-card deck, A down to 5. HandsBuilder.withMode(mode) deals from the deck and hand size of that mode, taken from the mode's configuration, the one the engine resolves. A fixture pins one seat and the other three are completed from the remaining cards. Changing the mode draws the unset seats again. Modes that play with 52 cards keep the standard deal.

A DealBuilder applies its metadata mode to its hands, and so does makeDeal when the overrides carry a mode. Set the mode before any withHands callback that draws the hands, such as rotate, swap or a projection like toRankRecord. Hands drawn before the mode is known come from 52 cards, so a reduced-deck mode set afterwards throws.

A card outside the mode's deck throws, as does a card held by two seats or, while other seats remain to deal, a set seat without a full hand for the mode.

testing.ts
const petitBridgeHands = new HandsBuilder().withMode("petit-bridge").withHand("N", "AKQ.AK.QJ9.T9").build();
// North is pinned, East, South and West get 10 of the 30 remaining cards, A down to 5

const petitBridgeDeal = new DealBuilder()
  .withMetadata((m) => m.withMode("petit-bridge")) // before any withHands that draws
  .withHands((h) => h.withHand("N", "AKQ.AK.QJ9.T9").swap("N", "S"))
  .build(); // 4 × 10 cards, South holds the pinned hand

const reducedDeal = makeDeal({ metadata: new MetadataBuilder().withMode("petit-bridge").build() });

new HandsBuilder().withMode("petit-bridge").withHand("N", "AKQ2.AK.QJ.T9").build(); // throws: S2 is outside the petit-bridge deck

Random values

For a single value rather than a fixture, draw it with a primitive instead of picking from the constants by hand. Each takes an optional Faker and defaults to the shared faker instance, like the builders.

FunctionReturns
randomSuit(random?)Suit
randomRank(random?)Rank
randomCard(random?)Card
randomPlayer(random?)Player
randomDenomination(random?)Denomination
randomBidLevel(random?)BidLevel
randomRegularBidParts(random?)RegularBidParts
randomRegularBid(random?)RegularBid
randomCounterStatus(random?)CounterStatus
testing.ts
const card = randomCard(); // e.g. "HQ", drawn from the shared faker
const opener = randomPlayer();
const bid = randomRegularBid(); // e.g. "3NT"

Random auctions

FunctionSignatureDescription
createRandomAuction<TComment>(options?: RandomAuctionOptions<TComment>, random?: Faker) => RandomAuctionResult<TComment>Generates a valid random auction where South is always the declarer. Level and denomination are drawn when omitted; comments land on the regular bids in order. Draws from random, the shared faker instance by default.

RandomAuctionOptions

Prop

Type

RandomAuctionResult

Prop

Type

testing.ts
const randomAuction = createRandomAuction({ level: 4, suit: "H", comments: ["Natural"] });
randomAuction.declarer; // "S"
randomAuction.contract; // { level: 4, suit: "H", doubled: "none" }
randomAuction.auction.moves; // AuctionMove[], "Natural" on the first regular bid

Builders

All builders are chainable, mutate in place and finish with build(). Methods named randomize draw from the builder's Faker.

HandsBuilder

Builds Hands. Seats left unset are dealt at random from the remaining cards of the mode's deck, 52 cards by default; the draw is kept until the input or the mode changes. A card held by two set seats, or outside the deck, throws.

MethodDescription
withMode(mode)Deal from the deck and hand size of a teaching mode, see Reduced decks.
withHand(player, compact)Set one hand.
withAll(input: HandsInput)Merge several hands.
fromCompact(input)Four space-separated hands for N, E, S, W.
fromHands(hands)Load every seat from a Hands.
rotate(n)Rotate hands n seats clockwise.
swap(a, b)Exchange two seats' hands.
randomize()Unset every seat, so the next projection deals all four at random.
build(), toHands()Hands.
toStringRecord()Record<Player, string> of compact hands.
toRankRecord()HandsData<Rank>.
toInline(dealer = "N")Single-line PBN string "N:… … … …".

AuctionBuilder

Generic over TComment. Builds an Auction; auctionEntries(builder.build()) gives the per-call view with seats.

MethodDescription
withDealer(player)Default dealer is South.
addBid(bid, { isAlerted?, comment? }), addPass(opts?)Append a call.
addAllPass()Four passes on an empty auction, three otherwise.
close()Append passes until the auction is complete; idempotent.
removeLast(n = 1), replaceBid(position, bid, opts?)Edit the sequence.
rotate(n)Shift the dealer; bids keep their order.
withDeclarer(player)Regenerate a canonical sequence landing on the current contract with a new declarer. Throws without a contract.
randomize(options?)Delegate to createRandomAuction.
fromCompact(input)"<Dealer> / <bids>".
fromAuction(auction)Load an Auction.
build()Auction<TComment>.
toContract()A ContractBuilder seeded from the implied contract.
static fromContract(contract, random?)Minimal sequence: dealer is declarer's partner, two passes, the contract bid, any double or redouble, closing passes.

ContractBuilder

MethodDescription
withDeclarer, withLevel, withSuit, withDoubledDefaults: South, 1, NT, none.
randomize({ allowDoubled? })Random level, denomination and declarer.
fromCompact(input)"4S", "3NTX", "N / 2HXX".
build()Contract with trump derived.

PlayBuilder

Generic over TComment. Builds a Play; the leader defaults to West.

MethodDescription
withLeader(player)Opening leader.
withContract(contract | { trump? } | null)Trump used by toTricks().
addCard(card, { comment? }), addTrick(cards)Append moves.
rotate(n), untilTrick(n), removeLast(n = 1)Edit the sequence.
randomize({ hands?, contract?, tricks? })Legal random play, following suit, winner leads next. Hands default to a random deal.
fromCompact(input)"<Leader> / <cards>".
fromPlay(play)Load a Play.
build()Play<TComment>.
toTricks()Trick[] with winners resolved against the trump.

TrickBuilder

withLead(player), withWinner(player | null), addCard(card), addCards(cards), fromCompact("<Leader> / <cards>"), build(): Trick. Players are attributed clockwise from the lead.

MetadataBuilder

withVulnerable, withScoring, withMode, withResult, withDealer, withDeclarer, withOverrides(partial), build(): DealMetadata.

DealBuilder

Generic over TAuctionComment, TPlayComment and TReview. withHands, withAuction, withPlay and withMetadata take a built value, or a callback receiving the module builder and returning it. The auction, play and metadata callbacks get a builder seeded with the current value and typed with the deal's comment generics; the hands callback gets the deal's own HandsBuilder, so seats it leaves unset stay random.

MethodDescription
withHands(Hands | (h: HandsBuilder) => HandsBuilder)Default: a random deal from the deck of the metadata mode.
withAuction(Auction | (a: AuctionBuilder) => AuctionBuilder)
withPlay(Play | (p: PlayBuilder) => PlayBuilder)The play builder also carries the trump of the current contract.
withContract(Contract | undefined)
withMetadata(DealMetadata | (m: MetadataBuilder) => MetadataBuilder)
withPlayer(player), withReview(review), withOverrides(partial)
randomize({ auction?: RandomAuctionOptions | boolean })Fresh random hands, optionally a random auction.
rotate(n)Rotate every seat-aware field: cards, first players, dealer, declarer, contract, vulnerability, player, string comments.
toTarget(target = "S")Rotate so the declarer sits at target.
toTargetWithMeta(target?)Same, returning RotatedDeal with rotationCount and originalDeclarer.
toPBN(converters?)A PBNDealBuilder projected through convertDealToPBNDeal.
build()Deal<TAuctionComment, TPlayComment, TReview>.

PBNDealBuilder

Cards default to a random deal drawn on build(), and pbn follows the cards unless withPbn sets it. withName, withPbn, withCards, withDealer, withVuln, withScoring, withDeclarer, withContract, withAuction(PBNAuction), withPlay(PBNPlay), withResult, withOverrides, build(): PBNDeal, plus fromDeal(dealBuilder, converters?) as an instance method and a static.

PBNSourceBuilder

Writes PBN text, for tests of code that reads files: the parser, pbn.diagnose, an import screen. Each withDeal adds a deal with Board (its 1-based position), a random Dealer and Vulnerable, and a random Deal. Its tags override them, a tag set to undefined is left out, and its lines are written verbatim after the tags, so malformed input is written as is. Tags go one per line and deals are separated by a blank line, after a % PBN 2.1 / % EXPORT header.

MethodDescription
withDeal({ tags?, lines? })Append a deal.
withDeals(count)Append count default deals.
withHeader(lines)Replace the header; [] writes none.
build()The PBN text.
toFile(name = "deals.pbn")The text as a File, for pbn.parseFile and pbn.diagnoseFile.
lineOf(dealIndex, prefix)1-based line, in build(), of the first line of that deal starting with prefix, to assert a diagnostic's line.
dealLines()The lines of each deal, without header and separators.
testing.ts
const source = new PBNSourceBuilder()
  .withDeal({ tags: { Dealer: "N", Vulnerable: undefined } })
  .withDeal({ lines: ['[Auction "N"]', "1NT AP", '[Contract "3NT"'] });

const text = source.build(); // "% PBN 2.1\n% EXPORT\n\n[Board \"1\"]\n[Dealer \"N\"]\n[Deal …"
const file = source.toFile("lesson.pbn");
source.lineOf(1, "[Contract"); // => the line a diagnostic about it points at
testing.ts
const fullDeal = new DealBuilder<string, string, string>()
  .withHands((h) => h.withHand("N", "AKQ2.KQ3.A54.K32").withHand("S", "7654.A87.T98.AT8"))
  .withAuction((a) => a.withDealer("N").addBid("1NT", { comment: "15-17" }).addPass().addBid("3NT").addAllPass())
  .withContract(new ContractBuilder().withDeclarer("N").withLevel(3).withSuit("NT").build())
  .withPlay((p) => p.withLeader("E").addTrick(["DA", "D2", "D3", "D4"]))
  .withReview("Standard 1NT-3NT")
  .rotate(2)
  .build();
// After rotate(2) the declarer is South and East's lead becomes West's

const tricks = new PlayBuilder()
  .withLeader("W")
  .withContract(new ContractBuilder().fromCompact("4H").build())
  .fromCompact("W / DA DK D9 D3")
  .toTricks();

const pbn = PBNDealBuilder.fromDeal(new DealBuilder()).build();
const trick2 = new TrickBuilder().withLead("W").addCards(["HA", "H3", "H5", "H2"]).withWinner("W").build();
testing.ts
const builder = new AuctionBuilder().fromCompact("N / 1H pass 2H AP");
builder.build(); // Auction
builder.toContract().build(); // Contract: 2H by North

const handsBuilder = new HandsBuilder().fromCompact("AKQ2.KQ3.A54.K32 JT98.JT9.KQJ.QJ9 7654.A87.T98.AT8 3.6542.7632.7654");
handsBuilder.toInline("N"); // "N:AKQ2.KQ3.A54.K32 JT98.JT9.KQJ.QJ9 ..."
handsBuilder.toRankRecord(); // HandsData<Rank>
handsBuilder.toStringRecord(); // { N: "AKQ2.KQ3.A54.K32", ... }

Scenarios

Named fixtures that return a builder, so you can keep chaining before build().

FunctionSignatureResult
auctionWithOpener1NT(declarer = "S") => AuctionBuilder3NT by the declarer, minimal sequence.
auctionWithMajorSuitGame(declarer = "S", suit: "S" | "H" = "H") => AuctionBuilderFour of a major.
auctionWithMinorSuitGame(declarer = "S", suit: "C" | "D" = "C") => AuctionBuilderFive of a minor.
slamAuction(declarer = "S", suit: Denomination = "NT") => AuctionBuilderLevel 6.
grandSlamAuction(declarer = "S", suit: Denomination = "NT") => AuctionBuilderLevel 7.
passedOutAuction(dealer = "S") => AuctionBuilderFour passes.
doubledContractAuction(declarer = "S", level: BidLevel = 4, suit: Denomination = "H") => AuctionBuilderContract doubled by an opponent.
dealWithContract(declarer = "S", level: BidLevel = 4, suit: Denomination = "H") => DealBuilderRandom hands, canonical auction and matching contract.
testing.ts
const gameAuction = auctionWithMajorSuitGame("S", "H").build(); // 4H by South
const passedOut = passedOutAuction("N").build();
const slamDeal = dealWithContract("N", 6, "NT").withPlay((p) => p.fromCompact("E / SA S2 S3 S4")).build();