← poker-tutor
import Foundation
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import PokerStore
import PokerUI
import TestKit
/// The gate: what the app does when somebody's free hands are gone, and — far more importantly —
/// what it does when they are not.
///
/// The failure that matters here is not somebody playing a hand they did not pay for. It is a
/// paying customer being stopped at a price they have already met, which is the one bug in this
/// area that would arrive as a refund request rather than a bug report. Most of what follows is
/// about the app refusing to put a paywall up.
@MainActor
private func app(handsPlayed: Int, subscribed: Bool, price: String? = "$9.99") -> AppState {
let session = TableSession(seed: 44, history: .inMemory())
session.pacedByCaller = true
let state = AppState(session: session)
state.dismissSheet()
let tally = TrialTally(store: InMemoryTally(), key: "paywall.test")
state.charge(for: tally, through: .stub(subscribed: subscribed, price: price))
for i in 0..<handsPlayed { tally.record(ordinaryHand(i + 1)) }
return state
}
private func ordinaryHand(_ number: Int) -> HandRecord {
HandRecord(sessionID: "s", sessionStarted: Date(timeIntervalSince1970: 1_780_000_000),
playedAt: Date(timeIntervalSince1970: 1_780_000_000 + Double(number) * 60),
handNumber: number, mode: "Tournament", bigBlind: 100, position: "BTN",
holeCards: "Ah Kd", board: "", netChips: 0, wentToShowdown: false, sawFlop: false,
putMoneyInPreflop: true, raisedPreflop: false, verdicts: [])
}
func paywallTests(_ t: TestRun) {
t.suite("who may sit down") { t in
let limit = FreeTrial.freeHands
t.test("the suite and the command line are never charged for anything") {
MainActor.assumeIsolated {
// An app with no tally has nobody to charge. If this ever fails, every other suite in
// the project starts hitting a paywall a hundred hands into a measurement.
let session = TableSession(seed: 7, history: .inMemory())
let plain = AppState(session: session)
plain.dismissSheet()
t.expect(plain.mayPlay, "a headless app must always be able to deal")
t.equal(plain.entitlement, .subscribed)
plain.start(.tournament)
t.expect(plain.session.isPlaying, "the table did not open")
t.equal(plain.openSheet, nil, "a sheet appeared in a headless app")
}
}
t.test("somebody inside the allowance sits down") {
MainActor.assumeIsolated {
let state = app(handsPlayed: limit - 1, subscribed: false)
state.start(.tournament)
t.expect(state.session.isPlaying, "one free hand left and the table would not open")
t.equal(state.openSheet, nil)
}
}
t.test("somebody out of free hands is asked to pay, and is not dealt in") {
MainActor.assumeIsolated {
let state = app(handsPlayed: limit, subscribed: false)
state.start(.tournament)
t.expect(!state.session.isPlaying, "the table dealt a hand nobody paid for")
t.equal(state.openSheet, "paywall-false", "the price was never shown")
}
}
t.test("a subscriber is never stopped, however many hands are behind them") {
MainActor.assumeIsolated {
let state = app(handsPlayed: limit * 10, subscribed: true)
state.start(.tournament)
t.expect(state.session.isPlaying, "a paying customer was locked out")
t.equal(state.openSheet, nil, "a paying customer was shown a price")
}
}
t.test("nobody is stopped before Apple has answered") {
MainActor.assumeIsolated {
// Until the first answer, "not subscribed" means "not known". Showing a paywall in
// that window shows it to somebody who has already paid.
let session = TableSession(seed: 9, history: .inMemory())
session.pacedByCaller = true
let state = AppState(session: session)
state.dismissSheet()
let tally = TrialTally(store: InMemoryTally(), key: "paywall.test.unchecked")
for i in 0..<(limit * 2) { tally.record(ordinaryHand(i + 1)) }
// A store that has not been started: nothing has been asked of Apple yet.
state.charge(for: tally, through: SubscriptionStore())
t.expect(state.mayPlay, "the app stopped somebody before it knew anything")
state.start(.tournament)
t.expect(state.session.isPlaying)
t.equal(state.openSheet, nil)
}
}
t.test("the paywall cannot be swiped away when there is nothing behind it") {
MainActor.assumeIsolated {
let state = app(handsPlayed: limit, subscribed: false)
state.start(.tournament)
t.equal(state.openSheet, "paywall-false", "dismissable, with no table to go back to")
}
}
t.test("opening the price on purpose can be closed again") {
MainActor.assumeIsolated {
let state = app(handsPlayed: 10, subscribed: false)
state.showPaywall()
t.equal(state.openSheet, "paywall-true")
state.dismissSheet()
t.equal(state.openSheet, nil)
}
}
}
t.suite("counting hands against the allowance") { t in
let limit = FreeTrial.freeHands
t.test("playing a hand in the app spends one") {
MainActor.assumeIsolated {
// The whole chain, rather than the arithmetic on its own: the session writes the hand
// down, and the tally has to have moved because of it.
let state = app(handsPlayed: 0, subscribed: false)
t.equal(state.handsPlayed, 0)
state.start(.tournament)
var steps = 0
while state.session.isPlaying, !state.handComplete, steps < 4_000 {
steps += 1
if state.session.heroToAct {
state.act(.fold)
} else if !state.stepOpponent() {
break
}
}
t.expect(state.handComplete, "the hand never finished")
t.equal(state.handsPlayed, 1, "a played hand was not counted")
}
}
t.test("the allowance is shown throughout the free hands and stops after") {
MainActor.assumeIsolated {
// A hundred free hands is short enough that the count is worth seeing from
// the first hand; it is also where a free player finds the price.
t.expect(app(handsPlayed: 0, subscribed: false).shouldWarnAboutAllowance,
"the count was not shown on the first hand")
t.expect(app(handsPlayed: limit - 1, subscribed: false).shouldWarnAboutAllowance,
"no notice with one hand left")
t.expect(!app(handsPlayed: limit, subscribed: false).shouldWarnAboutAllowance,
"still warning after the paywall took over")
t.expect(!app(handsPlayed: limit - 1, subscribed: true).shouldWarnAboutAllowance,
"a subscriber was warned about an allowance they are not on")
}
}
t.test("the count shown is the count left") {
MainActor.assumeIsolated {
t.equal(app(handsPlayed: limit - 7, subscribed: false).handsLeft, 7)
t.equal(app(handsPlayed: limit, subscribed: false).handsLeft, 0)
}
}
}
}
/// The free tier on the screens: one habit in full, the rest blurred, one drill open. `FreeView`
/// itself is tested with the scorecard; this is the app obeying it.
private func habitHand(_ number: Int, _ spot: SpotKind, _ error: ErrorKind?, cost: Double = 1) -> HandRecord {
HandRecord(sessionID: "s", sessionStarted: Date(timeIntervalSince1970: 1_780_000_000),
playedAt: Date(timeIntervalSince1970: 1_780_000_000 + Double(number) * 60),
handNumber: number, mode: "Tournament", bigBlind: 100, position: "BB",
holeCards: "Ah Kd", board: "2c 7h Js", netChips: -100, wentToShowdown: false,
sawFlop: true, putMoneyInPreflop: true, raisedPreflop: false,
verdicts: [VerdictRecord(street: "flop", spot: spot.name, leak: error?.name ?? "",
severity: error == nil ? 0 : 3, costInBB: error == nil ? 0 : cost,
headline: "h", detail: "d", taken: "call", best: "fold",
spoke: error != nil, confidence: 0.9)])
}
@MainActor
private func freeApp(subscribed: Bool) -> AppState {
let state = app(handsPlayed: 10, subscribed: subscribed)
var n = 1
// Limping: 30 of 60 chances, cheap. Folding to a re-raise: 6 of 15, expensive.
for i in 0..<60 { state.session.history.append(habitHand(n, .openingChance, i < 30 ? .limpedInstead : nil, cost: 0.4)); n += 1 }
for i in 0..<15 { state.session.history.append(habitHand(n, .facingReraise, i < 6 ? .foldedTooMuch : nil, cost: 40)); n += 1 }
return state
}
func freeTierScreensTests(_ t: TestRun) {
t.suite("the free tier on the screens") { t in
t.test("a free player is shown their most common habit, and Home agrees") {
MainActor.assumeIsolated {
let state = freeApp(subscribed: false)
let view = state.freeView
t.expect(view.isRestricted)
t.equal(view.visibleLeakID, "limping")
t.equal(view.hiddenCount, 1)
t.equal(state.focus?.id, "limping", "Home's card must name the same habit")
t.expect(state.allTimeScorecard.costing.first?.id == "fold-to-3bet",
"the card itself still leads with the money")
}
}
t.test("only that habit's drill opens; the others open the price") {
MainActor.assumeIsolated {
let state = freeApp(subscribed: false)
let shown = LeakCatalogue.named("limping")!
let hidden = LeakCatalogue.named("fold-to-3bet")!
state.dismissSheet()
state.offerDrill(hidden)
t.expect(state.openSheet?.hasPrefix("paywall") == true,
"a hidden habit's drill must show the price, got \(state.openSheet ?? "nothing")")
state.dismissSheet()
state.offerDrill(shown)
t.expect(state.openSheet?.hasPrefix("drill") == true, "the shown habit's drill must open")
t.expect(state.openSheet?.contains("limping") == true)
state.dismissSheet()
state.startOrResumeDrill(hidden)
t.expect(state.openSheet?.hasPrefix("paywall") == true, "startOrResume must gate too")
}
}
t.test("a subscriber has every drill and Home leads with the money") {
MainActor.assumeIsolated {
let state = freeApp(subscribed: true)
t.equal(state.freeView, .open)
t.equal(state.focus?.id, "fold-to-3bet")
state.dismissSheet()
state.offerDrill(LeakCatalogue.named("fold-to-3bet")!)
t.expect(state.openSheet?.hasPrefix("drill") == true, "a subscriber's drill must open")
}
}
}
}