// Fifty-two cards on a phone. // // The picker put the whole deck on the screen at once — four suits across, thirteen // ranks down — and that grid is 646px tall. Measured with the board tracked and seven // cards already out: // // 640x360 816px of content in a 317px sheet, 40 of the 52 below the fold // 667x375 816 in 330, 40 below // 844x390 774 in 343, 36 below // 375x667 816 in 587, 20 below // 1280x900 774 in 775, none // // So on every phone the tool is actually used on, entering the flop began with a // scroll, and a dealer looking for the nine of diamonds read down a column to find it. // There is no arrangement that fixes that: fifty-two targets at the 44px this sweep // holds everything else to need 572px of width before gaps if they are thirteen across, // which is wider than any phone in portrait, and thirteen rows deep if they are four. // // A card is not one choice out of fifty-two. It is two — a suit and a rank — and that // is how a dealer says it out loud. So the sheet asks them in that order: four suits on // a row, then the thirteen ranks of the one chosen. Seventeen targets instead of // fifty-two, none of them smaller than they were, and measured afterwards at eight // viewports from 360x640 to 1280x900: nothing below the fold, nothing scrolling, every // button still at least 44x44 and still hit-testing as itself. // // The second tap is not a second cost in the usual case. The suit stays where it was // put, so a second card of the same suit is one tap — and picking one card used to be // one tap plus a scroll through thirteen rows. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); const fs = require("fs"); const path = require("path"); const { CardPicker } = require("./load"); const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net"); const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8"); const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8"); const SRC = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "cardPicker.js"), "utf8"); const ruleFor = selector => { const at = CSS.indexOf(`\n${selector} {`); assert.ok(at >= 0, `there is no \`${selector}\` rule`); return CSS.slice(at, CSS.indexOf("}", at)); }; const methodBody = name => { const at = SRC.indexOf(`\n ${name}`); assert.ok(at >= 0, `cardPicker has no ${name}`); return SRC.slice(at, SRC.indexOf("\n },", at)); }; // The picker reaches for the global document in two places. Neither is exercised here — // these tests ask what it decides, not what it draws — but aimSuit reads the slot it is // about to fill, so it needs the state and nothing else. function aimedAt(board, cardIndex, { suit = "S", cursor = 0 } = {}) { CardPicker.state = { board: board.slice(), seats: [] }; CardPicker.target = "board"; CardPicker.boardLimit = board.length; CardPicker.cardIndex = cardIndex; CardPicker.suit = suit; CardPicker.cursor = cursor; CardPicker.aimSuit(); return { suit: CardPicker.suit, cursor: CardPicker.cursor }; } // --------------------------------------------------------------------------- describe("the deck is still the deck", () => { test("all fifty-two of it, ace down to two", () => { const deck = CardPicker.deck(); assert.equal(deck.length, 52); assert.equal(new Set(deck.map(entry => entry.card)).size, 52); assert.equal(deck[0].card, "AS"); assert.equal(deck[51].card, "2C"); }); // Which is what the duplicate check reads, and why splitting the *grid* by suit // cannot let the same card be dealt twice. test("and a card already out is still a card already out, in any suit", () => { CardPicker.state = { seats: [{ name: "Mike", cards: ["AS", "KH"] }], board: ["7D", "", "", "", ""], }; CardPicker.target = "board"; CardPicker.boardLimit = 5; CardPicker.cardIndex = 1; const used = CardPicker.cardsInUse(); assert.equal(used.get("AS"), "Mike"); assert.equal(used.get("KH"), "Mike"); assert.equal(used.get("7D"), "the board"); assert.equal(used.size, 3); }); }); describe("one suit at a time", () => { test("the grid is thirteen ranks of the chosen suit, in deck order", () => { CardPicker.suit = "D"; const cards = CardPicker.gridCards(); assert.equal(cards.length, 13); assert.deepEqual(cards.map(entry => entry.card).slice(0, 3), ["AD", "KD", "QD"]); assert.equal(cards[12].card, "2D"); assert.ok(cards.every(entry => entry.suit.code === "D")); }); test("every suit gets the same thirteen", () => { CardPicker.SUITS.forEach(suit => { CardPicker.suit = suit.code; assert.deepEqual(CardPicker.gridCards().map(entry => entry.rank), CardPicker.RANKS); }); }); // The glyph is the whole of the control on the eye, and "♦, radio button" is not a // suit out loud. test("and a suit has a name to be called by", () => { assert.deepEqual(CardPicker.SUITS.map(suit => suit.name), ["Spades", "Hearts", "Diamonds", "Clubs"]); assert.match(methodBody("paint()"), /aria-label", suit\.name/); }); test("an unknown suit code falls back rather than emptying the grid", () => { assert.equal(CardPicker.suitOf("Z").code, "S"); assert.equal(CardPicker.suitOf("H").symbol, "♥"); }); }); describe("where the sheet opens", () => { // The dealer came here to change that card. Hunting for where it lives is work the // sheet can do. test("on the suit of the card already in the slot, with it under the cursor", () => { assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 0), { suit: "H", cursor: 7 }); assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 1), { suit: "D", cursor: 12 }); assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 2), { suit: "S", cursor: 4 }); }); // Which is what makes a flush board, or two suited hole cards, one tap each after // the first. test("and an empty slot leaves the suit where the dealer put it", () => { assert.deepEqual(aimedAt(["7H", "", "", "", ""], 1, { suit: "H", cursor: 7 }), { suit: "H", cursor: 7 }); }); test("junk in a slot is left alone rather than read as a suit", () => { assert.deepEqual(aimedAt(["ZZ", "", "", "", ""], 0, { suit: "C", cursor: 3 }), { suit: "C", cursor: 3 }); assert.deepEqual(aimedAt(["", "", "", "", ""], 0, { suit: "C", cursor: 3 }), { suit: "C", cursor: 3 }); }); test("both ways in ask the question before they draw", () => { ["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"] .forEach(signature => { const at = SRC.indexOf(signature); assert.ok(at >= 0, `there is no \`${signature}\``); assert.match(SRC.slice(at, SRC.indexOf("this.render();", at)), /this\.aimSuit\(\);/); }); }); }); describe("changing suit is not moving", () => { // The cursor is a rank. The same rank in another suit is the card beside the one you // were on, so coming back to the ace would undo the only thing the dealer had said. test("the rank under the cursor survives the change", () => { const body = methodBody("setSuit(code, { focus = false } = {}) {"); assert.ok(!/cursor/.test(body), "setSuit still has an opinion about where the cursor is"); }); test("and asking for the suit already showing redraws nothing", () => { const body = methodBody("setSuit(code, { focus = false } = {}) {"); assert.match(body, /if \(this\.suit === code\) return;/); }); // The APG's radio group: one Tab stop for the set, arrows to move within it, and // moving is choosing. Wrapping is right here and wrong in the grid below — four // suits are a ring with no ends, thirteen ranks have two. test("the arrows run round the four suits", () => { const body = methodBody("onSuitKey(event)"); assert.match(body, /ArrowRight: 1.*ArrowDown: 1.*ArrowLeft: -1.*ArrowUp: -1/s); assert.match(body, /% this\.SUITS\.length/); assert.match(body, /event\.preventDefault\(\)/); }); }); describe("what the markup and the stylesheet have to agree about", () => { // Not a tablist: the grid below is not another panel of content, it is the same // thirteen controls filtered, and single-select filtering is what a radio group is. test("the suits are a radio group with a name", () => { const at = PAGE.indexOf('id="cardPickerSuits"'); assert.ok(at >= 0, "there is no suit row"); const tag = PAGE.slice(PAGE.lastIndexOf("<", at), PAGE.indexOf(">", at)); assert.match(tag, /role="radiogroup"/); assert.match(tag, /aria-label="Suit"/); }); test("each suit says whether it is the chosen one, and only one is a Tab stop", () => { const body = methodBody("paint()"); assert.match(body, /setAttribute\("role", "radio"\)/); assert.match(body, /aria-checked", chosen \? "true" : "false"/); assert.match(body, /tabIndex = chosen \? 0 : -1/); }); test("and the grid says which suit's thirteen these are", () => { const at = PAGE.indexOf('id="cardPickerGrid"'); assert.ok(at >= 0); assert.match(PAGE.slice(at, PAGE.indexOf(">", at)), /aria-labelledby="cardPickerGridLabel"/); assert.match(methodBody("paint()"), /gridLabel\.textContent = `\$\{this\.suitOf\(this\.suit\)\.name\}/); }); // A row of four, in the same four places every time, so the one the dealer wants is // somewhere their thumb already knows. test("the suit row is four fixed columns, not as many as fit", () => { assert.match(ruleFor("#cardPickerSuits"), /grid-template-columns:\s*repeat\(4, minmax\(0, 1fr\)\)/); }); test("a suit is a target before it is a glyph", () => { assert.match(ruleFor(".pickerSuitTab"), /min-height:\s*48px/); }); // Drawing the three unchosen suits in one grey made a dealer reaching for the hearts // read four shapes to find them, which is the search this sheet exists to remove. test("red is red whether or not it is the one chosen", () => { assert.match(ruleFor(".pickerSuitTab.cardRed"), /color:\s*#e07b7b/); assert.match(ruleFor(".pickerSuitTab.chosen"), /background:\s*#fdfdfd/); assert.match(ruleFor(".pickerSuitTab.cardRed.chosen"), /color:\s*#b32020/); }); // Five across on a 360px phone, seven at 460, more on a phone held sideways. test("the ranks take as many columns as the row holds", () => { assert.match(ruleFor("#cardPickerGrid"), /grid-template-columns:\s*repeat\(auto-fill, minmax\(3\.25rem, 1fr\)\)/); }); // Which is why the arrows cannot assume a number any more. test("so the arrows read the column count back off the layout", () => { assert.match(methodBody("onGridKey(event)"), /const stride = this\.gridStride\(\)/); assert.match(methodBody("gridStride()"), /getComputedStyle\(grid\)\.gridTemplateColumns/); }); test("what was measured is written beside the code", () => { const note = SRC.slice(0, SRC.indexOf("const CardPicker = {")); assert.match(note, /646px/); assert.match(note, /40 of the 52/); assert.match(note, /572px/); }); });