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 was reading
down a column to find it. No arrangement 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. 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. Portrait content went 816 -> 372,
landscape 816 -> 322.
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 one
tap is what picking a card used to cost *plus* a scroll through thirteen
rows. Opening on a slot that already holds a card shows that card's suit
with it under the cursor, which is the case where the dealer came to
change something rather than add it.
Three things that had to move with it:
- the cursor is a rank now, 0-12, and changing suit deliberately leaves
it alone: the same rank in another suit is the card beside the one
you were on.
- the arrows read the column count off the layout instead of assuming
SUITS.length. The rank row is auto-fill — five across on a 360px
phone, seven at 460 — and the last time this number disagreed with
the grid it slid the cursor into a column nobody asked for.
- the suits are a radiogroup, not a tablist: the grid below is not
another panel of content, it is the same thirteen controls filtered,
and the unchosen red suits stay red, because reading four shapes to
find the hearts is the search this sheet exists to remove.
deck() still returns all fifty-two and the duplicate check still reads
all of it, so splitting the grid by suit cannot let a card be dealt
twice.
1276 tests pass; the layout sweep reports clean.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
246 lines
11 KiB
JavaScript
246 lines
11 KiB
JavaScript
// 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/);
|
|
});
|
|
});
|