diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 3519795..ba75b70 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -760,7 +760,16 @@ that filling it doesn't shove fifty-two cards down the screen under a thumb that is already reaching for one. *@
- + @* The first of the two questions a card is. A radiogroup rather than a tablist: + the grid below is not a different panel of content, it is the same thirteen + controls filtered, and single-select filtering is what a radio group is for. + The suits are a fixed set written by paint(), not here, so that the one place + that knows what a suit looks like stays CardPicker.SUITS. *@ + + @* Which suit's thirteen these are, said once for a screen reader rather than + left to be inferred from the chosen radio two elements up. *@ + + diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 155dccb..7e7fdf7 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -2664,9 +2664,70 @@ input[type="checkbox"] { color: #e07b7b; } +/* The first of the two questions. Four, side by side, in the same four places every + time the sheet opens — a row a thumb can learn, rather than a control to read. */ +#cardPickerSuits { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--sp-2); + margin-bottom: var(--sp-2); +} + +.pickerSuitTab { + display: flex; + align-items: center; + justify-content: center; + min-height: 48px; + border-radius: var(--r-md); + border: 1px solid var(--line); + background: var(--surface-2); + color: var(--text-bright); + font-family: inherit; + /* Larger than any type on the sheet. The glyph is the whole of the control, and at + body size four of them across a phone read as decoration on a row of blank + buttons rather than as the thing being chosen. */ + font-size: var(--fs-xl); + line-height: 1; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} + +/* Red whether or not it is the chosen one. Drawing the three unchosen suits in one grey + made a dealer reaching for the hearts read four glyphs to find them, which is the + search this sheet exists to remove — and it is the one thing about a suit that can be + seen before the shape is. The same red the slots above already use. */ +.pickerSuitTab.cardRed { + color: #e07b7b; +} + +/* The chosen suit is drawn as the cards below it are — white face, dark ink — because + it is a statement about them: these thirteen are that suit. Anything quieter and the + row reads as four buttons with one of them slightly darker. */ +.pickerSuitTab.chosen { + border-color: #d5d5d5; + background: #fdfdfd; + color: #111; +} + +.pickerSuitTab.cardRed.chosen { + color: #b32020; +} + +@media (hover: hover) { + .pickerSuitTab:hover:not(.chosen) { + border-color: var(--line-strong); + color: var(--text-bright); + } +} + +/* As many ranks across as the row holds, rather than a fixed count that is either too + many for a 360px phone or too few for one held sideways. 3.25rem is the floor a card + is allowed to reach: 52px, comfortably over the 44 a thumb needs, which comes out at + five across in portrait and nine or more in landscape. The arrows read the column + count back off this rule rather than assuming one — see CardPicker.gridStride. */ #cardPickerGrid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(auto-fill, minmax(3.25rem, 1fr)); gap: var(--sp-1); } @@ -6050,6 +6111,13 @@ body.hasActionBar #toastHost { column-gap: var(--sp-3); } + /* The card picker is six pixels over on the shortest phone in the list — 322px of + content in the 317 that 88vh comes to at 360. The same seven points the seat menu + takes buy 14, which is the whole of it. */ + #cardPickerSheet { + max-height: 92vh; + } + /* The move picker is a list of chairs and has no hand tier at all, so there is nothing to put in the right-hand column and a column reserved for nothing would narrow the names for no reason. One column, and the chairs spread across it. */ diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js index 117bfc0..6faf216 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js @@ -2,23 +2,51 @@ // // Typing "AH" into a two-character box is fine at a desk and miserable at a table: // it needs a keyboard, it accepts nonsense like "ZZ", and it happily lets the same -// card land in two players' hands. The picker is one tap per card, greys out cards -// already dealt somewhere else at the table, and stores exactly the same two-character -// string the rest of the tool has always read. +// card land in two players' hands. The picker greys out cards already dealt somewhere +// else at the table and stores exactly the same two-character string the rest of the +// tool has always read. +// +// It used to put all fifty-two on the screen at once, four suits across and thirteen +// ranks down, and that grid is 646px tall. Measured with the board tracked and seven +// cards 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 was reading down a column. +// Fifty-two 44px targets do not fit a phone in any arrangement: thirteen across is +// 572px of grid before gaps, which is wider than any phone in portrait. +// +// A card is not one choice out of fifty-two. It is two: a suit and a rank. So the sheet +// asks them in that order — four suits on a row, then the thirteen ranks of the one +// chosen — which is seventeen targets instead of fifty-two, all of them on the screen +// at once on every phone, and none of them smaller than they were. The suit stays put +// between picks, so a second card of the same suit is still a single tap. const CardPicker = { RANKS: ["A", "K", "Q", "J", "T", "9", "8", "7", "6", "5", "4", "3", "2"], SUITS: [ - { code: "S", symbol: "♠", red: false }, - { code: "H", symbol: "♥", red: true }, - { code: "D", symbol: "♦", red: true }, - { code: "C", symbol: "♣", red: false }, + { code: "S", symbol: "♠", name: "Spades", red: false }, + { code: "H", symbol: "♥", name: "Hearts", red: true }, + { code: "D", symbol: "♦", name: "Diamonds", red: true }, + { code: "C", symbol: "♣", name: "Clubs", red: false }, ], - // Which card in the grid the arrows are holding, as a flat index into - // RANKS x SUITS. Kept across a redraw so the grid doesn't send the dealer back to - // the ace of spades every time they pick something. + // Which card in the grid the arrows are holding, as an index into the thirteen + // ranks on show. Kept across a redraw so the grid doesn't send the dealer back to + // the ace every time they pick something — and kept across a change of suit, which + // is the same rank in a different suit rather than a new place to be. cursor: 0, + // Which suit's thirteen are on the grid. Deliberately not reset when the sheet + // opens: a dealer entering a flush board, or two suited hole cards, picks the suit + // once. Where the slot being filled already holds a card, that card's suit wins — + // the sheet should open showing what it is about to replace. + suit: "S", + state: null, hooks: null, // Which set of slots is being filled: a seat's two hole cards, or the five @@ -36,6 +64,7 @@ const CardPicker = { this.seatIndex = seatIndex; this.cardIndex = cardIndex; this.cursor = 0; + this.aimSuit(); this.render(); document.getElementById("cardPickerOverlay").hidden = false; }, @@ -51,6 +80,7 @@ const CardPicker = { this.boardLimit = Math.max(1, Math.min(5, limit)); this.cardIndex = Math.min(cardIndex, this.boardLimit - 1); this.cursor = 0; + this.aimSuit(); this.render(); document.getElementById("cardPickerOverlay").hidden = false; }, @@ -145,10 +175,10 @@ const CardPicker = { return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`; }, - // Every card in the deck, in the order the grid draws them: four suits across, - // thirteen ranks down. The arrow keys and the roving tabindex both work in this - // order, so it is written once rather than rebuilt by two nested loops that have to - // agree. + // Every card in the deck, ace down to two, each rank's four suits together. The + // grid shows one suit's worth of this at a time — see gridCards — and the duplicate + // check reads the whole of it, so the order is written once here rather than + // rebuilt by two nested loops that have to agree. deck() { const cards = []; this.RANKS.forEach(rank => { @@ -157,6 +187,64 @@ const CardPicker = { return cards; }, + suitOf(code) { + return this.SUITS.find(suit => suit.code === code) || this.SUITS[0]; + }, + + // The thirteen on the grid: one suit's worth of deck(), in deck order, so the ranks + // read A down to 2 exactly as they always have. + gridCards() { + return this.deck().filter(entry => entry.suit.code === this.suit); + }, + + // Open on the card that is already there. A slot holding the nine of diamonds + // should show the diamonds with the nine under the cursor — the dealer came here to + // change that card, and hunting for where it lives is work the sheet can do. An + // empty slot changes nothing, which is what keeps the suit across a flush board. + aimSuit() { + const current = (this.slotValues()[this.cardIndex] || "").toUpperCase(); + if (!/^[AKQJT2-9][SHDC]$/.test(current)) return; + this.suit = current.slice(1); + const rank = this.RANKS.indexOf(current.slice(0, 1)); + if (rank >= 0) this.cursor = rank; + }, + + // Changing suit is not moving: the cursor is a rank, and the same rank in another + // suit is the card directly beside the one you were on. Coming back with the cursor + // reset to the ace would undo the only thing the dealer had told the sheet. + setSuit(code, { focus = false } = {}) { + if (this.suit === code) return; + this.suit = code; + this.render(); + if (focus) { + const chosen = document.querySelector(`#cardPickerSuits [data-suit="${code}"]`); + if (chosen) chosen.focus({ preventScroll: true }); + } + }, + + // Arrow keys along the suits, the way the APG's radio group asks for: one Tab stop + // for the set, arrows to move within it, and moving is choosing. + onSuitKey(event) { + const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key]; + if (step === undefined) return; + event.preventDefault(); + const at = this.SUITS.findIndex(suit => suit.code === this.suit); + const to = (at + step + this.SUITS.length) % this.SUITS.length; + this.setSuit(this.SUITS[to].code, { focus: true }); + }, + + // How far Down and Up move, asked of the grid rather than assumed. The row is as + // many ranks as the width holds — five on a 360px phone, nine on a phone held + // sideways — so a constant here would be a lie on most screens, and the last time + // this number disagreed with the layout the cursor slid into a column nobody asked + // for. Outside a browser there is nothing to measure and nothing to move. + gridStride() { + const grid = typeof document !== "undefined" && document.getElementById("cardPickerGrid"); + if (!grid || typeof getComputedStyle !== "function") return this.RANKS.length; + const columns = getComputedStyle(grid).gridTemplateColumns.split(/\s+/).filter(Boolean).length; + return columns > 0 ? columns : this.RANKS.length; + }, + // Where the arrows can go: one card, in the direction asked for, and nowhere if // that runs off the end of the grid. Wrapping would put the dealer on a different // rank without them asking to move rank. @@ -197,7 +285,7 @@ const CardPicker = { }, onGridKey(event) { - const stride = this.SUITS.length; + const stride = this.gridStride(); const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: stride, ArrowUp: -stride }[event.key]; if (step !== undefined) { event.preventDefault(); @@ -266,9 +354,38 @@ const CardPicker = { slots.appendChild(btn); }); + // The first question. Four of them, always in the same four places, so the one + // the dealer wants is somewhere their thumb already knows. + const suits = document.getElementById("cardPickerSuits"); + suits.innerHTML = ""; + this.SUITS.forEach(suit => { + const chosen = suit.code === this.suit; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "pickerSuitTab"; + btn.classList.toggle("cardRed", suit.red); + btn.classList.toggle("chosen", chosen); + btn.textContent = suit.symbol; + btn.setAttribute("role", "radio"); + btn.setAttribute("aria-checked", chosen ? "true" : "false"); + // The glyph is the whole of the control on the eye, and "♦, radio button" + // is not a suit out loud. The word is. + btn.setAttribute("aria-label", suit.name); + btn.dataset.suit = suit.code; + // One Tab stop for the four, like the grid below it has one for thirteen. + btn.tabIndex = chosen ? 0 : -1; + btn.dataset.focusKey = `suit-${suit.code}`; + btn.addEventListener("click", () => this.setSuit(suit.code, { focus: true })); + btn.addEventListener("keydown", event => this.onSuitKey(event)); + suits.appendChild(btn); + }); + + const gridLabel = document.getElementById("cardPickerGridLabel"); + if (gridLabel) gridLabel.textContent = `${this.suitOf(this.suit).name} — pick a rank`; + const grid = document.getElementById("cardPickerGrid"); grid.innerHTML = ""; - this.deck().forEach(({ rank, suit, card }) => { + this.gridCards().forEach(({ rank, suit, card }) => { const takenBy = used.get(card); const btn = document.createElement("button"); diff --git a/tests/holdem/theDeckInTwoQuestions.test.js b/tests/holdem/theDeckInTwoQuestions.test.js new file mode 100644 index 0000000..9c70a92 --- /dev/null +++ b/tests/holdem/theDeckInTwoQuestions.test.js @@ -0,0 +1,245 @@ +// 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/); + }); +}); diff --git a/tests/holdem/theKeyboardInASheet.test.js b/tests/holdem/theKeyboardInASheet.test.js index ca46083..844975b 100644 --- a/tests/holdem/theKeyboardInASheet.test.js +++ b/tests/holdem/theKeyboardInASheet.test.js @@ -258,12 +258,11 @@ describe("a redraw that does not drop the dealer", () => { }); }); -describe("fifty-two cards, one Tab stop", () => { - test("the deck is the grid's order, written once", () => { +describe("thirteen cards, one Tab stop", () => { + test("the deck is written once, ace down to two", () => { const deck = CardPicker.deck(); assert.equal(deck.length, 52); assert.equal(new Set(deck.map(c => c.card)).size, 52); - // Four suits across, thirteen ranks down — which is what the arrow keys step by. assert.deepEqual(deck.slice(0, 4).map(c => c.card), ["AS", "AH", "AD", "AC"]); assert.equal(deck[4].card, "KS"); assert.equal(deck[51].card, "2C"); @@ -285,7 +284,7 @@ describe("fifty-two cards, one Tab stop", () => { }; test("a step is one card, whether or not that card is already out", () => { - // 7C is dealt. Stepping down the clubs from the eight lands on it and says so, + // The seven is dealt. Stepping along from the eight lands on it and says so, // rather than quietly arriving at the six. withGrid(["8C", "7C!", "6C"], () => { assert.equal(CardPicker.nextCursor(0, 1), 1); @@ -294,28 +293,29 @@ describe("fifty-two cards, one Tab stop", () => { // The whole reason the skipping had to go. Hunting for the next card that could take // focus did the exact thing the no-wrap rule below was written to prevent: it moved - // the dealer sideways without being asked. Right from the ace of spades over a dealt - // ace of hearts landed on the ace of diamonds, so the column silently became the one - // to its right, and the next press of Down walked the king of diamonds. + // the dealer sideways without being asked. Right from the ace over a dealt king + // landed on the queen, so the column silently became the one to its right, and the + // next press of Down walked a rank nobody had asked for. test("and it never slides the dealer into a column they didn't ask for", () => { - withGrid(["AS", "AH!", "AD", "AC", "KS", "KH", "KD", "KC"], () => { + withGrid(["AS", "KS!", "QS", "JS", "TS", "9S", "8S", "7S"], () => { const afterRight = CardPicker.nextCursor(0, 1); - assert.equal(afterRight, 1, "a dealt ace of hearts is still the cell to the right of the ace of spades"); - assert.equal(CardPicker.nextCursor(afterRight, 4), 5, "and down from it is the king of the same suit"); + assert.equal(afterRight, 1, "a dealt king is still the cell to the right of the ace"); + assert.equal(CardPicker.nextCursor(afterRight, 4), 5, + "and down a row of four from it is the nine, four ranks along"); }); }); - // Wrapping would answer "move me down one rank" by moving the dealer to a different - // suit, which is not what they asked and not where they would look. + // Wrapping would answer "move me down a row" by moving the dealer to the other end + // of the grid, which is not what they asked and not where they would look. test("the edge of the grid is the edge, not a way round to the other side", () => { - withGrid(["AS", "AH", "AD", "AC"], () => { + withGrid(["AS", "KS", "QS", "JS"], () => { assert.equal(CardPicker.nextCursor(3, 1), 3); assert.equal(CardPicker.nextCursor(0, -1), 0); }); }); test("only one card is in the Tab order at a time", () => { - withGrid(["AS", "AH", "AD", "AC"], nodes => { + withGrid(["AS", "KS", "QS", "JS"], nodes => { CardPicker.aimCursor(2); assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, 0, -1]); }); @@ -325,7 +325,7 @@ describe("fifty-two cards, one Tab stop", () => { // become a dealt card. It stays there, because that cell is still a cell and its // neighbours are still where they were a moment ago. test("a cursor left on a dealt card stays on it", () => { - withGrid(["AS!", "AH", "AD", "AC"], nodes => { + withGrid(["AS!", "KS", "QS", "JS"], nodes => { CardPicker.aimCursor(0); assert.equal(CardPicker.cursor, 0); assert.deepEqual(nodes.map(n => n.tabIndex), [0, -1, -1, -1]); @@ -333,7 +333,7 @@ describe("fifty-two cards, one Tab stop", () => { }); test("and a grid with nothing left in it still has somewhere to put the cursor", () => { - withGrid(["2S!", "2H!", "2D!", "2C!"], nodes => { + withGrid(["5S!", "4S!", "3S!", "2S!"], nodes => { CardPicker.aimCursor(3); assert.equal(CardPicker.cursor, 3); assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, -1, 0]); @@ -344,23 +344,39 @@ describe("fifty-two cards, one Tab stop", () => { // Home and End lean on: they ask for one card past either end and take what they // are given. test("Home and End ask past the ends and land on them", () => { - withGrid(["AS", "AH", "AD", "AC"], () => { + withGrid(["AS", "KS", "QS", "JS"], () => { assert.equal(CardPicker.nextCursor(-1, 1), 0); assert.equal(CardPicker.nextCursor(4, -1), 3); }); }); - test("the arrows step by a row of suits, not by a guess", () => { + // It used to step by `SUITS.length`, because the grid was four suits wide at every + // size. The grid is thirteen ranks of one suit now and takes as many columns as the + // width holds — five on a 360px phone, seven at 460 — so a constant would be wrong + // on most screens, which is the thing that slid the cursor into the wrong column + // the last time these two disagreed. It asks the layout instead. + test("the arrows step by the row the grid actually has, not by a guess", () => { const picker = src("cardPicker"); const at = picker.indexOf("onGridKey(event)"); assert.ok(at >= 0, "the grid has no arrow keys"); const body = picker.slice(at, picker.indexOf("\n },", at)); - assert.match(body, /const stride = this\.SUITS\.length/); + assert.match(body, /const stride = this\.gridStride\(\)/); assert.match(body, /ArrowRight: 1.*ArrowLeft: -1.*ArrowDown: stride.*ArrowUp: -stride/s); assert.match(body, /"Home"/); assert.match(body, /"End"/); }); + test("and the row it actually has is read off the grid", () => { + const picker = src("cardPicker"); + const at = picker.indexOf("gridStride() {"); + assert.ok(at >= 0, "there is no gridStride"); + const body = picker.slice(at, picker.indexOf("\n },", at)); + assert.match(body, /getComputedStyle\(grid\)\.gridTemplateColumns/); + // Outside a browser there is nothing to measure, and thirteen ranks in one row + // is the shape that makes Up and Down do nothing rather than something wrong. + assert.match(body, /return this\.RANKS\.length/); + }); + test("a fresh sheet starts at the top of the deck", () => { const picker = src("cardPicker"); ["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"]