Advertisement

← poker-tutor

Sources/PokerTests/PaywallTests.swift

12.4 KB Download file
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")
            }
        }
    }
}