Hold'em: every seat on the ring is the same seat, and the ring is measured before it is believed #122
@@ -26,7 +26,10 @@
|
||||
</div>
|
||||
<div id="holdemHeaderActions">
|
||||
<button id="misdealBtn" class="holdemBtn" title="Take this hand back — every chip returns to the player who put it in, and the same button deals again" hidden>Misdeal</button>
|
||||
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
|
||||
@* Both the label and the title are rewritten every render from whether a hand
|
||||
is out — see Render.renderHeaderActions. What is here is what is true before
|
||||
the first render: no hand, so pressing it only moves the button. *@
|
||||
<button id="nextHandBtn" class="holdemBtn" title="Pass the button on again without dealing — for a hand nobody played">Move Button ⟳</button>
|
||||
@* Not the accent any more. It is a shortcut to a sheet, available all night,
|
||||
and it is never the next step — the bar always has that, and at showdown
|
||||
the bar says so in as many words. See .actionAdvance in holdem.css. *@
|
||||
@@ -44,7 +47,7 @@
|
||||
|
||||
Not an `*Overlay` id, deliberately — Modal watches those and would take the
|
||||
sidebar panel this becomes on a desktop for a dialog. It is driven entirely by
|
||||
body.seatSheetOpen, and it is `display: none` above 700px where the panel is
|
||||
body.seatSheetOpen, and it is `display: none` above 900px where the panel is
|
||||
just a panel. *@
|
||||
<div id="seatSheetScrim"></div>
|
||||
|
||||
@@ -89,11 +92,59 @@
|
||||
half-way through a night. *@
|
||||
<details class="sidebarPanel" id="tableSetupPanel">
|
||||
<summary><h2>Table Setup</h2> <span class="panelGlance" id="glanceTableSetup" hidden></span></summary>
|
||||
@* The numbers first, and only the numbers.
|
||||
|
||||
This panel is the one thing that is open when the tool is first
|
||||
opened, and it was 886px of a 667px phone — the two figures its own
|
||||
summary glances at, the blinds, sat below 218px of prose explaining
|
||||
three switches a dealer may never touch. The three have gone into
|
||||
"While you deal" below, with the paragraphs that explain them, which
|
||||
is where House take and Without live betting already were: the panel
|
||||
had the vocabulary for this and was only using it for money. *@
|
||||
@* Every field below takes digits and none of them is type="number".
|
||||
That is deliberate, and it is the second half of a fix — the first
|
||||
half is Render.setField, which stopped a re-render writing over the
|
||||
text somebody was still typing.
|
||||
|
||||
type="number" was costing more than it paid here. It silently drops
|
||||
characters it dislikes with no feedback to the person or to assistive
|
||||
tech; it reports a half-typed "0." as "0", which is what let the
|
||||
decimal point be written away; it does not expose selectionStart, so
|
||||
nothing can put a caret back where it was; it draws a spin button
|
||||
into a 54px-wide blind field, where five digits already need 45 of
|
||||
the 52 available; and it is the control GOV.UK dropped from their
|
||||
number patterns after user testing, for the same reasons plus Dragon
|
||||
and NVDA.
|
||||
|
||||
inputmode is what actually summons the keypad on a phone, and it is
|
||||
the better keypad: "decimal" carries the separator for the reader's
|
||||
locale, which is the key a dealer needs to type 0.25 and the one
|
||||
type="number" leaves them hunting for on iOS.
|
||||
|
||||
min/max/step go with it. Nothing relied on them: the seat count is
|
||||
clamped to 2..24 in main.js before it is used, and the money fields
|
||||
are read through a finite-number check that a spinner never provided
|
||||
anyway. *@
|
||||
<div class="panelBody">
|
||||
<label class="fieldRow">
|
||||
<span>Seats at table</span>
|
||||
<input type="number" id="seatCountInput" min="2" max="24" value="10" />
|
||||
<input type="text" id="seatCountInput" inputmode="numeric" autocomplete="off" value="10" />
|
||||
</label>
|
||||
<div class="blindsRow">
|
||||
<label class="fieldRow">
|
||||
<span>Small blind</span>
|
||||
<input type="text" id="smallBlindInput" inputmode="decimal" autocomplete="off" />
|
||||
</label>
|
||||
<label class="fieldRow">
|
||||
<span>Big blind</span>
|
||||
<input type="text" id="bigBlindInput" inputmode="decimal" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="fieldRow">
|
||||
<span>Starting stack ($)</span>
|
||||
<input type="text" id="defaultStackInput" inputmode="decimal" autocomplete="off" placeholder="e.g. 200" />
|
||||
</label>
|
||||
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
|
||||
<label class="fieldRow">
|
||||
<span>Blinds move</span>
|
||||
<select id="directionSelect">
|
||||
@@ -101,42 +152,37 @@
|
||||
<option value="counterclockwise">Counterclockwise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="fieldRow">
|
||||
<span>Dedicated dealer (not a seat)</span>
|
||||
<input type="checkbox" id="dedicatedDealerToggle" />
|
||||
</label>
|
||||
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
|
||||
<label class="fieldRow">
|
||||
<span>Ask me for the cards as they come out</span>
|
||||
<input type="checkbox" id="trackBoardToggle" />
|
||||
</label>
|
||||
@* The label used to lead with "Track the board", which is the feature's name
|
||||
rather than what pressing it does to your evening. What it does is put a
|
||||
card sheet in front of you at four moments: each street, and the showdown. *@
|
||||
<p class="fieldHint">Every time you deal a street, a sheet asks which cards came off the deck, and puts them on the felt. At the showdown it asks for each hand that turns over — then Resolve Pot reads the winner and the amount instead of asking you. Close the sheet at any point to go back to picking the winner yourself.</p>
|
||||
<label class="fieldRow">
|
||||
<span>Keep the screen on during a hand</span>
|
||||
<input type="checkbox" id="keepScreenAwakeToggle" />
|
||||
</label>
|
||||
@* On by default, and the only setting here that is. A dealer has to
|
||||
opt in to the tool doing more than they asked; they should not have
|
||||
to opt in to it staying readable. *@
|
||||
<p class="fieldHint">Stops the phone dimming and locking between the flop and the turn. It only holds while betting is open, so the screen sleeps as usual during breaks — and the tool says so the first time it takes hold.</p>
|
||||
<div class="blindsRow">
|
||||
<label class="fieldRow">
|
||||
<span>Small blind</span>
|
||||
<input type="number" id="smallBlindInput" min="0" step="0.5" />
|
||||
</label>
|
||||
<label class="fieldRow">
|
||||
<span>Big blind</span>
|
||||
<input type="number" id="bigBlindInput" min="0" step="0.5" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="fieldRow">
|
||||
<span>Starting stack ($)</span>
|
||||
<input type="number" id="defaultStackInput" min="0" step="1" placeholder="e.g. 200" />
|
||||
</label>
|
||||
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
|
||||
|
||||
@* All three change what the tool does rather than what the table is,
|
||||
and all three are set once and then left alone. The glance says so
|
||||
when one of them is not on its default, because a switch you cannot
|
||||
see is a switch you cannot remember turning on. *@
|
||||
<details class="subPanel">
|
||||
<summary>While you deal <span class="panelGlance subPanelGlance" id="glanceWhileYouDeal" hidden></span></summary>
|
||||
<div class="subPanelBody">
|
||||
<label class="fieldRow">
|
||||
<span>Dedicated dealer (not a seat)</span>
|
||||
<input type="checkbox" id="dedicatedDealerToggle" />
|
||||
</label>
|
||||
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
|
||||
<label class="fieldRow">
|
||||
<span>Ask me for the cards as they come out</span>
|
||||
<input type="checkbox" id="trackBoardToggle" />
|
||||
</label>
|
||||
@* The label used to lead with "Track the board", which is the feature's name
|
||||
rather than what pressing it does to your evening. What it does is put a
|
||||
card sheet in front of you at four moments: each street, and the showdown. *@
|
||||
<p class="fieldHint">Every time you deal a street, a sheet asks which cards came off the deck, and puts them on the felt. At the showdown it asks for each hand that turns over — then Resolve Pot reads the winner and the amount instead of asking you. Close the sheet at any point to go back to picking the winner yourself.</p>
|
||||
<label class="fieldRow">
|
||||
<span>Keep the screen on during a hand</span>
|
||||
<input type="checkbox" id="keepScreenAwakeToggle" />
|
||||
</label>
|
||||
@* On by default, and the only setting here that is. A dealer has to
|
||||
opt in to the tool doing more than they asked; they should not have
|
||||
to opt in to it staying readable. *@
|
||||
<p class="fieldHint">Stops the phone dimming and locking between the flop and the turn. It only holds while betting is open, so the screen sleeps as usual during breaks — and the tool says so the first time it takes hold.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="subPanel">
|
||||
<summary>House take</summary>
|
||||
@@ -144,11 +190,11 @@
|
||||
<div class="blindsRow">
|
||||
<label class="fieldRow">
|
||||
<span>Rake (%)</span>
|
||||
<input type="number" id="rakePercentInput" min="0" max="100" step="0.5" placeholder="0" />
|
||||
<input type="text" id="rakePercentInput" inputmode="decimal" autocomplete="off" placeholder="0" />
|
||||
</label>
|
||||
<label class="fieldRow">
|
||||
<span>Cap ($)</span>
|
||||
<input type="number" id="rakeCapInput" min="0" step="1" placeholder="none" />
|
||||
<input type="text" id="rakeCapInput" inputmode="decimal" autocomplete="off" placeholder="none" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="fieldRow">
|
||||
@@ -169,7 +215,7 @@
|
||||
<div class="subPanelBody">
|
||||
<label class="fieldRow">
|
||||
<span>Base pot amount ($)</span>
|
||||
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
|
||||
<input type="text" id="basePotInput" inputmode="decimal" autocomplete="off" placeholder="e.g. 500" />
|
||||
</label>
|
||||
<p class="fieldHint">For tracking a hand by hand instead of tapping the action: everyone is assumed to have put in this much, unless their seat says otherwise.</p>
|
||||
</div>
|
||||
@@ -275,16 +321,36 @@
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary><h2>Chip Legend</h2> <span class="panelGlance" id="glanceChips" hidden></span></summary>
|
||||
@* The panel was 793px on an iPhone and 420 of them were the colour form:
|
||||
five rows of swatch, value box and a red delete block, then an add
|
||||
button and a paragraph of instructions — all of it above "Stacks in
|
||||
chips", which is the part a dealer opens this panel *during* a game to
|
||||
read. Setting the colours is a thing you do once, on the first night,
|
||||
and it was sitting on top of the thing you do at every colour-up.
|
||||
|
||||
So the order is the other way round now, and the form is behind a
|
||||
disclosure, which is the shape Table Setup already uses for House take
|
||||
and Without live betting. The legend line stays out in the open: it is
|
||||
what the panel is named for, it is two lines rather than four hundred
|
||||
pixels, and it answers the other question a dealer opens this for —
|
||||
what is the green one worth. *@
|
||||
<div class="panelBody">
|
||||
<div id="chipLegendList"></div>
|
||||
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
||||
<p class="fieldHint">Give each chip color a value, then count chips on a seat instead of typing a total.</p>
|
||||
<ul id="chipLegendStrip" class="chipLegendStrip"></ul>
|
||||
|
||||
<div id="colourUp" hidden>
|
||||
<h3 class="panelSubhead">Stacks in chips</h3>
|
||||
<dl id="colourUpList" class="colourUpList"></dl>
|
||||
<p class="fieldHint">The fewest chips that make each stack — for colouring up, or counting one out.</p>
|
||||
</div>
|
||||
|
||||
<details class="subPanel">
|
||||
<summary>Colours and values</summary>
|
||||
<div class="subPanelBody">
|
||||
<div id="chipLegendList"></div>
|
||||
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
||||
<p class="fieldHint">Give each chip color a value, then count chips on a seat instead of typing a total.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -374,19 +440,29 @@
|
||||
<span>Name</span>
|
||||
<input type="text" class="seatNameInput" placeholder="Seat" maxlength="24" />
|
||||
</label>
|
||||
<div class="seatCardToggles">
|
||||
<label class="inHandToggle" title="This seat is playing the current hand">
|
||||
<input type="checkbox" class="inHandCheckbox" />
|
||||
<span>In hand</span>
|
||||
</label>
|
||||
<label class="foldToggle" title="Folded">
|
||||
<input type="checkbox" class="foldCheckbox" />
|
||||
<span>Fold</span>
|
||||
</label>
|
||||
@* One question with three answers, not two boxes with four corners.
|
||||
|
||||
A seat is sitting out, in the hand, or folded — and "folded" only means
|
||||
anything while the seat is in. So the two checkboxes here could be ticked
|
||||
into a state that means nothing, and the code that stopped them doing it
|
||||
`disabled` the Fold box whenever In hand was clear. Measured: that left the
|
||||
label at the same opacity and the same colour with a `title` on it, so a
|
||||
dealer pressing Fold on a seat that was sitting out got no movement and no
|
||||
reason — the exact thing round 16 went through the tool to remove, hiding
|
||||
from the rule that finds it because the title sat on the label rather than
|
||||
on the control.
|
||||
|
||||
Three named states cannot contradict each other and none of them is ever
|
||||
unavailable, so there is nothing left to dim. The radiogroup is the same
|
||||
shape the card picker's suits use. *@
|
||||
<div class="seatState" role="radiogroup" aria-label="This seat">
|
||||
<button type="button" class="seatStateOption" data-seat-state="out" role="radio" aria-checked="false">Sitting out</button>
|
||||
<button type="button" class="seatStateOption" data-seat-state="in" role="radio" aria-checked="false">In the hand</button>
|
||||
<button type="button" class="seatStateOption" data-seat-state="folded" role="radio" aria-checked="false">Folded</button>
|
||||
</div>
|
||||
<label class="fieldRow stackRow">
|
||||
<span>Stack</span>
|
||||
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
|
||||
<input type="text" class="stackInput" inputmode="decimal" autocomplete="off" placeholder="table default" />
|
||||
</label>
|
||||
<div class="seatBuyIns" hidden></div>
|
||||
|
||||
@@ -410,7 +486,7 @@
|
||||
<div class="chipCounts"></div>
|
||||
<label class="fieldRow amountOverrideRow">
|
||||
<span>$ amount</span>
|
||||
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
|
||||
<input type="text" class="amountOverrideInput" inputmode="decimal" autocomplete="off" placeholder="auto" />
|
||||
</label>
|
||||
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
|
||||
</div>
|
||||
@@ -429,7 +505,7 @@
|
||||
<template id="chipLegendRowTemplate">
|
||||
<div class="chipLegendRow">
|
||||
<input type="color" class="chipColorInput" aria-label="Chip colour" />
|
||||
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" aria-label="Chip value" />
|
||||
<input type="text" class="chipValueInput" inputmode="decimal" autocomplete="off" placeholder="value" aria-label="Chip value" />
|
||||
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -442,7 +518,7 @@
|
||||
<label class="chipCountRow">
|
||||
<span class="chipSwatch"></span>
|
||||
<span class="chipCountValue"></span>
|
||||
<input type="number" class="chipCountInput" min="0" step="1" value="0" />
|
||||
<input type="text" class="chipCountInput" inputmode="numeric" autocomplete="off" value="0" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -480,7 +556,7 @@
|
||||
<div id="addChipsSheet" role="dialog" aria-labelledby="addChipsTitle">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="addChipsHeader">
|
||||
<h2 id="addChipsTitle"></h2>
|
||||
<h2 id="addChipsTitle" tabindex="-1" data-modal-focus></h2>
|
||||
<span id="addChipsSubtitle"></span>
|
||||
</div>
|
||||
<div id="addChipsQuick"></div>
|
||||
@@ -497,9 +573,13 @@
|
||||
|
||||
<div id="rulingsOverlay" hidden>
|
||||
<div id="rulingsSheet" role="dialog" aria-labelledby="rulingsTitle">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
@* The grip belongs inside the header, not above it, because the header is stuck
|
||||
to the top of the scrollport — anything above a sticky thing is something the
|
||||
list scrolls out through. The seat menu's header is built the same way, and
|
||||
for the same reason. *@
|
||||
<div class="rulingsHeader">
|
||||
<h2 id="rulingsTitle">Common rulings</h2>
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<h2 id="rulingsTitle" tabindex="-1" data-modal-focus>Common rulings</h2>
|
||||
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||
</div>
|
||||
|
||||
@@ -509,162 +589,190 @@
|
||||
not during it.
|
||||
</p>
|
||||
|
||||
<nav id="rulingsIndex" class="rulingsIndex" aria-label="Jump to a ruling"></nav>
|
||||
@* Twelve rulings, shut.
|
||||
|
||||
<section class="ruling" data-short="Misdeal">
|
||||
<h3>Redeal it — misdeal</h3>
|
||||
<ul>
|
||||
<li>The first card goes to the wrong seat.</li>
|
||||
<li>A seat entitled to a hand gets dealt out, or a seat that isn't entitled gets dealt in.</li>
|
||||
<li>Two or more boxed cards turn up on the initial deal.</li>
|
||||
<li>Either of the first two cards is exposed by dealer error.</li>
|
||||
</ul>
|
||||
</section>
|
||||
They used to be twelve headings with all their prose under them — 2,480px of
|
||||
it on a phone, of which 1,754 was below the fold — and a row of twelve chips
|
||||
across the top to jump between them. The chips were an index of the twelve
|
||||
headings printed directly above the same twelve headings, which is the same
|
||||
list twice: 172px of navigation before the first word, and the dealer who
|
||||
used it landed in the middle of a scroll with eleven rulings they didn't ask
|
||||
for still wrapped around them.
|
||||
|
||||
<section class="ruling" data-short="Board card early">
|
||||
<h3>A board card turned too early</h3>
|
||||
<p>
|
||||
If the fourth board card gets turned before the betting round is finished,
|
||||
that card is out of play for the round. Finish the betting, then burn and
|
||||
turn what would have been the fifth card in the fourth card's place.
|
||||
</p>
|
||||
</section>
|
||||
The headings are the index now. NN/g's rule for this shape — a list of
|
||||
independent topics somebody opens one of — is an accordion, shut by default,
|
||||
and half of these headings answer the question without being opened at all:
|
||||
"A short all-in doesn't reopen the betting" is the ruling, not the topic.
|
||||
|
||||
<section class="ruling" data-short="Out of turn">
|
||||
<h3>Acting out of turn</h3>
|
||||
<p>
|
||||
A one-off is usually just backed up if no one has acted behind them.
|
||||
Doing it repeatedly is what earns a penalty.
|
||||
</p>
|
||||
</section>
|
||||
Not exclusive (no `name` attribute): closing one because another opened moves
|
||||
everything under the dealer's finger, which is the disorientation the
|
||||
accordion is here to avoid. *@
|
||||
|
||||
<section class="ruling" data-short="Clock">
|
||||
<h3>Calling the clock on a player</h3>
|
||||
<p>
|
||||
Any player may ask for a clock once a decision is taking unreasonably
|
||||
long; it is the dealer, not the table, who grants it. The player then
|
||||
gets a set time and a countdown out loud — thirty seconds and ten is the
|
||||
usual form.
|
||||
</p>
|
||||
<p>
|
||||
If the count reaches zero with no action, the hand is <em>dead</em> if
|
||||
they were facing a bet, and <em>checked</em> if they were not: nobody is
|
||||
folded for slowness when checking was free. The action bar runs the
|
||||
count once you tap <em>Call the clock</em>, and offers the ruling as one
|
||||
button when it ends.
|
||||
</p>
|
||||
</section>
|
||||
@* Six and six, down one column and then the next. Two boxes rather than one
|
||||
list so that landscape can lay them side by side without the columns
|
||||
sharing rows: opening a ruling in one of them moves what is under it in
|
||||
that column and nothing else. Everywhere but landscape the wrappers are
|
||||
`display: contents` and the twelve are one column, exactly as before. *@
|
||||
<div class="rulingsList">
|
||||
<div class="rulingsColumn">
|
||||
<details class="ruling">
|
||||
<summary><h3>Redeal it — misdeal</h3></summary>
|
||||
<ul>
|
||||
<li>The first card goes to the wrong seat.</li>
|
||||
<li>A seat entitled to a hand gets dealt out, or a seat that isn't entitled gets dealt in.</li>
|
||||
<li>Two or more boxed cards turn up on the initial deal.</li>
|
||||
<li>Either of the first two cards is exposed by dealer error.</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<section class="ruling" data-short="Straddle">
|
||||
<h3>A straddle is a blind raise</h3>
|
||||
<p>
|
||||
The player left of the big blind may put out twice it before the cards,
|
||||
without looking. It is a blind bet, not a raise made in turn, so it does
|
||||
three things at once: everyone else acts first, the minimum raise becomes
|
||||
twice the straddle, and the straddler still has the option when the
|
||||
action comes back — they may raise even if nobody raised into them.
|
||||
</p>
|
||||
<p>
|
||||
Re-straddles carry on to the left, each doubling again, and stop before
|
||||
the small blind. Tap <em>Straddle</em> on the deal prompt and the tool
|
||||
posts it, puts the right player on the clock and prices the raise;
|
||||
the seat wears an <em>STR</em> badge for the hand.
|
||||
</p>
|
||||
</section>
|
||||
<details class="ruling">
|
||||
<summary><h3>A board card turned too early</h3></summary>
|
||||
<p>
|
||||
If the fourth board card gets turned before the betting round is finished,
|
||||
that card is out of play for the round. Finish the betting, then burn and
|
||||
turn what would have been the fifth card in the fourth card's place.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<section class="ruling" data-short="Short all-in">
|
||||
<h3>A short all-in doesn't reopen the betting</h3>
|
||||
<p>
|
||||
Somebody shoving in less than a full raise — say $3 more when the last
|
||||
raise was $8 — does not give the players behind them another go. Anyone
|
||||
who has already acted owes the difference or folds; they don't get to
|
||||
raise again because a short stack happened to land between them.
|
||||
</p>
|
||||
<p>
|
||||
A player who hadn't acted yet keeps full rights and may still raise, and
|
||||
the minimum raise stays measured off the last <em>full</em> raise, not
|
||||
off the short one. The action bar follows this for you.
|
||||
</p>
|
||||
</section>
|
||||
<details class="ruling">
|
||||
<summary><h3>Acting out of turn</h3></summary>
|
||||
<p>
|
||||
A one-off is usually just backed up if no one has acted behind them.
|
||||
Doing it repeatedly is what earns a penalty.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<section class="ruling" data-short="Uncalled bet">
|
||||
<h3>Chips nobody called come back</h3>
|
||||
<p>
|
||||
Bet more than anyone was able to call and the excess is returned to you
|
||||
before the showdown — it was never contested, so it isn't part of the
|
||||
pot. The pot breakdown shows it as "uncalled" rather than pretending it's
|
||||
a side pot you could win.
|
||||
</p>
|
||||
</section>
|
||||
<details class="ruling">
|
||||
<summary><h3>Calling the clock on a player</h3></summary>
|
||||
<p>
|
||||
Any player may ask for a clock once a decision is taking unreasonably
|
||||
long; it is the dealer, not the table, who grants it. The player then
|
||||
gets a set time and a countdown out loud — thirty seconds and ten is the
|
||||
usual form.
|
||||
</p>
|
||||
<p>
|
||||
If the count reaches zero with no action, the hand is <em>dead</em> if
|
||||
they were facing a bet, and <em>checked</em> if they were not: nobody is
|
||||
folded for slowness when checking was free. The action bar runs the
|
||||
count once you tap <em>Call the clock</em>, and offers the ruling as one
|
||||
button when it ends.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<section class="ruling" data-short="Oversized chip">
|
||||
<h3>One big chip, nothing said</h3>
|
||||
<p>
|
||||
The most common argument at a home table. A player facing a bet who
|
||||
pushes out a single oversized chip without saying anything has
|
||||
<em>called</em> — not raised, however big the chip is. To raise with one
|
||||
chip you have to say "raise" before it lands; say it without an amount
|
||||
and the raise is the most that chip allows.
|
||||
</p>
|
||||
<p>
|
||||
Facing no bet, the same silent chip is a bet of the whole chip. And a
|
||||
clear verbal declaration in turn is binding either way — if words and
|
||||
chips disagree, whichever came first is the action.
|
||||
</p>
|
||||
</section>
|
||||
<details class="ruling">
|
||||
<summary><h3>A straddle is a blind raise</h3></summary>
|
||||
<p>
|
||||
The player left of the big blind may put out twice it before the cards,
|
||||
without looking. It is a blind bet, not a raise made in turn, so it does
|
||||
three things at once: everyone else acts first, the minimum raise becomes
|
||||
twice the straddle, and the straddler still has the option when the
|
||||
action comes back — they may raise even if nobody raised into them.
|
||||
</p>
|
||||
<p>
|
||||
Re-straddles carry on to the left, each doubling again, and stop before
|
||||
the small blind. Tap <em>Straddle</em> on the deal prompt and the tool
|
||||
posts it, puts the right player on the clock and prices the raise;
|
||||
the seat wears an <em>STR</em> badge for the hand.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<section class="ruling" data-short="Minimum raise">
|
||||
<h3>A raise has to be a full raise</h3>
|
||||
<p>
|
||||
At least as much again as the last raise, or the big blind if there
|
||||
hasn't been one. Anything between a call and that minimum isn't a raise
|
||||
a player can make — it's a call, unless they're putting their last chips
|
||||
in. The raise pad says so if you type one, and won't stop you: your
|
||||
ruling beats the arithmetic.
|
||||
</p>
|
||||
</section>
|
||||
<details class="ruling">
|
||||
<summary><h3>A short all-in doesn't reopen the betting</h3></summary>
|
||||
<p>
|
||||
Somebody shoving in less than a full raise — say $3 more when the last
|
||||
raise was $8 — does not give the players behind them another go. Anyone
|
||||
who has already acted owes the difference or folds; they don't get to
|
||||
raise again because a short stack happened to land between them.
|
||||
</p>
|
||||
<p>
|
||||
A player who hadn't acted yet keeps full rights and may still raise, and
|
||||
the minimum raise stays measured off the last <em>full</em> raise, not
|
||||
off the short one. The action bar follows this for you.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<section class="ruling" data-short="Who shows first">
|
||||
<h3>Who turns their hand over first</h3>
|
||||
<p>
|
||||
If there was a bet on the river, the last player to bet or raise shows
|
||||
first. If the river was checked all the way round, it's the first live
|
||||
player left of the button. Nobody has to be asked twice — a player may
|
||||
always turn their hand over early if they want to.
|
||||
</p>
|
||||
<p>
|
||||
When everyone is all-in before the river there was no final round to
|
||||
lead, so it falls to whoever last put money in, on whichever street that
|
||||
was. The action bar names the seat and the reason at every showdown, so
|
||||
the call is already made before anyone asks.
|
||||
</p>
|
||||
</section>
|
||||
<div class="rulingsColumn">
|
||||
<details class="ruling">
|
||||
<summary><h3>Chips nobody called come back</h3></summary>
|
||||
<p>
|
||||
Bet more than anyone was able to call and the excess is returned to you
|
||||
before the showdown — it was never contested, so it isn't part of the
|
||||
pot. The pot breakdown shows it as "uncalled" rather than pretending it's
|
||||
a side pot you could win.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<section class="ruling" data-short="Odd chip">
|
||||
<h3>The odd chip in a split pot</h3>
|
||||
<p>
|
||||
When a chopped pot won't divide evenly, the odd chip goes to the first
|
||||
player left of the button, not to whoever the dealer happened to tick
|
||||
first. Pot payouts here order the winners that way, so the leftover lands
|
||||
on the right seat every time.
|
||||
</p>
|
||||
</section>
|
||||
<details class="ruling">
|
||||
<summary><h3>One big chip, nothing said</h3></summary>
|
||||
<p>
|
||||
The most common argument at a home table. A player facing a bet who
|
||||
pushes out a single oversized chip without saying anything has
|
||||
<em>called</em> — not raised, however big the chip is. To raise with one
|
||||
chip you have to say "raise" before it lands; say it without an amount
|
||||
and the raise is the most that chip allows.
|
||||
</p>
|
||||
<p>
|
||||
Facing no bet, the same silent chip is a bet of the whole chip. And a
|
||||
clear verbal declaration in turn is binding either way — if words and
|
||||
chips disagree, whichever came first is the action.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<section class="ruling" data-short="String bet">
|
||||
<h3>Betting in one motion — the string bet</h3>
|
||||
<p>
|
||||
A bet is whatever goes in on the first trip to the stack, unless the
|
||||
amount was said out loud first. Reaching back for more chips without
|
||||
having said "raise" is a string bet, and only the first motion stands.
|
||||
Saying the number is always enough on its own: "raise to sixty" binds
|
||||
whatever order the chips arrive in.
|
||||
</p>
|
||||
<p>
|
||||
It's the rule home games invoke most and enforce least, and both are
|
||||
fine — the point is that a player can't watch the table react and then
|
||||
decide how much to bet. Say it, or push it all at once.
|
||||
</p>
|
||||
</section>
|
||||
<details class="ruling">
|
||||
<summary><h3>A raise has to be a full raise</h3></summary>
|
||||
<p>
|
||||
At least as much again as the last raise, or the big blind if there
|
||||
hasn't been one. Anything between a call and that minimum isn't a raise
|
||||
a player can make — it's a call, unless they're putting their last chips
|
||||
in. The raise pad says so if you type one, and won't stop you: your
|
||||
ruling beats the arithmetic.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details class="ruling">
|
||||
<summary><h3>Who turns their hand over first</h3></summary>
|
||||
<p>
|
||||
If there was a bet on the river, the last player to bet or raise shows
|
||||
first. If the river was checked all the way round, it's the first live
|
||||
player left of the button. Nobody has to be asked twice — a player may
|
||||
always turn their hand over early if they want to.
|
||||
</p>
|
||||
<p>
|
||||
When everyone is all-in before the river there was no final round to
|
||||
lead, so it falls to whoever last put money in, on whichever street that
|
||||
was. The action bar names the seat and the reason at every showdown, so
|
||||
the call is already made before anyone asks.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details class="ruling">
|
||||
<summary><h3>The odd chip in a split pot</h3></summary>
|
||||
<p>
|
||||
When a chopped pot won't divide evenly, the odd chip goes to the first
|
||||
player left of the button, not to whoever the dealer happened to tick
|
||||
first. Pot payouts here order the winners that way, so the leftover lands
|
||||
on the right seat every time.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details class="ruling">
|
||||
<summary><h3>Betting in one motion — the string bet</h3></summary>
|
||||
<p>
|
||||
A bet is whatever goes in on the first trip to the stack, unless the
|
||||
amount was said out loud first. Reaching back for more chips without
|
||||
having said "raise" is a string bet, and only the first motion stands.
|
||||
Saying the number is always enough on its own: "raise to sixty" binds
|
||||
whatever order the chips arrive in.
|
||||
</p>
|
||||
<p>
|
||||
It's the rule home games invoke most and enforce least, and both are
|
||||
fine — the point is that a player can't watch the table react and then
|
||||
decide how much to bet. Say it, or push it all at once.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="rulingsSource">
|
||||
Follows the Tournament Directors Association rules, which most rooms and
|
||||
@@ -675,26 +783,65 @@
|
||||
|
||||
<div id="seatMenuOverlay" hidden>
|
||||
<div id="seatMenuSheet" role="dialog" aria-labelledby="seatMenuTitle">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
@* The grip was the only thing at the top of this sheet, and NN/g is explicit
|
||||
that a grab handle is not a close affordance — a sheet wants a real one. It
|
||||
earns its place twice over here: this sheet is the one that can outgrow the
|
||||
screen, and with the header stuck to the top the way out never scrolls away.
|
||||
|
||||
Third job, the quiet one: Escape finds this close button rather than the one
|
||||
at the foot, so Escape closes the sheet while Back steps back inside it.
|
||||
|
||||
It used to carry a fourth. Modal focused a dialog's first control and the
|
||||
first control here was Fold, so putting a close button above the list was
|
||||
also what moved the landing spot off the destructive choice. That was the
|
||||
right half of the answer — Roselli is clear that a close button is a landing
|
||||
spot for a sentence, not for a sheet long enough to scroll, where a stray
|
||||
Space dismisses the thing the dealer just opened. The heading below names
|
||||
the spot outright now, and it says which seat. *@
|
||||
<div class="seatMenuHeader">
|
||||
<h2 id="seatMenuTitle"></h2>
|
||||
<span id="seatMenuSubtitle"></span>
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="seatMenuHeading">
|
||||
<h2 id="seatMenuTitle" tabindex="-1" data-modal-focus></h2>
|
||||
<span id="seatMenuSubtitle"></span>
|
||||
</div>
|
||||
<button id="seatMenuCloseBtn" data-modal-close class="seatMenuClose" type="button" aria-label="Close this menu">×</button>
|
||||
</div>
|
||||
<div id="seatMenuActions"></div>
|
||||
<button id="seatMenuCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cardPickerOverlay" hidden>
|
||||
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle">
|
||||
@* `data-why-note` is this sheet claiming the job of saying why a card can't be
|
||||
picked. The toast that answers everywhere else is fixed to the bottom of the
|
||||
window, and this sheet stands on top of it. *@
|
||||
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle" data-why-note="cardPickerNote">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="cardPickerHeader">
|
||||
<h2 id="cardPickerTitle"></h2>
|
||||
@* Not the first control any more. Modal used to land on whatever came
|
||||
first in here, and what came first in here was Clear: the sheet a
|
||||
dealer opens to write a card down opened on the button that rubs one
|
||||
out. The heading says which seat and which card, which is the thing
|
||||
worth hearing before any of the fifty-two. *@
|
||||
<h2 id="cardPickerTitle" tabindex="-1" data-modal-focus></h2>
|
||||
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
|
||||
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||
</div>
|
||||
<div id="cardPickerSlots"></div>
|
||||
<div id="cardPickerGrid"></div>
|
||||
@* Empty until a dealt card is pressed, and holding its line the whole time so
|
||||
that filling it doesn't shove fifty-two cards down the screen under a thumb
|
||||
that is already reaching for one. *@
|
||||
<p id="cardPickerNote" class="pickerNote" role="status"></p>
|
||||
@* 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. *@
|
||||
<div id="cardPickerSuits" role="radiogroup" aria-label="Suit"></div>
|
||||
@* 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. *@
|
||||
<span id="cardPickerGridLabel" class="visuallyHidden"></span>
|
||||
<div id="cardPickerGrid" role="group" aria-labelledby="cardPickerGridLabel"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -716,8 +863,14 @@
|
||||
<div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1">
|
||||
<h2 id="confirmTitle"></h2>
|
||||
<p id="confirmMessage"></p>
|
||||
@* The one sheet that lands on a control rather than its heading, and the one
|
||||
where the APG asks for it by name: this is a question with two answers and
|
||||
one of them cannot be taken back, so the dealer starts on the answer that
|
||||
leaves them no worse off. It is short enough to be read at a glance, which is
|
||||
the case where landing on a control is the right thing. Said out loud here
|
||||
rather than left to depend on which button happens to be written first. *@
|
||||
<div class="confirmActions">
|
||||
<button id="confirmCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
<button id="confirmCancelBtn" data-modal-close data-modal-focus class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -726,8 +879,13 @@
|
||||
<div id="potWizardOverlay" hidden>
|
||||
<div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
|
||||
<div class="potWizardHeader">
|
||||
<h2 id="potWizardTitle">Resolve the Pot</h2>
|
||||
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
|
||||
<h2 id="potWizardTitle" tabindex="-1" data-modal-focus>Resolve the Pot</h2>
|
||||
@* Named, and named for what leaving does rather than "Close". The glyph is
|
||||
the whole of this control, so unnamed it announced as "button" — on the
|
||||
one dialog in the tool that divides money, where the difference between
|
||||
backing out and confirming is somebody's stack. *@
|
||||
<button id="closePotWizardBtn" type="button" data-modal-close class="holdemBtn holdemBtnGhost"
|
||||
aria-label="Close without paying the pot">✕</button>
|
||||
</div>
|
||||
<div id="potWizardBody"></div>
|
||||
@* The buttons live outside the scrolling body on purpose. This is the one
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,15 @@
|
||||
/* Quick Split is phone-first: sized for one thumb at the table, then allowed to
|
||||
breathe on a laptop. Palette matches the dealer table so the two read as one tool. */
|
||||
|
||||
/* And matches it in the half a stylesheet does not paint, for the same reason: this
|
||||
page is #0a0a0a and had never told the browser so, which leaves the scrollbars, the
|
||||
caret, the selection and the autofill wash drawn for a light page. It has no
|
||||
checkboxes to go white, but a dealer who opens it from the felt should not find a
|
||||
light scrollbar down the side of the darker of the two pages. */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background: radial-gradient(ellipse at 50% -10%, #1c1c1c 0%, #101010 55%, #0a0a0a 100%);
|
||||
min-height: 100vh;
|
||||
@@ -517,20 +526,30 @@ button,
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* As wide as the three things in it, and no wider.
|
||||
This sits in the same wrapping flex row as the chips, where it has never once fitted
|
||||
on the first line of a phone. `flex: 1 1 auto` then handed it the whole of the second
|
||||
line: measured on an iPhone, a 358px bar with 252px of nothing in the middle and the
|
||||
− and + pushed 304px apart — the two ends of a control a thumb is meant to work with
|
||||
one hand. 136px now, its two ends 121px apart. */
|
||||
.waysStepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 150px;
|
||||
flex: 0 0 auto;
|
||||
background: #191919;
|
||||
border: 1px solid #333;
|
||||
border-radius: 11px;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
/* 44 both ways, not 40x44. It had been 40px wide since the steppers were written, and
|
||||
the sweep never said so because the only two steppers on the site are on this screen
|
||||
and this screen was only ever audited in its all-in mode, where neither is on display
|
||||
until a side pot has more than two contenders. The first sweep that opened the even
|
||||
mode reported `hittable 39x43` on both ends, on every touch viewport. */
|
||||
.stepBtn {
|
||||
width: 40px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
@@ -542,17 +561,26 @@ button,
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* The floor and the ceiling look like the floor and the ceiling. */
|
||||
.stepBtn:disabled {
|
||||
opacity: 0.32;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.stepBtn:hover {
|
||||
.stepBtn:not(:disabled):hover {
|
||||
background: #333;
|
||||
}
|
||||
}
|
||||
|
||||
/* The count itself, now that no chip beside it is also holding one — so it is painted
|
||||
the way a selected chip is painted, because that is what it is standing in for. */
|
||||
.waysReadout {
|
||||
flex: 1;
|
||||
min-width: 2.5ch;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 0.8rem;
|
||||
color: #f0d9a8;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,14 @@ const ActionBar = {
|
||||
state.actingSeatIndex !== null && state.actingSeatIndex !== undefined
|
||||
? state.actingSeatIndex
|
||||
: null;
|
||||
// watchHeight first, because render() may reach for publishHeight; then publish
|
||||
// once there is a bar to measure. The ResizeObserver does come round on its own
|
||||
// when the markup's `hidden` comes off — but it arrives a frame later at best,
|
||||
// and measured on a cold load with nine seats to lay out it was 875ms behind.
|
||||
// The felt is drawn inside that gap.
|
||||
this.watchHeight();
|
||||
this.render();
|
||||
this.publishHeight();
|
||||
},
|
||||
|
||||
// The bar is fixed to the bottom of the screen, so everything else has to know how
|
||||
@@ -31,18 +37,40 @@ const ActionBar = {
|
||||
if (!host || this.heightWatcher) return;
|
||||
|
||||
const publish = () => {
|
||||
const visible = !host.hidden;
|
||||
const rail = this.isRail(host);
|
||||
// A bar nobody has drawn yet is not a bar of zero height. It is a bar whose
|
||||
// height nobody knows, and saying zero is answering the question wrongly
|
||||
// rather than declining it: the felt reads this to decide how tall to be,
|
||||
// and zero tells it the whole window is free.
|
||||
//
|
||||
// It is the state this bar starts in — `<div id="actionBar" hidden>` in the
|
||||
// markup, taken out by the first render() — and watchHeight() used to
|
||||
// publish from inside that window, because mount() called it before render.
|
||||
// Measured at 360x640 nine-handed: the table came up 336x504 against a
|
||||
// published 525px of room, then collapsed to 336x374 sixty milliseconds
|
||||
// later when the real 151px bar was finally measured. 1280x900 lost 13px
|
||||
// the same way. A table that is drawn, read, and then redrawn at a
|
||||
// different size is the layout shift this measurement exists to prevent.
|
||||
//
|
||||
// A rail is different, and still publishes: it is a real bar that genuinely
|
||||
// takes none of the bottom, because it is off to the side.
|
||||
if (host.hidden && !rail) return;
|
||||
// On a phone held sideways the bar is a rail down the right-hand side, so it
|
||||
// takes no height at all — and `--actionBarHeight` is what four other rules
|
||||
// and the felt measurement all mean by "how much of the bottom is spoken
|
||||
// for". Publishing the rail's height there would reserve the whole window.
|
||||
const height = visible && !rail ? host.getBoundingClientRect().height : 0;
|
||||
const height = rail ? 0 : host.getBoundingClientRect().height;
|
||||
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
|
||||
// What is under the felt is the bar in one layout and the two links in the
|
||||
// other. Either way it is the same question — how much of the bottom is
|
||||
// already spoken for — so it is the same argument.
|
||||
this.publishFeltRoom(rail ? this.roomBelowFelt() : height);
|
||||
//
|
||||
// Except that the felt is asked a slightly different question from everything
|
||||
// else that reads --actionBarHeight. The toast and the scroll padding want to
|
||||
// know what is covering the bottom of the window *right now*; the table wants
|
||||
// to know how big to be, and being a different size for the few seconds a pad
|
||||
// is open over it is not an answer, it is a jolt. See restingBarHeight.
|
||||
this.publishFeltRoom(rail ? this.roomBelowFelt() : this.restingBarHeight(height));
|
||||
};
|
||||
|
||||
// Kept so anything that moves the table without touching the bar can ask for
|
||||
@@ -58,6 +86,42 @@ const ActionBar = {
|
||||
publish();
|
||||
},
|
||||
|
||||
// How tall the bar is when it is only a bar.
|
||||
//
|
||||
// The raise pad is 57px taller than the row it replaces, and the felt was sized
|
||||
// against it. Measured with five players nine-handed, pressing Raise:
|
||||
//
|
||||
// 1280x900 felt 738x532 -> 669x481, pucks 120 -> 109, worst seat moved 73px
|
||||
// 360x640 felt 283x329 -> 283x279, pucks 44 -> 48, worst seat moved 50px
|
||||
// 390x844 felt 309x463 -> 309x451, worst seat moved 11px
|
||||
//
|
||||
// The whole table jumped, re-ran the ten-candidate width search, redrew at a
|
||||
// different size — and then undid all of it when the dealer pressed Raise to. Every
|
||||
// raise, all night, at the one moment they are reading the felt to decide what to
|
||||
// type. A phone held sideways already did the right thing here and scored 0px: the
|
||||
// rail is a column down the side, so its height never was the table's business.
|
||||
//
|
||||
// A pad over the bar is a transient surface, and the thing a transient surface must
|
||||
// not do is take the context away from underneath itself. So the felt is sized
|
||||
// against the bar the thumb is coming back to, and the pad grows up over the bottom
|
||||
// of the felt for as long as it is open.
|
||||
//
|
||||
// Only the felt's question is answered this way. --actionBarHeight stays what it has
|
||||
// always been — what is actually covering the bottom of the window — because that is
|
||||
// what the toast has to clear and what the page has to scroll past.
|
||||
//
|
||||
// The remembered height goes stale if the window changes size while the pad is open,
|
||||
// which on a phone means turning it over mid-raise. The next publish after the pad
|
||||
// closes is a measurement, so it puts itself right; a stale felt for the seconds in
|
||||
// between is the same trade being made everywhere else here.
|
||||
restingBarHeight(measured) {
|
||||
if (!this.raiseOpen) {
|
||||
this.barHeightAtRest = measured;
|
||||
return measured;
|
||||
}
|
||||
return this.barHeightAtRest === undefined ? measured : this.barHeightAtRest;
|
||||
},
|
||||
|
||||
// Is the bar a rail down the side rather than a bar across the bottom?
|
||||
//
|
||||
// Asked of the stylesheet rather than answered again here. The layout is chosen by
|
||||
@@ -202,9 +266,31 @@ const ActionBar = {
|
||||
}
|
||||
|
||||
this.syncTankTimer();
|
||||
// After the tank timer, because whether the stopwatch and the clock button are
|
||||
// on the acting line is what decides how much room the name has left.
|
||||
this.fitActingLine();
|
||||
this.fitUndoLabel();
|
||||
this.showRailEnd(host);
|
||||
this.announce();
|
||||
},
|
||||
|
||||
// A phone held sideways puts this bar down the right-hand edge as a rail, anchored
|
||||
// low: the buttons sit at the bottom, where a thumb rests rather than where a hand
|
||||
// has to reach up. The rail scrolls when what it holds outgrows it, which the raise
|
||||
// pad does on a 360px-tall screen the moment the dealer has their text turned up —
|
||||
// measured at 640x360 with 20px of root text, 407px of pad in a 358px rail.
|
||||
//
|
||||
// And a scroll container starts at its top. So the overflow came off the bottom,
|
||||
// which is exactly where the two buttons that end a raise are: Cancel and Raise to
|
||||
// were half under the edge of the screen with the hand number and the pot — the two
|
||||
// things a dealer had already read — holding the room above them. Anchored low, the
|
||||
// rail should open at the end for the same reason it is anchored low at all.
|
||||
showRailEnd(host) {
|
||||
if (!this.isRail(host)) return;
|
||||
if (host.scrollHeight <= host.clientHeight) return;
|
||||
host.scrollTop = host.scrollHeight;
|
||||
},
|
||||
|
||||
// The empty table. One sentence and one button, in the place every other prompt in
|
||||
// this tool appears, so a dealer who has never opened it before is looking at the
|
||||
// next step rather than at ten chairs labelled EMPTY.
|
||||
@@ -336,20 +422,66 @@ const ActionBar = {
|
||||
row.appendChild(runout);
|
||||
}
|
||||
|
||||
// The second line of this block, held as a line rather than left to whatever the
|
||||
// wrapping happens to produce.
|
||||
//
|
||||
// The row wraps, and flex decides its line breaks before it decides what to
|
||||
// shrink — so a name too long for the space left beside the clock and the undo
|
||||
// pill did not give way, it pushed them onto a third line. That is 22px, and the
|
||||
// bar is fixed to the bottom, so it came off the felt: measured at 360x640
|
||||
// mid-street, "Dee · $600 behind" left the table 283x329 and "Christopher ·
|
||||
// $1,200 behind" made it 283x310, with every seat moving 24px between one player
|
||||
// and the next. Whose turn it was decided how big the table was.
|
||||
//
|
||||
// On its own line the three of them are a row that can shrink instead of a row
|
||||
// that can wrap, and the block is two lines whoever is acting.
|
||||
const acting = document.createElement("div");
|
||||
acting.className = "actionActing";
|
||||
|
||||
const index = this.state.actingSeatIndex;
|
||||
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
|
||||
const player = this.state.seats[index];
|
||||
// Two spans rather than one sentence, because only one half of it is
|
||||
// allowed to give way. This row wraps, and a name long enough to push the
|
||||
// clock and the undo pill onto a third line takes 22px off the felt — so
|
||||
// whose turn it is decided how big the table was. Measured at 360x640:
|
||||
// "Dee · $600 behind" left the bar 151px tall and "Christopher · $1,200
|
||||
// behind" made it 173, and the felt resized between one player and the next.
|
||||
//
|
||||
// So the name shrinks and the number does not. A name cut to "Christophe…"
|
||||
// is still the player the dealer is looking at; a stack cut to "$1,2…" is a
|
||||
// figure they would have to go and find somewhere else.
|
||||
const who = document.createElement("span");
|
||||
who.className = "actionWho";
|
||||
who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`;
|
||||
row.appendChild(who);
|
||||
|
||||
const whoName = document.createElement("span");
|
||||
whoName.className = "actionWhoName";
|
||||
whoName.textContent = HoldemState.displayName(player, index);
|
||||
// The whole of it, for the one name on the narrowest phone that still has to
|
||||
// give up a few characters. Same arrangement the seat pucks have.
|
||||
whoName.title = whoName.textContent;
|
||||
who.appendChild(whoName);
|
||||
|
||||
const whoStack = document.createElement("span");
|
||||
whoStack.className = "actionWhoStack";
|
||||
// No leading space: this is a flex item and its own would be collapsed away
|
||||
// at the edge. .actionWho holds the two of them apart instead.
|
||||
whoStack.textContent = `· ${Money.format(Betting.stackOf(this.state, player))} behind`;
|
||||
who.appendChild(whoStack);
|
||||
|
||||
acting.appendChild(who);
|
||||
|
||||
// Built every render and shown by the tick, the same way the clock button
|
||||
// beside it is: a stopwatch reading "0:02" is 50px of the narrowest line in
|
||||
// the tool spent on a number nobody acts on. Starts hidden so a decision
|
||||
// that has only just landed on somebody never flashes one up for a frame.
|
||||
const tank = document.createElement("span");
|
||||
tank.className = "actionTank";
|
||||
tank.id = "actionTank";
|
||||
tank.title = "How long this player has been thinking";
|
||||
tank.textContent = "0:00";
|
||||
row.appendChild(tank);
|
||||
tank.hidden = true;
|
||||
acting.appendChild(tank);
|
||||
|
||||
// Rendered every time and shown by the tick once the wait is long enough
|
||||
// to be worth it, so a clock never costs a re-render at the moment the
|
||||
@@ -375,7 +507,7 @@ const ActionBar = {
|
||||
clock.title = `${this.CLOCK_THINK_S} seconds, then a ${this.CLOCK_COUNTDOWN_S}-second countdown`;
|
||||
clock.addEventListener("click", () => this.callClock());
|
||||
}
|
||||
row.appendChild(clock);
|
||||
acting.appendChild(clock);
|
||||
}
|
||||
|
||||
// A standing control rather than a toast per action: a dealer taps this bar
|
||||
@@ -387,16 +519,50 @@ const ActionBar = {
|
||||
undo.type = "button";
|
||||
undo.className = "actionUndo";
|
||||
undo.title = `Undo ${undoLabel}`;
|
||||
// And said out loud, not only hung off a hover. The visible label is the
|
||||
// short form and every short form is a substring of this one, which is what
|
||||
// Label in Name asks: a speech user saying what they can see still hits it,
|
||||
// and a screen reader gets the qualifier the pill had no room for. Without
|
||||
// this the name was the visible text — on a phone, "↶ hand killed U".
|
||||
undo.setAttribute("aria-label", `Undo ${undoLabel}`);
|
||||
// The label goes in its own element rather than straight onto the button.
|
||||
// The pill is a fixed width — it names the last action, so its text changes
|
||||
// on every tap, and a control that resizes under the dealer's thumb reads
|
||||
// as the interface twitching. Fixed width means long labels have to
|
||||
// ellipsize, and only a real element can: as a bare text node the label
|
||||
// as the interface twitching. Fixed width means long labels have to give
|
||||
// something up, and only a real element can: as a bare text node the label
|
||||
// truncated *through* the keycap below it, which then sat squashed against
|
||||
// the cut-off text looking like a rendering fault.
|
||||
//
|
||||
// Three elements, because what it gives up matters. Measured on a phone,
|
||||
// the label has 78px and every label with an actor on it wants more:
|
||||
// "↶ Mike from work's fold" 128, "↶ Christopher's fold" 109. Widening it is
|
||||
// not available — the room it would take is the room the acting name beside
|
||||
// it needs, and that is what fitActingLine is already spending. So the actor
|
||||
// comes off, whole, and the act stays; see fitUndoLabel, and undoParts for
|
||||
// what that requires of an act.
|
||||
const label = document.createElement("span");
|
||||
label.className = "actionUndoLabel";
|
||||
label.textContent = `↶ ${undoLabel}`;
|
||||
|
||||
const glyph = document.createElement("span");
|
||||
glyph.className = "actionUndoGlyph";
|
||||
glyph.textContent = "↶";
|
||||
// Decoration, and hidden so it stays out of the name above — "left arrow
|
||||
// over hand killed" is not what this control is called.
|
||||
glyph.setAttribute("aria-hidden", "true");
|
||||
label.appendChild(glyph);
|
||||
|
||||
const parts = this.undoStep();
|
||||
if (parts.lead) {
|
||||
const lead = document.createElement("span");
|
||||
lead.className = "actionUndoLead";
|
||||
lead.textContent = parts.lead;
|
||||
label.appendChild(lead);
|
||||
}
|
||||
|
||||
const act = document.createElement("span");
|
||||
act.className = "actionUndoAct";
|
||||
act.textContent = parts.act;
|
||||
label.appendChild(act);
|
||||
undo.appendChild(label);
|
||||
// U undoes, and nothing said so — this control was the only place a dealer
|
||||
// could have learned it, and it was the one action bar control without its
|
||||
@@ -408,11 +574,15 @@ const ActionBar = {
|
||||
// floated above the bar. Here the key belongs inline, after the label.
|
||||
key.className = "actionUndoKey";
|
||||
key.textContent = this.KEY_HINTS.actionUndo;
|
||||
key.setAttribute("aria-hidden", "true");
|
||||
undo.appendChild(key);
|
||||
undo.addEventListener("click", () => this.hooks.undoLast());
|
||||
row.appendChild(undo);
|
||||
acting.appendChild(undo);
|
||||
}
|
||||
|
||||
// Nothing to act on and nothing to take back — no second line at all.
|
||||
if (acting.childElementCount) row.appendChild(acting);
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
@@ -542,12 +712,59 @@ const ActionBar = {
|
||||
}
|
||||
},
|
||||
|
||||
// Whether this decision has been going on long enough to be worth saying anything
|
||||
// about — which is the one question behind both the stopwatch and the button beside
|
||||
// it, so they ask it once and answer it together.
|
||||
//
|
||||
// The stopwatch used to be there from the first second of every decision, and for
|
||||
// the first half-minute it reads "0:07" in grey: a number nobody acts on, holding
|
||||
// 50px of a line that on a 390px phone has 349 to spend between a name, a stack, a
|
||||
// clock button and the undo pill. It is not a readout the dealer is watching; it is
|
||||
// a prompt, and a prompt that is always there is not one. See fitActingLine for
|
||||
// what the room it gives back is spent on.
|
||||
tankWorthShowing() {
|
||||
if (this.clockRunning()) return true;
|
||||
if (this.tankStartedAt === null) return false;
|
||||
// Nothing to time from — the action was already here when the page loaded. The
|
||||
// dash and its explanation are the answer to "why is there no clock on this
|
||||
// one", so it has to be visible to be asked.
|
||||
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) return true;
|
||||
return this.tankSeconds() >= this.NUDGE_AFTER_S;
|
||||
},
|
||||
|
||||
tankSeconds() {
|
||||
return Math.floor((Date.now() - this.tankStartedAt) / 1000);
|
||||
},
|
||||
|
||||
// Updates only the timer's own text, so the seconds ticking over never rebuilds the
|
||||
// buttons under the dealer's thumb.
|
||||
paintTank() {
|
||||
const el = document.getElementById("actionTank");
|
||||
if (!el || this.tankStartedAt === null) return;
|
||||
const nudge = document.getElementById("actionClockBtn");
|
||||
|
||||
const wasTank = el.hidden;
|
||||
const wasNudge = nudge ? nudge.hidden : null;
|
||||
|
||||
const worth = this.tankWorthShowing();
|
||||
el.hidden = !worth;
|
||||
// The button that starts a clock is offered once somebody has been thinking
|
||||
// long enough for the table to start asking. Before that it is a control nobody
|
||||
// needs, and this bar is docked over the seats. While a clock is already running
|
||||
// the button says "Stop the clock" and renderStatus decides whether it is there
|
||||
// at all — once the count has expired there is nothing left to stop.
|
||||
if (nudge && !this.clockRunning()) nudge.hidden = !worth;
|
||||
|
||||
if (!this.paintTankText(el)) return;
|
||||
|
||||
// Two things just left the line or joined it, so what is left has a different
|
||||
// amount of room than it did a second ago.
|
||||
if (el.hidden !== wasTank || (nudge && nudge.hidden !== wasNudge)) this.fitActingLine();
|
||||
},
|
||||
|
||||
// The readout itself. Returns false when it has asked for a full render instead, so
|
||||
// that nothing downstream measures a bar which is about to be rebuilt.
|
||||
paintTankText(el) {
|
||||
// A clock takes the readout over: the number the table is waiting on is no
|
||||
// longer how long this has taken but how long is left. At zero the bar has to
|
||||
// change shape — a ruling and the button that carries it out — so that one
|
||||
@@ -557,39 +774,95 @@ const ActionBar = {
|
||||
if (left === 0 && !this.clockExpiredShown) {
|
||||
this.clockExpiredShown = true;
|
||||
this.render();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
el.textContent = left <= this.CLOCK_COUNTDOWN_S ? `${left}` : this.formatTank(left);
|
||||
el.title = "Time left before the hand is decided";
|
||||
el.classList.toggle("tankNudge", left > this.CLOCK_COUNTDOWN_S);
|
||||
el.classList.toggle("tankSlow", left <= this.CLOCK_COUNTDOWN_S);
|
||||
return;
|
||||
}
|
||||
|
||||
// The button that starts a clock is offered once somebody has been thinking
|
||||
// long enough for the table to start asking — which is the same threshold the
|
||||
// stopwatch already marks. Before that it is a control nobody needs, and this
|
||||
// bar is docked over the seats.
|
||||
const nudge = document.getElementById("actionClockBtn");
|
||||
if (nudge && !this.clockRunning()) {
|
||||
const waited = Math.floor((Date.now() - this.tankStartedAt) / 1000);
|
||||
nudge.hidden = this.tankUnknownFor === this.tankSeatIndex
|
||||
? false
|
||||
: waited < this.NUDGE_AFTER_S;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
|
||||
el.textContent = "—";
|
||||
el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves.";
|
||||
el.classList.remove("tankNudge", "tankSlow");
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
|
||||
const seconds = this.tankSeconds();
|
||||
el.title = "How long this player has been thinking";
|
||||
el.textContent = this.formatTank(seconds);
|
||||
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
|
||||
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
|
||||
return true;
|
||||
},
|
||||
|
||||
// Which half of "Christopher · $1,200 behind" survives when the line cannot hold
|
||||
// both of them.
|
||||
//
|
||||
// The name was set up to be the half that gives way, on the rule the seat pucks
|
||||
// keep: a number read wrong is a worse failure than a name read short. That rule is
|
||||
// right and it stays. What was missing under it was a floor. Flex shrinks the only
|
||||
// item that *can* shrink until the line fits, and with the clock button on the line
|
||||
// there is no fitting to be had — measured at 360x640 and again at 390x844, both
|
||||
// "Christopher" and "Alexandra" were drawn 0px wide, so the bar read
|
||||
//
|
||||
// · $1,200 behind
|
||||
//
|
||||
// a dangling separator and a figure, at the exact moment the dealer is looking at
|
||||
// the bar to say whose turn it is out loud. The ellipsis never appeared: there were
|
||||
// no characters left to put one after.
|
||||
//
|
||||
// So the stack comes off the line before the name gives up a single character. It is
|
||||
// not lost — it is on that player's own seat puck, a couple of inches up the same
|
||||
// screen, which is where a dealer counting somebody down is looking anyway. The name
|
||||
// is the thing the bar alone is saying.
|
||||
// The last action in the two halves the pill draws it in. The history is main.js's,
|
||||
// because undo pops it — remembering the halves here would name the step that was
|
||||
// just taken back rather than the one now on top. A caller that has not been given
|
||||
// the halves gets the label whole, with nothing to drop, which is what it had before.
|
||||
undoStep() {
|
||||
const label = this.hooks.lastActionLabel();
|
||||
const parts = this.hooks.lastActionParts && this.hooks.lastActionParts();
|
||||
return parts || { lead: "", act: label };
|
||||
},
|
||||
|
||||
// Which half of "Christopher's fold" the pill can hold.
|
||||
//
|
||||
// The actor comes off whole rather than being cut down: "Christo…'s fold" is a worse
|
||||
// sentence than "fold", and the question a hand later is what came back rather than
|
||||
// whose it was — the dealer has the seat in front of them. The whole of it stays on
|
||||
// the button's title and aria-label, which is what the guidance asks of a control
|
||||
// that has had to shorten its own name.
|
||||
fitUndoLabel() {
|
||||
const label = document.querySelector("#actionBar .actionUndoLabel");
|
||||
const lead = label && label.querySelector(".actionUndoLead");
|
||||
const act = label && label.querySelector(".actionUndoAct");
|
||||
if (!lead || !act) return;
|
||||
// The act is what gets asked, not the label. The label cannot overflow — the act
|
||||
// inside it is the item with `overflow: hidden`, so it swallows the excess and
|
||||
// the row it sits in measures as though everything fitted. Asking the label was
|
||||
// the first way this was written and it read "↶ dealing the hand" with "the
|
||||
// hand" quietly cut off inside it, on the one label short enough that the actor
|
||||
// alone still fitted.
|
||||
//
|
||||
// Measured with the actor back on, so the answer is a property of the pill
|
||||
// rather than of whatever the last render left behind.
|
||||
lead.hidden = false;
|
||||
if (act.scrollWidth > act.clientWidth + 1) lead.hidden = true;
|
||||
},
|
||||
|
||||
fitActingLine() {
|
||||
const name = document.querySelector("#actionBar .actionWhoName");
|
||||
const stack = document.querySelector("#actionBar .actionWhoStack");
|
||||
if (!name || !stack) return;
|
||||
// Always measured with the stack back on, so the answer is a property of the
|
||||
// line rather than of whatever the last render happened to leave behind.
|
||||
stack.hidden = false;
|
||||
// What the name wants against what flex gave it. A cut name reads as an
|
||||
// overflowing one even when the overflow is the whole word.
|
||||
if (name.scrollWidth > name.clientWidth + 1) stack.hidden = true;
|
||||
},
|
||||
|
||||
// "0:07", "4:01", and past the hour "1:05:03" rather than "65:03" — a table left
|
||||
@@ -710,12 +983,48 @@ const ActionBar = {
|
||||
// would actually take back.
|
||||
if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
|
||||
|
||||
this.hooks.beginAction(label);
|
||||
const step = this.undoParts(label);
|
||||
this.hooks.beginAction(step.long, step);
|
||||
mutate();
|
||||
this.hooks.save(this.state);
|
||||
this.hooks.rerender();
|
||||
},
|
||||
|
||||
// An action's name in the parts the undo pill needs it in.
|
||||
//
|
||||
// The pill is a fixed width — it names the last action, so its text changes on every
|
||||
// tap, and a control that resizes under a thumb reads as the interface twitching.
|
||||
// Measured on a phone, that leaves the label 78px, and given the halves it can drop
|
||||
// one. The actor is the half that goes, because the dealer has the seat in front of
|
||||
// them and the question a hand later is *what* came back, not who it belonged to;
|
||||
// and because "Christo…'s fold" is a worse sentence than "fold".
|
||||
//
|
||||
// Two rules follow, and both were being broken:
|
||||
//
|
||||
// `act` has to name the action on its own, because it is what survives. Four
|
||||
// labels put a verb in the lead instead of an actor — "dealing" + "the hand",
|
||||
// "calling" + "the straddle off" — so dropping the lead left the pill reading
|
||||
// "↶ the hand" after every deal of the night, and "↶ the straddle off", neither
|
||||
// of which names anything. Those are re-split; the ones with no actor at all
|
||||
// hand over a single string and have nothing to drop.
|
||||
//
|
||||
// `act` has to fit, because the box ellipsises whatever is left. Measured at
|
||||
// 390x844 with a thumb: "hand killed on the clock" wanted 125px of 78, "check on
|
||||
// the clock" 99, "waiting for the blind" 103, "posting to come in" 97 — all four
|
||||
// came out cut mid-phrase. The qualifier moves into `long` and the verb stays on
|
||||
// the pill.
|
||||
//
|
||||
// `long` is the whole sentence, for the title and the accessible name and for the
|
||||
// toast the bar's own Undo raises. Every short form is a substring of it — that is
|
||||
// what WCAG's Label in Name asks of a control that has shortened its own name, and
|
||||
// it is why `long` can be the accessible name rather than only a title.
|
||||
undoParts(label) {
|
||||
const lead = typeof label === "string" ? "" : (label.lead || "");
|
||||
const act = typeof label === "string" ? label : label.act;
|
||||
const spelt = typeof label === "string" ? "" : (label.long || "");
|
||||
return { lead, act, long: spelt || (lead ? `${lead} ${act}` : act) };
|
||||
},
|
||||
|
||||
renderActions() {
|
||||
const index = this.state.actingSeatIndex;
|
||||
const player = this.state.seats[index];
|
||||
@@ -744,7 +1053,13 @@ const ActionBar = {
|
||||
expired.verb === "fold" ? `Kill ${expired.who}'s hand` : `Check for ${expired.who}`,
|
||||
"actionEnforceClock",
|
||||
() => this.commit(
|
||||
expired.verb === "fold" ? `${expired.who}'s hand killed on the clock` : `${expired.who}'s check on the clock`,
|
||||
// The ruling is what the sentence is for; the pill keeps the verb.
|
||||
// "hand killed on the clock" wants 125px of the 78 the pill has and
|
||||
// came out "hand killed on the cl…"; "check on the clock" wanted 99.
|
||||
{ lead: `${expired.who}'s`,
|
||||
act: expired.verb === "fold" ? "hand killed" : "check",
|
||||
long: `${expired.who}'s ${expired.verb === "fold"
|
||||
? "hand killed on the clock" : "check on the clock"}` },
|
||||
() => {
|
||||
this.stopClock();
|
||||
if (expired.verb === "fold") Betting.fold(this.state, index);
|
||||
@@ -756,16 +1071,16 @@ const ActionBar = {
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionButtons";
|
||||
|
||||
row.appendChild(this.button("Fold", "actionFold", () => this.commit(`${who}'s fold`, () => Betting.fold(this.state, index))));
|
||||
row.appendChild(this.button("Fold", "actionFold", () => this.commit({ lead: `${who}'s`, act: "fold" }, () => Betting.fold(this.state, index))));
|
||||
|
||||
if (owed <= 0) {
|
||||
row.appendChild(this.button("Check", "actionCheck", () => this.commit(`${who}'s check`, () => Betting.check(this.state, index))));
|
||||
row.appendChild(this.button("Check", "actionCheck", () => this.commit({ lead: `${who}'s`, act: "check" }, () => Betting.check(this.state, index))));
|
||||
} else if (owed >= stack) {
|
||||
// Calling would put them all-in, so say that rather than quoting a number
|
||||
// they can't cover.
|
||||
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(`${who}'s all-in`, () => Betting.allIn(this.state, index)), Money.format(stack)));
|
||||
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit({ lead: `${who}'s`, act: "all-in" }, () => Betting.allIn(this.state, index)), Money.format(stack)));
|
||||
} else {
|
||||
row.appendChild(this.button("Call", "actionCall", () => this.commit(`${who}'s call`, () => Betting.call(this.state, index)), Money.format(owed)));
|
||||
row.appendChild(this.button("Call", "actionCall", () => this.commit({ lead: `${who}'s`, act: "call" }, () => Betting.call(this.state, index)), Money.format(owed)));
|
||||
}
|
||||
|
||||
if (stack > owed) {
|
||||
@@ -863,9 +1178,13 @@ const ActionBar = {
|
||||
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
|
||||
this.raiseOpen = false;
|
||||
const who = HoldemState.displayName(player, index);
|
||||
const label = raising
|
||||
? `${who}'s raise to ${Money.format(target)}`
|
||||
: `${who}'s bet of ${Money.format(target)}`;
|
||||
// The amount stays in the half that survives: "did I raise, and to what"
|
||||
// is one question. It is the only act long enough to still need an ellipsis
|
||||
// on the narrowest phone, and only past four figures — "raise to $60" is
|
||||
// 55px of the 59 the label has once the arrow is drawn, "raise to $1,200" is
|
||||
// 69. The whole of it is on the button's own title and aria-label either way.
|
||||
const label = { lead: `${who}'s`,
|
||||
act: raising ? `raise to ${Money.format(target)}` : `bet of ${Money.format(target)}` };
|
||||
this.commit(label, () => Betting.raiseTo(this.state, index, target));
|
||||
}));
|
||||
|
||||
@@ -972,12 +1291,12 @@ const ActionBar = {
|
||||
buttons.push(this.button(
|
||||
on === 0 ? `Straddle ${nextAmount}` : `Re-straddle ${nextAmount}`,
|
||||
"actionStraddle",
|
||||
() => this.commit(on === 0 ? "the straddle" : "the re-straddle",
|
||||
() => this.commit({ lead: "the", act: on === 0 ? "straddle" : "re-straddle" },
|
||||
() => { this.state.straddleCount = on + 1; })));
|
||||
}
|
||||
if (on > 0) {
|
||||
buttons.push(this.button("No straddle", "actionStraddle actionStraddleOff",
|
||||
() => this.commit("calling the straddle off",
|
||||
() => this.commit({ lead: "the straddle", act: "called off" },
|
||||
() => { this.state.straddleCount = 0; })));
|
||||
}
|
||||
return buttons;
|
||||
@@ -1065,7 +1384,7 @@ const ActionBar = {
|
||||
aside.appendChild(this.button(
|
||||
benched.length === 1 ? "Deal them in" : `Deal in all ${benched.length}`,
|
||||
"dealPromptDebtBtn",
|
||||
() => this.commit("dealing everybody in", () => {
|
||||
() => this.commit({ lead: "everybody", act: "dealt in" }, () => {
|
||||
benched.forEach(index => {
|
||||
this.state.seats[index].inHand = true;
|
||||
this.state.seats[index].folded = false;
|
||||
@@ -1154,7 +1473,10 @@ const ActionBar = {
|
||||
posting ? "Don't post" : `Post ${Money.format(preview.bigBlind)}${each}`,
|
||||
"dealPromptDebtBtn",
|
||||
() => this.commit(
|
||||
posting ? `${who} waiting for the blind` : `${who} posting to come in`,
|
||||
// Both of these wanted around 100px of 78 and came out mid-phrase.
|
||||
{ lead: who,
|
||||
act: posting ? "waiting" : "posting",
|
||||
long: `${who} ${posting ? "waiting for the blind" : "posting to come in"}` },
|
||||
() => { this.state.postingIn = posting ? [] : ids; })
|
||||
));
|
||||
wrap.appendChild(line);
|
||||
@@ -1173,7 +1495,10 @@ const ActionBar = {
|
||||
// browser wants the "keep my data" request to come from — see
|
||||
// requestDurable() in state.js. Harmless and instant after the first time.
|
||||
if (this.hooks.keepStorage) this.hooks.keepStorage();
|
||||
this.commit("dealing the hand", () => Betting.beginHand(this.state));
|
||||
// No actor to drop, so no half of this can go missing: "dealing" was the
|
||||
// lead, and dropping a lead that is a verb left the pill reading "↶ the
|
||||
// hand" after every deal of the night.
|
||||
this.commit({ act: "the deal" }, () => Betting.beginHand(this.state));
|
||||
}));
|
||||
wrap.appendChild(row);
|
||||
|
||||
@@ -1234,7 +1559,8 @@ const ActionBar = {
|
||||
this.toShowdown();
|
||||
return;
|
||||
}
|
||||
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
|
||||
const street = Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase();
|
||||
this.commit({ act: `the ${street}` }, () => Betting.closeStreet(this.state));
|
||||
// The cards that just came off the deck. Asked for here rather than left
|
||||
// for the dealer to remember, because the moment they are holding them is
|
||||
// the only moment they know what they are without turning the board round.
|
||||
|
||||
@@ -41,7 +41,17 @@ const AddChips = {
|
||||
];
|
||||
},
|
||||
|
||||
// Choosing a preset redraws the three of them so the chosen one can light up, which
|
||||
// meant the button the dealer had just pressed was deleted while it held focus —
|
||||
// and a deleted element doesn't hand focus on, it drops it on the body. Measured:
|
||||
// Space on "Buy-in" left focus nowhere, inside a modal, with the amount changed and
|
||||
// nothing to say so. See Modal.preservingFocus; the key is the amount, because that
|
||||
// is what makes one of these three buttons itself.
|
||||
render() {
|
||||
Modal.preservingFocus(document.getElementById("addChipsSheet"), () => this.paint());
|
||||
},
|
||||
|
||||
paint() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
document.getElementById("addChipsTitle").textContent =
|
||||
`Add chips — ${HoldemState.displayName(player, this.seatIndex)}`;
|
||||
@@ -59,6 +69,7 @@ const AddChips = {
|
||||
btn.type = "button";
|
||||
btn.className = "addChipsQuickBtn";
|
||||
btn.classList.toggle("selected", Number(this.draft) === option.value);
|
||||
btn.dataset.focusKey = `quick-${option.value}`;
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "addChipsQuickLabel";
|
||||
|
||||
@@ -2,18 +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 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
|
||||
@@ -30,6 +63,8 @@ const CardPicker = {
|
||||
this.target = "seat";
|
||||
this.seatIndex = seatIndex;
|
||||
this.cardIndex = cardIndex;
|
||||
this.cursor = 0;
|
||||
this.aimSuit();
|
||||
this.render();
|
||||
document.getElementById("cardPickerOverlay").hidden = false;
|
||||
},
|
||||
@@ -44,6 +79,8 @@ const CardPicker = {
|
||||
this.seatIndex = null;
|
||||
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;
|
||||
},
|
||||
@@ -138,10 +175,153 @@ const CardPicker = {
|
||||
return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
|
||||
},
|
||||
|
||||
// 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 => {
|
||||
this.SUITS.forEach(suit => cards.push({ rank, suit, card: `${rank}${suit.code}` }));
|
||||
});
|
||||
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.
|
||||
//
|
||||
// This used to hunt past dealt cards, because a `disabled` button cannot take focus
|
||||
// — and hunting did the very thing the no-wrap rule was written to prevent. Right
|
||||
// from A♠ with the ace of hearts dealt landed on A♦, so the column silently became
|
||||
// the one to its right, and the next press of Down walked K♦ instead of K♥. A dealt
|
||||
// card is `aria-disabled` now, so it holds focus like every other cell and the grid
|
||||
// tells the truth about its own shape; the APG asks for exactly that inside a
|
||||
// composite widget, and pressing one is answered rather than ignored.
|
||||
nextCursor(from, step) {
|
||||
const count = document.querySelectorAll("#cardPickerGrid .pickerCard").length;
|
||||
const to = from + step;
|
||||
return to >= 0 && to < count ? to : from;
|
||||
},
|
||||
|
||||
// One Tab stop for fifty-two cards.
|
||||
//
|
||||
// Every card used to be its own stop, which made this sheet fifty-six of them:
|
||||
// reaching the seven of clubs meant something like forty presses of the only key
|
||||
// that moves at all without a mouse. The APG's answer for a set of controls like
|
||||
// this is the roving tabindex — one of them is in the Tab order at a time, and the
|
||||
// arrows move between them — so the grid now costs one press to enter and one to
|
||||
// leave, and the arrows bring the row into view on the way, which forty Tab presses
|
||||
// through a scrolling sheet never reliably did.
|
||||
//
|
||||
// No new role is claimed for the container. They are buttons, they announce as
|
||||
// buttons, and the only thing that changed is how far away they are.
|
||||
aimCursor(index, { focus = false } = {}) {
|
||||
const buttons = [...document.querySelectorAll("#cardPickerGrid .pickerCard")];
|
||||
if (!buttons.length) return;
|
||||
this.cursor = Math.max(0, Math.min(buttons.length - 1, index));
|
||||
buttons.forEach((btn, i) => {
|
||||
btn.tabIndex = i === this.cursor ? 0 : -1;
|
||||
});
|
||||
if (focus) buttons[this.cursor].focus({ preventScroll: false });
|
||||
},
|
||||
|
||||
onGridKey(event) {
|
||||
const stride = this.gridStride();
|
||||
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: stride, ArrowUp: -stride }[event.key];
|
||||
if (step !== undefined) {
|
||||
event.preventDefault();
|
||||
this.aimCursor(this.nextCursor(this.cursor, step), { focus: true });
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Home" && event.key !== "End") return;
|
||||
event.preventDefault();
|
||||
const count = document.querySelectorAll("#cardPickerGrid .pickerCard").length;
|
||||
this.aimCursor(event.key === "Home" ? this.nextCursor(-1, 1) : this.nextCursor(count, -1), { focus: true });
|
||||
},
|
||||
|
||||
// A redraw is not a reason to lose your place. Every button in here is thrown away
|
||||
// and rebuilt on each one, so whatever held focus was being deleted out from under
|
||||
// the dealer — see Modal.preservingFocus. The card just picked is the one key that
|
||||
// can't come back, because it is a dealt card now: the slot it went into is the
|
||||
// useful place to be instead.
|
||||
render() {
|
||||
const sheet = document.getElementById("cardPickerSheet");
|
||||
Modal.preservingFocus(sheet, () => this.paint(), () => {
|
||||
const slot = sheet.querySelector("#cardPickerSlots .pickerSlot.active");
|
||||
if (slot) slot.focus({ preventScroll: true });
|
||||
});
|
||||
},
|
||||
|
||||
paint() {
|
||||
const used = this.cardsInUse();
|
||||
const values = this.slotValues();
|
||||
|
||||
// Whatever the line was answering has been answered. Cleared here rather than
|
||||
// on a timer, because a paint is exactly the set of things that make the last
|
||||
// answer stale — a card went in, or the sheet moved to a different slot — and
|
||||
// nothing else in this sheet redraws.
|
||||
const note = document.getElementById("cardPickerNote");
|
||||
if (note) note.textContent = "";
|
||||
|
||||
document.getElementById("cardPickerTitle").textContent = this.onBoard()
|
||||
? `Board — ${this.boardSlotLabel(this.cardIndex).toLowerCase()}`
|
||||
: `${HoldemState.displayName(this.state.seats[this.seatIndex], this.seatIndex)} — card ${this.cardIndex + 1}`;
|
||||
@@ -156,8 +336,17 @@ const CardPicker = {
|
||||
btn.classList.toggle("active", slot === this.cardIndex);
|
||||
const value = values[slot] || "";
|
||||
btn.textContent = value || "—";
|
||||
if (this.onBoard()) btn.title = this.boardSlotLabel(slot);
|
||||
// What it is called out loud. On the eye it is a card or an em dash, and an
|
||||
// em dash is what a screen reader was being given as the whole name of the
|
||||
// control: "dash, button", twice, above fifty-two more. The slot knows which
|
||||
// slot it is and what is in it, so it says both.
|
||||
const which = this.onBoard() ? this.boardSlotLabel(slot) : `Card ${slot + 1}`;
|
||||
btn.setAttribute("aria-label", value ? `${which}: ${value}` : `${which}: empty`);
|
||||
if (this.onBoard()) btn.title = which;
|
||||
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
|
||||
// The name this control answers to after a redraw. A slot is always the
|
||||
// same slot — flop 2 stays flop 2 — so its number is the whole of it.
|
||||
btn.dataset.focusKey = `slot-${slot}`;
|
||||
btn.addEventListener("click", () => {
|
||||
this.cardIndex = slot;
|
||||
this.render();
|
||||
@@ -165,40 +354,78 @@ 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.RANKS.forEach(rank => {
|
||||
this.SUITS.forEach(suit => {
|
||||
const card = `${rank}${suit.code}`;
|
||||
const takenBy = used.get(card);
|
||||
this.gridCards().forEach(({ rank, suit, card }) => {
|
||||
const takenBy = used.get(card);
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pickerCard";
|
||||
btn.classList.toggle("cardRed", suit.red);
|
||||
btn.disabled = !!takenBy;
|
||||
if (takenBy) {
|
||||
btn.title = takenBy === "the board"
|
||||
? "Already face up on the board"
|
||||
: `Already dealt to ${takenBy}`;
|
||||
}
|
||||
if ((values[this.cardIndex] || "").toUpperCase() === card) {
|
||||
btn.classList.add("selected");
|
||||
}
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pickerCard";
|
||||
btn.classList.toggle("cardRed", suit.red);
|
||||
btn.dataset.focusKey = card;
|
||||
// Named in the sentence rather than left as "this one", because the sentence
|
||||
// is read above the grid and the card that was pressed can be eight rows
|
||||
// down it. Short by measurement, not by taste — see .pickerNote.
|
||||
const label = `${rank}${suit.symbol}`;
|
||||
Unavailable.set(btn, !!takenBy, takenBy === "the board"
|
||||
? `${label} — face up on the board`
|
||||
: `${label} — dealt to ${takenBy}`);
|
||||
// Still the tooltip, and now also the accessible description — `title` only
|
||||
// reaches a screen reader on an element it is allowed to land on, which is
|
||||
// the other half of what `disabled` was costing here.
|
||||
if (takenBy) btn.title = Unavailable.reason(btn);
|
||||
else btn.removeAttribute("title");
|
||||
if ((values[this.cardIndex] || "").toUpperCase() === card) {
|
||||
btn.classList.add("selected");
|
||||
}
|
||||
|
||||
const rankEl = document.createElement("span");
|
||||
rankEl.className = "pickerRank";
|
||||
rankEl.textContent = rank;
|
||||
const suitEl = document.createElement("span");
|
||||
suitEl.className = "pickerSuit";
|
||||
suitEl.textContent = suit.symbol;
|
||||
btn.appendChild(rankEl);
|
||||
btn.appendChild(suitEl);
|
||||
const rankEl = document.createElement("span");
|
||||
rankEl.className = "pickerRank";
|
||||
rankEl.textContent = rank;
|
||||
const suitEl = document.createElement("span");
|
||||
suitEl.className = "pickerSuit";
|
||||
suitEl.textContent = suit.symbol;
|
||||
btn.appendChild(rankEl);
|
||||
btn.appendChild(suitEl);
|
||||
|
||||
btn.addEventListener("click", () => this.assign(card));
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
btn.addEventListener("click", () => this.assign(card));
|
||||
btn.addEventListener("keydown", event => this.onGridKey(event));
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
|
||||
// Hand the Tab order back exactly one of them. Done after the grid is built,
|
||||
// because which card can hold the cursor depends on which are dealt already.
|
||||
this.aimCursor(this.cursor);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,20 @@
|
||||
// (in practice: Undo). Only one is ever on screen; a second replaces the first.
|
||||
const Toast = {
|
||||
DEFAULT_DURATION_MS: 8000,
|
||||
|
||||
// What the clock is set back to when the dealer lets go of a toast.
|
||||
//
|
||||
// Pausing while it is touched or focused is only half the job. Let go at 7.9
|
||||
// seconds of 8 and the message goes while the thumb is still travelling: the
|
||||
// timer that matters once somebody has reached for a toast is the time to act
|
||||
// on it, not what happened to be left of the time to notice it.
|
||||
RESUME_MS: 2500,
|
||||
|
||||
timer: null,
|
||||
endsAt: 0,
|
||||
remaining: 0,
|
||||
// Where focus was standing when this arrived.
|
||||
returnFocusTo: null,
|
||||
|
||||
show(message, options = {}) {
|
||||
const host = document.getElementById("toastHost");
|
||||
@@ -17,7 +30,11 @@ const Toast = {
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "toast";
|
||||
toast.setAttribute("role", "status");
|
||||
// No role on the toast itself. #toastHost is the live region, and it is in the
|
||||
// markup — which is what makes it observed. A region that arrives already
|
||||
// carrying its text has not been watched by anything yet, and is routinely
|
||||
// never spoken at all; one nested inside a region that is watched only buys a
|
||||
// second reading of the same sentence.
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "toastText";
|
||||
@@ -29,6 +46,12 @@ const Toast = {
|
||||
action.type = "button";
|
||||
action.className = "toastAction";
|
||||
action.textContent = options.actionLabel;
|
||||
// Which keyboard shortcut, if any, presses this. Measured on a desktop,
|
||||
// this button is the 67th of the page's 68 tab stops, and it is gone in
|
||||
// eight seconds: reaching it by Tab is not a thing anybody can do. Only
|
||||
// the toasts that say so opt in — "Turn off" and "Reload" are not things
|
||||
// a stray keypress should be able to reach.
|
||||
if (options.shortcut) action.dataset.shortcut = options.shortcut;
|
||||
action.addEventListener("click", () => {
|
||||
this.dismiss();
|
||||
options.onAction();
|
||||
@@ -44,6 +67,27 @@ const Toast = {
|
||||
close.addEventListener("click", () => this.dismiss());
|
||||
toast.appendChild(close);
|
||||
|
||||
// Read after dismiss() above has had its chance to put focus back, so that a
|
||||
// toast replacing another remembers where the dealer actually is.
|
||||
const active = document.activeElement;
|
||||
this.returnFocusTo = active && active !== document.body ? active : null;
|
||||
|
||||
// A message with a button in it is on a timer the reader has to beat, which is
|
||||
// what WCAG's Timing Adjustable is about. Touching it or tabbing into it stops
|
||||
// the clock; letting go starts it again.
|
||||
toast.addEventListener("pointerenter", () => this.hold());
|
||||
toast.addEventListener("focusin", event => {
|
||||
// Where focus came *from* beats where it was when this appeared: a dealer
|
||||
// who moves on and then tabs back into the toast should be put back where
|
||||
// they had got to, not where they were eight seconds ago.
|
||||
if (event.relatedTarget && !toast.contains(event.relatedTarget)) {
|
||||
this.returnFocusTo = event.relatedTarget;
|
||||
}
|
||||
this.hold();
|
||||
});
|
||||
["pointerleave", "focusout"].forEach(type =>
|
||||
toast.addEventListener(type, () => this.resume()));
|
||||
|
||||
host.appendChild(toast);
|
||||
// Next frame, so the entrance transition actually runs.
|
||||
requestAnimationFrame(() => {
|
||||
@@ -51,10 +95,51 @@ const Toast = {
|
||||
this.publishHeight();
|
||||
});
|
||||
|
||||
this.timer = setTimeout(
|
||||
() => this.dismiss(),
|
||||
options.duration || this.DEFAULT_DURATION_MS
|
||||
);
|
||||
this.arm(options.duration || this.DEFAULT_DURATION_MS);
|
||||
},
|
||||
|
||||
arm(ms) {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.endsAt = Date.now() + ms;
|
||||
this.timer = setTimeout(() => this.dismiss(), ms);
|
||||
},
|
||||
|
||||
// Is the dealer holding this open — a finger on it, or the keyboard inside it?
|
||||
heldOpen() {
|
||||
if (typeof document === "undefined") return false;
|
||||
const host = document.getElementById("toastHost");
|
||||
const toast = host && host.firstElementChild;
|
||||
if (!toast) return false;
|
||||
if (document.activeElement && toast.contains(document.activeElement)) return true;
|
||||
// :hover rather than a flag of our own, so a pointer that left the window
|
||||
// without a pointerleave — or one that never sent pointerenter because the
|
||||
// toast appeared underneath it — is answered by the same question either way.
|
||||
try {
|
||||
return typeof toast.matches === "function" && toast.matches(":hover");
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
hold() {
|
||||
if (!this.timer) return;
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.remaining = Math.max(0, this.endsAt - Date.now());
|
||||
},
|
||||
|
||||
resume() {
|
||||
// `focusout` arrives before the new focus lands, so "is anything in here still
|
||||
// focused?" has no true answer until the next tick. pointerleave is deferred
|
||||
// with it rather than against it: leaving with the keyboard still inside must
|
||||
// not restart the clock.
|
||||
setTimeout(() => {
|
||||
if (this.timer || this.heldOpen()) return;
|
||||
if (typeof document === "undefined") return;
|
||||
const host = document.getElementById("toastHost");
|
||||
if (!host || !host.firstElementChild) return;
|
||||
this.arm(Math.max(this.remaining, this.RESUME_MS));
|
||||
}, 0);
|
||||
},
|
||||
|
||||
dismiss() {
|
||||
@@ -62,8 +147,25 @@ const Toast = {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.remaining = 0;
|
||||
// The same guard publishHeight below already carries, for the same reason: the
|
||||
// bar dismisses a toast on the way into every action it commits, and that path
|
||||
// is otherwise plain bookkeeping that answers without a document.
|
||||
if (typeof document === "undefined") return;
|
||||
const host = document.getElementById("toastHost");
|
||||
if (host) host.innerHTML = "";
|
||||
if (host) {
|
||||
// Never leave the keyboard standing on nothing. A control removed while it
|
||||
// has focus hands focus to <body>, and on this page that is 66 tab stops
|
||||
// back from where the dealer was — so whoever pressed Undo, or ✕, or was
|
||||
// simply reading when a second toast arrived, goes back where they were.
|
||||
const active = document.activeElement;
|
||||
const goBack = host.contains(active) ? this.returnFocusTo : null;
|
||||
host.innerHTML = "";
|
||||
if (goBack && goBack.isConnected && typeof goBack.focus === "function") {
|
||||
goBack.focus();
|
||||
}
|
||||
}
|
||||
this.returnFocusTo = null;
|
||||
this.publishHeight();
|
||||
},
|
||||
|
||||
@@ -131,6 +233,80 @@ const Dialog = {
|
||||
},
|
||||
};
|
||||
|
||||
// A control that is on the screen and cannot act, and the reason it can't.
|
||||
//
|
||||
// `disabled` is the wrong attribute the moment there is something to say. It takes the
|
||||
// control out of the tab order, so a screen reader never lands on it and never hears
|
||||
// the `title` hung off it; and it eats the tap on a touch screen, where there is no
|
||||
// hover to show a `title` to begin with. Measured on an iPhone with three hands dealt:
|
||||
// seven of the card picker's fifty-two cards were dead, none of them could hold focus,
|
||||
// and tapping one changed nothing whatsoever — no toast, no text, not a byte of DOM.
|
||||
// The tool's answer to "who has the ace of spades?" existed, and was reachable only
|
||||
// with a mouse.
|
||||
//
|
||||
// `aria-disabled` announces the same state and leaves the control reachable, so the
|
||||
// press arrives and can be answered instead. Nothing enforces it for you — that is the
|
||||
// whole point — so a press is intercepted once, centrally, in `answerPress` below.
|
||||
const Unavailable = {
|
||||
// Which control is being told off, if any. A press can land on the text inside a
|
||||
// button rather than the button, so the answer is looked for upwards.
|
||||
from(target) {
|
||||
if (!target || typeof target.closest !== "function") return null;
|
||||
return target.closest('[aria-disabled="true"]');
|
||||
},
|
||||
|
||||
reason(el) {
|
||||
return (el && el.dataset && el.dataset.whyNot) || "";
|
||||
},
|
||||
|
||||
blocked(el) {
|
||||
return !!el && el.getAttribute("aria-disabled") === "true";
|
||||
},
|
||||
|
||||
// Both directions in one call, because every caller works out the condition anyway
|
||||
// and the two halves have to stay in step — a control left marked after the reason
|
||||
// stopped being true is worse than one that was never marked.
|
||||
set(el, blocked, reason) {
|
||||
if (!el) return;
|
||||
if (!blocked) {
|
||||
el.removeAttribute("aria-disabled");
|
||||
delete el.dataset.whyNot;
|
||||
return;
|
||||
}
|
||||
el.setAttribute("aria-disabled", "true");
|
||||
el.dataset.whyNot = reason || "";
|
||||
},
|
||||
|
||||
// Where the answer is written.
|
||||
//
|
||||
// The toast is fixed to the bottom of the window, and a sheet that stands 88% of
|
||||
// the screen tall is sitting on top of it — so a control inside one answers inside
|
||||
// it instead. A sheet opts in by naming its own line: `data-why-note="someId"`.
|
||||
// Anything else falls through to the toast, which is where the table says things.
|
||||
say(el, why) {
|
||||
const host = el && typeof el.closest === "function" ? el.closest("[data-why-note]") : null;
|
||||
const note = host ? document.getElementById(host.dataset.whyNote) : null;
|
||||
if (note) {
|
||||
note.textContent = why;
|
||||
return;
|
||||
}
|
||||
Toast.show(why);
|
||||
},
|
||||
|
||||
// One listener for the whole table, in the capture phase so it arrives before the
|
||||
// control's own handler and can stop it. Registered once, by main.js.
|
||||
listen(root) {
|
||||
root.addEventListener("click", event => {
|
||||
const el = this.from(event.target);
|
||||
const why = this.reason(el);
|
||||
if (!why) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.say(el, why);
|
||||
}, true);
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { Toast, Dialog };
|
||||
module.exports = { Toast, Dialog, Unavailable };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// `hidden` attribute they all already toggle.
|
||||
Modal.mount();
|
||||
|
||||
// A press on a control that can't act is answered rather than swallowed. One
|
||||
// listener for the whole table, because the alternative is remembering to guard
|
||||
// every click handler that might one day be attached to a dimmed control — and the
|
||||
// one that gets forgotten is a dead tap with no explanation, which is the bug this
|
||||
// is here to end. See Unavailable in feedback.js.
|
||||
Unavailable.listen(document);
|
||||
|
||||
// ---- A scroll is not an edit ----
|
||||
//
|
||||
// A focused `<input type="number">` is a spinbutton, and a browser lets the mouse
|
||||
@@ -54,6 +61,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
Toast.show(message, {
|
||||
actionLabel: "Undo",
|
||||
shortcut: "undo",
|
||||
onAction: () => {
|
||||
restoreState(snapshot);
|
||||
Toast.show("Undone.", { duration: 2500 });
|
||||
@@ -342,6 +350,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// the right panel to have open on a table nobody has touched, and the wrong one to
|
||||
// have open on the fourth hand of a night.
|
||||
//
|
||||
// That was half the answer. The other half was what the panel held while it was
|
||||
// open — 886px of it, on a 667px phone — and it has since given three switches and
|
||||
// the paragraphs explaining them to "While you deal". The panel this opens is 529px
|
||||
// now, and the two figures its own summary glances at are the first two rows of it.
|
||||
//
|
||||
// Decided once, here, rather than on every render — a panel that reopened itself
|
||||
// whenever the table emptied would fight the dealer who had just shut it.
|
||||
(() => {
|
||||
@@ -407,13 +420,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
Render.bringIntoView(panel, { block: "nearest" });
|
||||
},
|
||||
|
||||
beginAction: label => {
|
||||
actionHistory.push({ label, snapshot: snapshotState() });
|
||||
// `parts` is the same name split into the actor and the act — see
|
||||
// ActionBar.undoParts. It rides along with the label rather than replacing it,
|
||||
// because the toast below wants the sentence and the pill wants the halves.
|
||||
beginAction: (label, parts) => {
|
||||
actionHistory.push({ label, parts, snapshot: snapshotState() });
|
||||
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
|
||||
},
|
||||
|
||||
lastActionLabel: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].label : null),
|
||||
|
||||
lastActionParts: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].parts : null),
|
||||
|
||||
undoLast: () => {
|
||||
const step = actionHistory.pop();
|
||||
if (!step) return;
|
||||
@@ -436,6 +454,24 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// The toast's Undo, pressed the same way the bar's is: by pressing the button that
|
||||
// is actually on the screen, not by running a second copy of what it does.
|
||||
//
|
||||
// It has to be reachable. Every whole-step undo — next hand, resolve pot, a seat
|
||||
// edited — clears the per-action history on its way past, so the bar has no pill to
|
||||
// press and this button is the only way back. Measured on a desktop, it is the 67th
|
||||
// of the page's 68 tab stops and it is gone in eight seconds, so U was the key the
|
||||
// sidebar legend advertised and nothing answered.
|
||||
//
|
||||
// Only a button that named this shortcut, so the wake lock's "Turn off" and the
|
||||
// other-tab warning's "Reload" are not things a stray U can reach.
|
||||
const pressToastAction = shortcut => {
|
||||
const btn = document.querySelector(`#toastHost .toastAction[data-shortcut="${shortcut}"]`);
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
};
|
||||
|
||||
const quickRaiseButton = label =>
|
||||
[...document.querySelectorAll("#actionBar .raiseQuickBtn")]
|
||||
.find(b => b.querySelector(".actionBtnLabel").textContent === label);
|
||||
@@ -456,7 +492,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// Dealing is an advance too — Enter should carry a dealer through setup, deal,
|
||||
// streets and showdown without reaching for the trackpad.
|
||||
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance") || pressInBar(".actionDeal"),
|
||||
undo: () => pressInBar(".actionUndo"),
|
||||
// The toast first, because it is the more recent of the two. They can both be
|
||||
// up at once — a seat edited mid-hand leaves the bar's pill alone — and the one
|
||||
// the dealer just caused is the one on the screen saying what it will take back.
|
||||
undo: () => pressToastAction("undo") || pressInBar(".actionUndo"),
|
||||
cancel: () => pressInBar(".raiseCancel"),
|
||||
});
|
||||
|
||||
@@ -476,6 +515,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
Toast.show(message, {
|
||||
actionLabel: "Undo",
|
||||
shortcut: "undo",
|
||||
onAction: () => {
|
||||
restoreState(snapshot);
|
||||
Toast.show("Undone.", { duration: 2500 });
|
||||
@@ -523,7 +563,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
|
||||
document.getElementById("basePotInput").addEventListener("input", e => {
|
||||
state.basePotAmount = e.target.value === "" ? null : Number(e.target.value);
|
||||
state.basePotAmount = Money.readAmount(e.target.value);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
@@ -624,6 +664,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// middle took the table from 800 chips to 792, and the toast said nothing
|
||||
// about it. Chips only ever leave this tool because a dealer meant them to.
|
||||
const unresolved = HoldemState.unresolvedContributions(state);
|
||||
// Asked before the mutation, because after it there is never a hand out. This is
|
||||
// the difference between the two things this one button does: end the hand that
|
||||
// is running, or pass the button along on a hand nobody played.
|
||||
const wasLive = !!state.bettingOpen;
|
||||
|
||||
if (unresolved > Betting.EPSILON_CHIPS) {
|
||||
const ok = await Dialog.confirm({
|
||||
@@ -652,7 +696,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
? ` ${Money.format(unresolved)} taken off the table.`
|
||||
: "";
|
||||
|
||||
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
|
||||
// The toast said "Next hand — ... Seat the table, then deal." every time, including
|
||||
// the press that followed the pot wizard's own "Start Next Hand" — two lines that
|
||||
// read as a confirmation of the thing the dealer had already done, when what had
|
||||
// actually happened was a second rotation. It now reports which of the two it was,
|
||||
// and the second line is gone: the bar is already holding a green "Post blinds &
|
||||
// deal", so the toast was spending a line telling a dealer to press it.
|
||||
const done = wasLive
|
||||
? `Next hand — ${where}.${discarded}`
|
||||
: `Button moved on, no hand dealt — ${where}.${discarded}`;
|
||||
|
||||
runUndoable(done, () => {
|
||||
// Written down as well as announced. The toast is a moment; the bank check
|
||||
// is asked at cash-out, and until now it saw only that the chips were gone
|
||||
// and reported them as missing.
|
||||
@@ -774,7 +828,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
["defaultStackInput", "defaultStack"],
|
||||
].forEach(([id, key]) => {
|
||||
document.getElementById(id).addEventListener("input", e => {
|
||||
state[key] = e.target.value === "" ? null : Number(e.target.value);
|
||||
// Money.readAmount rather than Number(): these boxes are type="text" now, so
|
||||
// a half-typed "0." or a stray letter reaches here instead of being silently
|
||||
// eaten by the control, and NaN must not become the blind.
|
||||
state[key] = Money.readAmount(e.target.value);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
@@ -874,56 +931,32 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// An index across the top of the rulings sheet, one chip per ruling.
|
||||
// The sheet opens on twelve shut rulings, and it opens that way every time.
|
||||
//
|
||||
// This is opened mid-argument with players waiting, and it was nine rulings of
|
||||
// prose with no way in: reaching the last one meant scrolling 1.6 screens on a
|
||||
// desktop, 2.1 on a phone and 3.5 on a phone held sideways. A dealer looking for
|
||||
// the odd-chip rule had to read past seven others to find it.
|
||||
//
|
||||
// Built from the sections rather than written out beside them, so a ruling added
|
||||
// later turns up in the index without anyone having to remember to add it twice.
|
||||
const buildRulingsIndex = () => {
|
||||
const index = document.getElementById("rulingsIndex");
|
||||
if (!index || index.childElementCount > 0) return;
|
||||
[...rulingsOverlay.querySelectorAll(".ruling")].forEach((section, i) => {
|
||||
const heading = section.querySelector("h3");
|
||||
if (!heading) return;
|
||||
if (!section.id) section.id = `ruling${i + 1}`;
|
||||
|
||||
const jump = document.createElement("button");
|
||||
jump.type = "button";
|
||||
jump.className = "rulingsIndexBtn";
|
||||
// A short label where the ruling gives one. The headings are sentences —
|
||||
// "A short all-in doesn't reopen the betting" — and nine of those stacked
|
||||
// one per row made the index 342px tall on a phone, which is most of the
|
||||
// sheet before the first ruling. "Short all-in" says the same thing to
|
||||
// somebody who is looking for it, and three fit on a line.
|
||||
jump.textContent = section.dataset.short || heading.textContent;
|
||||
// The full heading is still what it announces, so the short label never
|
||||
// costs a screen reader the sentence.
|
||||
if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent);
|
||||
jump.addEventListener("click", () => {
|
||||
Render.bringIntoView(section, { block: "start" });
|
||||
// The heading is what the dealer is now reading, so that is where the
|
||||
// keyboard and a screen reader should be too.
|
||||
heading.setAttribute("tabindex", "-1");
|
||||
heading.focus({ preventScroll: true });
|
||||
});
|
||||
index.appendChild(jump);
|
||||
});
|
||||
// There was an index of chips here that this built from the headings; the headings
|
||||
// are the index now, so there is nothing to build. What is left is the other half
|
||||
// of the job: a dealer who read the odd-chip rule an hour ago should not find it
|
||||
// still hanging open under the one they came back for. <details> keeps its state,
|
||||
// so closing the sheet is what forgets it.
|
||||
const openRulings = () => {
|
||||
rulingsOverlay.querySelectorAll(".ruling[open]").forEach(r => { r.open = false; });
|
||||
rulingsOverlay.hidden = false;
|
||||
};
|
||||
|
||||
document.getElementById("openRulingsBtn").addEventListener("click", () => {
|
||||
buildRulingsIndex();
|
||||
rulingsOverlay.hidden = false;
|
||||
});
|
||||
document.getElementById("openRulingsBtn").addEventListener("click", openRulings);
|
||||
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
|
||||
rulingsOverlay.addEventListener("click", e => {
|
||||
if (e.target === rulingsOverlay) closeRulings();
|
||||
});
|
||||
|
||||
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
|
||||
document.getElementById("seatMenuCloseBtn").addEventListener("click", () => SeatMenu.close());
|
||||
// The button at the thumb end is the sheet's Back while the sheet has an inside to
|
||||
// go back to. Opening the move picker used to be one-way: it replaced the action
|
||||
// list, and the only control left threw the whole menu away.
|
||||
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => {
|
||||
if (SeatMenu.mode === "move") SeatMenu.closeMovePicker();
|
||||
else SeatMenu.close();
|
||||
});
|
||||
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
|
||||
if (e.target.id === "seatMenuOverlay") SeatMenu.close();
|
||||
});
|
||||
|
||||
@@ -14,12 +14,24 @@
|
||||
const Modal = {
|
||||
// `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
|
||||
// must not be a Tab stop.
|
||||
//
|
||||
// `details > summary` is here because a summary is focusable without anyone giving
|
||||
// it a tabindex, and leaving it out was not a small omission. The rulings sheet is
|
||||
// twelve summaries and one Done button; this list saw one control in it, so the Tab
|
||||
// trap below decided Done was both the first stop and the last and bounced every
|
||||
// press straight back to it. Measured: eighty Tab presses, one element ever
|
||||
// reached. Twelve rulings, none of them openable without a mouse.
|
||||
//
|
||||
// `:not([tabindex="-1"])` belongs on every line, not only the last. A control taken
|
||||
// out of the Tab order still matches `button` or `input` — and a roving tabindex,
|
||||
// which is what the card grid runs on now, is fifty-one of them at a time.
|
||||
FOCUSABLE: [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
'input:not([disabled]):not([type="hidden"])',
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'a[href]:not([tabindex="-1"])',
|
||||
'button:not([disabled]):not([tabindex="-1"])',
|
||||
'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
|
||||
'select:not([disabled]):not([tabindex="-1"])',
|
||||
'textarea:not([disabled]):not([tabindex="-1"])',
|
||||
'details > summary:not([tabindex="-1"])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(","),
|
||||
|
||||
@@ -63,11 +75,22 @@ const Modal = {
|
||||
|
||||
this.open.push({ overlay, returnFocus: document.activeElement });
|
||||
|
||||
// Focus the first real control. A dialog that leaves focus on the button
|
||||
// behind it reads, to a screen reader, as though nothing happened at all.
|
||||
// Content-only sheets get focus on the sheet so the title is announced.
|
||||
// Where the sheet puts the dealer when it opens.
|
||||
//
|
||||
// This used to be "the first control", full stop — one answer to a question six
|
||||
// dialogs answer differently, and on the card picker the first control is Clear.
|
||||
// The sheet for writing a card down opened on the button that rubs one out. The
|
||||
// APG's rule is the opposite of that: where an action is hard to take back, land
|
||||
// on the least destructive thing in the dialog.
|
||||
//
|
||||
// So a sheet names its own landing spot with `data-modal-focus`, and on all five
|
||||
// that name one it is the heading. That is Roselli's advice for a dialog with
|
||||
// more in it than a sentence — give the reader a chance to orient — and here it
|
||||
// has a second use, because these headings say *which*: which seat, which card,
|
||||
// which player's chips. A sheet that names nothing still gets the first control,
|
||||
// and a sheet with no controls at all still gets focus on itself.
|
||||
const focusable = this.focusableIn(overlay);
|
||||
const target = focusable[0];
|
||||
const target = overlay.querySelector("[data-modal-focus]") || focusable[0];
|
||||
if (target) {
|
||||
target.focus({ preventScroll: true });
|
||||
} else if (sheet) {
|
||||
@@ -93,6 +116,39 @@ const Modal = {
|
||||
}
|
||||
},
|
||||
|
||||
// Rebuild part of a sheet without losing the dealer's place in it.
|
||||
//
|
||||
// Emptying the element that currently holds focus does not move focus — it destroys
|
||||
// it, and the browser drops the remains on `<body>`. Measured, on three sheets that
|
||||
// redraw themselves after every tap: choosing a buy-in preset, picking a card,
|
||||
// switching which slot is being filled. All three left focus on the document body,
|
||||
// which inside a modal is nowhere: the next Tab had to be rescued by the wrap above.
|
||||
//
|
||||
// Controls that get rebuilt say who they are with `data-focus-key`, and whoever held
|
||||
// focus is handed back the control with the same name. When that name is gone the
|
||||
// caller is asked instead, because only it knows where the dealer should be next —
|
||||
// the card just picked is a dealt card now, and dealt cards are disabled.
|
||||
preservingFocus(root, rebuild, whenGone) {
|
||||
const active = document.activeElement;
|
||||
const inside = !!(active && root.contains(active));
|
||||
const key = inside ? active.dataset.focusKey : undefined;
|
||||
|
||||
rebuild();
|
||||
|
||||
// Nothing was taken from under the dealer: either they weren't in this sheet, or
|
||||
// the control they were standing on is still standing, and still has them. Only
|
||||
// a rebuild that destroyed the focused element has anything to put right, which
|
||||
// is why Clear — a button in the header that no redraw touches — keeps focus
|
||||
// through a redraw it asked for.
|
||||
if (!inside || active.isConnected) return;
|
||||
|
||||
const again = key === undefined ? null
|
||||
: [...root.querySelectorAll("[data-focus-key]")]
|
||||
.find(el => el.dataset.focusKey === key && !el.disabled);
|
||||
if (again) again.focus({ preventScroll: true });
|
||||
else if (whenGone) whenGone();
|
||||
},
|
||||
|
||||
topmost() {
|
||||
return this.open.length ? this.open[this.open.length - 1] : null;
|
||||
},
|
||||
@@ -130,12 +186,20 @@ const Modal = {
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (event.shiftKey && (active === first || !top.overlay.contains(active))) {
|
||||
// Where the dealer stands *in the ring*, which is a different question from
|
||||
// where they are on the screen. Three things inside these sheets are on purpose
|
||||
// not in the ring: the heading a sheet lands on, every card in the grid bar the
|
||||
// one the arrows are holding, and — for a moment after a rebuild — nothing at
|
||||
// all, because focus has been dropped on the body. -1 covers all three, and the
|
||||
// wrap has to work from there or the first Shift+Tab off a heading walks out of
|
||||
// the dialog it was supposed to be held inside.
|
||||
const at = focusable.indexOf(document.activeElement);
|
||||
|
||||
if (event.shiftKey && at <= 0) {
|
||||
event.preventDefault();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) {
|
||||
} else if (!event.shiftKey && (at === -1 || at === focusable.length - 1)) {
|
||||
event.preventDefault();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
@@ -17,6 +17,31 @@ const Money = {
|
||||
return Math.round((Number(amount) || 0) * 100);
|
||||
},
|
||||
|
||||
// What a typed-in box is worth, or null if it isn't worth anything yet.
|
||||
//
|
||||
// The numeric fields are type="text" now, which is the whole point — the browser
|
||||
// hands over exactly what was typed instead of silently dropping the characters it
|
||||
// dislikes. The cost of that is that what was typed might not be a number, and
|
||||
// `Number("abc")` is NaN, which would go into state, get saved, and come back as
|
||||
// null on reload having taken the blind with it.
|
||||
//
|
||||
// So the reading is done in one place: empty is null, anything non-finite is null,
|
||||
// and a real number comes back rounded to the cent the rest of the tool works in.
|
||||
// Null rather than 0 because the fields it reads distinguish them — an unset
|
||||
// starting stack falls back to the table default, a zero one does not.
|
||||
//
|
||||
// Deliberately not clamped to >= 0 here. A negative blind is a different mistake
|
||||
// from a mistyped one, and the callers that care already clamp (the rake to 0..100,
|
||||
// the seat count to 2..24); folding it in would silently turn a typed "-5" into
|
||||
// something the dealer did not ask for.
|
||||
readAmount(text) {
|
||||
if (text == null) return null;
|
||||
const trimmed = String(text).trim();
|
||||
if (trimmed === "") return null;
|
||||
const value = Number(trimmed);
|
||||
return Number.isFinite(value) ? this.round(value) : null;
|
||||
},
|
||||
|
||||
fromCents(cents) {
|
||||
return cents / 100;
|
||||
},
|
||||
@@ -149,11 +174,20 @@ const Money = {
|
||||
//
|
||||
// Only number boxes. A name or a note is a thing you edit, and selecting all of
|
||||
// it on focus would be the same mistake pointing the other way.
|
||||
//
|
||||
// What makes a box a number box is now the inputmode rather than the type: nothing
|
||||
// in the tool is type="number" any more (see the comment above the blinds in
|
||||
// TexasHoldem.cshtml). Both inputmodes count. "numeric" is the seat count and the
|
||||
// chip count — whole-number boxes that open pre-filled exactly like the money ones,
|
||||
// and which this used to catch through their old type. Reading only "decimal" would
|
||||
// have quietly dropped them, so it reads both.
|
||||
selectsOnFocus(field) {
|
||||
if (!field || field.tagName !== "INPUT") return false;
|
||||
const type = String(field.type || "").toLowerCase();
|
||||
if (type === "number") return true;
|
||||
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
|
||||
if (type !== "text") return false;
|
||||
const mode = String(field.inputMode || "").toLowerCase();
|
||||
return mode === "decimal" || mode === "numeric";
|
||||
},
|
||||
|
||||
// Would a wheel over this field change what it says?
|
||||
@@ -166,6 +200,12 @@ const Money = {
|
||||
//
|
||||
// A predicate rather than a listener so the rule can be tested without a browser,
|
||||
// the same way selectsOnFocus is. main.js does the blurring.
|
||||
//
|
||||
// As of the move off type="number" this answers false for every field in the tool,
|
||||
// and that is the point rather than a bug: a text box with an inputmode is not a
|
||||
// spinbutton, so a wheel over it cannot change it and there is nothing left to
|
||||
// defend. Kept, tests and all, because it is the guard that makes that safe to rely
|
||||
// on — reintroduce a type="number" anywhere and this starts firing again on its own.
|
||||
wheelWouldEdit(field, activeElement) {
|
||||
// The tag check is belt and braces and known to be so: nothing but an <input>
|
||||
// carries type="number", so the line below already excludes every textarea,
|
||||
|
||||
@@ -24,6 +24,32 @@ const PotCalculator = {
|
||||
return entries.filter(e => !e.folded && e.remaining > this.EPSILON);
|
||||
},
|
||||
|
||||
// The most this player can be handed if they win the round outright.
|
||||
//
|
||||
// resolveRound caps the pool at the winner's own remaining contribution and then
|
||||
// takes min(remaining, cap) from every entry, dead money included — so the ceiling
|
||||
// is that sum. It is not the player's own contribution, which is what the wizard
|
||||
// used to print beside each name under the words "up to": with three players in
|
||||
// for $250 each the rows all read "up to $250" while the prompt above them said
|
||||
// $765 was in play, and $765 is what the winner was actually paid. A dealer who
|
||||
// trusted the row would have short-paid by two thirds.
|
||||
ceilingFor(entries, id) {
|
||||
const winner = entries.find(e => e.id === id);
|
||||
if (!winner || winner.remaining <= this.EPSILON) return 0;
|
||||
return Money.round(entries.reduce(
|
||||
(sum, e) => sum + Math.min(e.remaining, winner.remaining), 0));
|
||||
},
|
||||
|
||||
// The same number, but only when it is worth saying: a ceiling equal to everything
|
||||
// on the table is the prompt's own figure repeated once per row, which is ink that
|
||||
// carries no data. It appears exactly when somebody is in for less than the rest —
|
||||
// which is the one time a dealer needs to be told, because it is the hand where
|
||||
// picking that winner leaves a side pot still to award.
|
||||
bindingCeilingFor(entries, id) {
|
||||
const ceiling = this.ceilingFor(entries, id);
|
||||
return ceiling < this.totalPot(entries) - this.EPSILON ? ceiling : null;
|
||||
},
|
||||
|
||||
// Splits the money on the table into the main pot and any side pots, with who can
|
||||
// win each. Read-only — this is what a dealer says out loud before the showdown
|
||||
// ("main pot four-eighty, side pot ninety between seats 3 and 5"), which is the
|
||||
|
||||
@@ -108,7 +108,25 @@ const PotWizard = {
|
||||
requestAnimationFrame(update);
|
||||
},
|
||||
|
||||
// Confirming a round replaces the whole step, buttons and all, and the button it
|
||||
// replaces first is the Confirm the dealer just pressed — so on a table with side
|
||||
// pots, paying the main pot dropped focus on the body and the next Tab had to start
|
||||
// over. There is no keeping them where they were here: the control is gone and the
|
||||
// question has changed. The new question is where they should be, so focus lands on
|
||||
// the sentence asking it — "that side pot still leaves $240 unassigned, who's next
|
||||
// best?" — rather than on the button that answers it.
|
||||
renderStepInto() {
|
||||
const modal = document.getElementById("potWizardModal");
|
||||
Modal.preservingFocus(modal, () => this.paintStep(), () => {
|
||||
const asking = modal.querySelector("#potWizardBody .potWizardPrompt")
|
||||
|| document.getElementById("potWizardTitle");
|
||||
if (!asking) return;
|
||||
asking.tabIndex = -1;
|
||||
asking.focus({ preventScroll: true });
|
||||
});
|
||||
},
|
||||
|
||||
paintStep() {
|
||||
// Auto-resolve any round where only one player is left standing — nothing to ask.
|
||||
let contenders = PotCalculator.getContenders(this.entries);
|
||||
while (contenders.length === 1) {
|
||||
@@ -218,10 +236,18 @@ const PotWizard = {
|
||||
}
|
||||
label.appendChild(text);
|
||||
|
||||
const upTo = document.createElement("span");
|
||||
upTo.className = "potWizardChoiceUpTo";
|
||||
upTo.textContent = `up to ${Money.format(c.remaining)}`;
|
||||
label.appendChild(upTo);
|
||||
// What this row costs if it is the one ticked — but only on the rows where
|
||||
// that is less than the figure in the prompt above. On a pot everybody
|
||||
// covered it was the same number three times, and it was the wrong number:
|
||||
// each player's own stake rather than what winning pays them. See
|
||||
// PotCalculator.bindingCeilingFor.
|
||||
const ceiling = PotCalculator.bindingCeilingFor(this.entries, c.id);
|
||||
if (ceiling !== null) {
|
||||
const upTo = document.createElement("span");
|
||||
upTo.className = "potWizardChoiceUpTo";
|
||||
upTo.textContent = `up to ${Money.format(ceiling)}`;
|
||||
label.appendChild(upTo);
|
||||
}
|
||||
|
||||
// Cards sit apart from the name rather than inside it, so a dealer reading
|
||||
// a row across the table sees the hand, not a sentence.
|
||||
|
||||
@@ -21,6 +21,27 @@ const QuickSplit = {
|
||||
mode: "even",
|
||||
entries: [],
|
||||
|
||||
// The first count the preset chips don't cover. Below it the chips are the control;
|
||||
// at it and above, one chip opens a stepper and the stepper is the control. The
|
||||
// number is never stated by two of them at once — it used to be stated by three,
|
||||
// with chip "2" lit gold, "2 ways" in a bar under it, and "Each of 2 players gets"
|
||||
// in the panel above.
|
||||
overflowFrom() {
|
||||
return this.WAY_CHOICES[this.WAY_CHOICES.length - 1] + 1;
|
||||
},
|
||||
|
||||
clampWays(count) {
|
||||
return Math.max(this.WAY_CHOICES[0], Math.min(this.MAX_WAYS, count));
|
||||
},
|
||||
|
||||
// A pot is chopped between the players who can win it, and there is no such thing
|
||||
// as chopping it between more of them than there are. This used to read `Math.max`,
|
||||
// which let a side pot only two players were eligible for be split ten ways at $12
|
||||
// each — an answer to a question nobody at the table could have asked.
|
||||
chopCeiling(contenderCount) {
|
||||
return Math.max(2, Math.min(contenderCount, this.MAX_WAYS));
|
||||
},
|
||||
|
||||
mount() {
|
||||
this.potInput = document.getElementById("potInput");
|
||||
this.clearPotBtn = document.getElementById("clearPotBtn");
|
||||
@@ -86,10 +107,11 @@ const QuickSplit = {
|
||||
renderWayChoices() {
|
||||
const row = document.getElementById("waysChoices");
|
||||
row.innerHTML = "";
|
||||
const beyond = this.ways >= this.overflowFrom();
|
||||
|
||||
this.WAY_CHOICES.forEach(count => {
|
||||
row.appendChild(
|
||||
this.makeChoiceButton(String(count), this.ways === count, () => {
|
||||
this.makeChoiceButton(String(count), !beyond && this.ways === count, () => {
|
||||
this.ways = count;
|
||||
this.renderWayChoices();
|
||||
this.update();
|
||||
@@ -97,34 +119,65 @@ const QuickSplit = {
|
||||
);
|
||||
});
|
||||
|
||||
// Beyond a five-way chop it's rare enough to hide behind a stepper rather than
|
||||
// spend a row of buttons on it.
|
||||
const more = document.createElement("div");
|
||||
more.className = "waysStepper";
|
||||
// Beyond a five-way chop it's rare enough not to be worth a chip each. One more
|
||||
// chip is the door to the rest; it reads "6+" whatever the count behind it is,
|
||||
// because the stepper is where the count is written. Tapping it while it is
|
||||
// already open does nothing — it is the fifth option of a group, not a toggle.
|
||||
const door = this.makeChoiceButton(`${this.overflowFrom()}+`, beyond, () => {
|
||||
if (beyond) return;
|
||||
this.ways = this.overflowFrom();
|
||||
this.renderWayChoices();
|
||||
this.update();
|
||||
});
|
||||
door.setAttribute("aria-label", `${this.overflowFrom()} or more`);
|
||||
row.appendChild(door);
|
||||
|
||||
const minus = this.makeStepButton("−", () => this.stepWays(-1));
|
||||
const plus = this.makeStepButton("+", () => this.stepWays(1));
|
||||
const readout = document.createElement("span");
|
||||
readout.className = "waysReadout";
|
||||
readout.textContent = `${this.ways} ways`;
|
||||
|
||||
more.appendChild(minus);
|
||||
more.appendChild(readout);
|
||||
more.appendChild(plus);
|
||||
row.appendChild(more);
|
||||
if (beyond) row.appendChild(this.buildWaysStepper());
|
||||
},
|
||||
|
||||
makeStepButton(label, onPick) {
|
||||
// Six to twelve, which is exactly what the door promised. The floor is 6 rather
|
||||
// than 2 because the chips below it are one tap away and already on screen — and
|
||||
// because stepping down out of the stepper would pull the stepper out from under
|
||||
// the finger that was pressing it.
|
||||
buildWaysStepper() {
|
||||
const stepper = document.createElement("div");
|
||||
stepper.className = "waysStepper";
|
||||
stepper.setAttribute("role", "group");
|
||||
stepper.setAttribute("aria-label", "How many ways");
|
||||
|
||||
stepper.appendChild(this.makeStepButton("−", () => this.stepWays(-1), {
|
||||
label: "One fewer way",
|
||||
disabled: this.ways <= this.overflowFrom(),
|
||||
}));
|
||||
|
||||
const readout = document.createElement("span");
|
||||
readout.className = "waysReadout";
|
||||
readout.textContent = String(this.ways);
|
||||
stepper.appendChild(readout);
|
||||
|
||||
stepper.appendChild(this.makeStepButton("+", () => this.stepWays(1), {
|
||||
label: "One more way",
|
||||
disabled: this.ways >= this.MAX_WAYS,
|
||||
}));
|
||||
|
||||
return stepper;
|
||||
},
|
||||
|
||||
makeStepButton(label, onPick, options) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "stepBtn";
|
||||
btn.textContent = label;
|
||||
if (options && options.label) btn.setAttribute("aria-label", options.label);
|
||||
// A step that can't happen says so. Both ends of both steppers on this screen
|
||||
// used to look exactly like the middle and swallow the tap in silence.
|
||||
btn.disabled = !!(options && options.disabled);
|
||||
btn.addEventListener("click", onPick);
|
||||
return btn;
|
||||
},
|
||||
|
||||
stepWays(delta) {
|
||||
this.ways = Math.max(2, Math.min(this.MAX_WAYS, this.ways + delta));
|
||||
this.ways = this.clampWays(this.ways + delta);
|
||||
this.renderWayChoices();
|
||||
this.update();
|
||||
},
|
||||
@@ -205,7 +258,8 @@ const QuickSplit = {
|
||||
// The per-pot ways live on the row rather than in `this.ways`, so opening a chop on
|
||||
// the main pot doesn't change what the side pot below it is saying.
|
||||
addChopControl(row, amount, contenderCount) {
|
||||
let ways = contenderCount;
|
||||
const ceiling = this.chopCeiling(contenderCount);
|
||||
let ways = ceiling;
|
||||
let open = false;
|
||||
|
||||
const toggle = document.createElement("button");
|
||||
@@ -225,19 +279,24 @@ const QuickSplit = {
|
||||
const result = SplitCalculator.split(amount, ways, this.chip);
|
||||
detail.innerHTML = "";
|
||||
|
||||
const stepper = document.createElement("div");
|
||||
stepper.className = "unevenChopWays";
|
||||
stepper.appendChild(this.makeStepButton("−", () => {
|
||||
if (ways > 2) { ways -= 1; draw(); }
|
||||
}));
|
||||
const label = document.createElement("span");
|
||||
label.className = "unevenChopWaysLabel";
|
||||
label.textContent = `${ways} ways`;
|
||||
stepper.appendChild(label);
|
||||
stepper.appendChild(this.makeStepButton("+", () => {
|
||||
if (ways < Math.max(contenderCount, this.MAX_WAYS)) { ways += 1; draw(); }
|
||||
}));
|
||||
detail.appendChild(stepper);
|
||||
// Two eligible players is two ways and nothing else, so the stepper is left
|
||||
// out rather than shown with both ends dead — which is the common shape, a
|
||||
// side pot usually coming down to the two who could still call.
|
||||
if (ceiling > 2) {
|
||||
const stepper = document.createElement("div");
|
||||
stepper.className = "unevenChopWays";
|
||||
stepper.appendChild(this.makeStepButton("−", () => {
|
||||
if (ways > 2) { ways -= 1; draw(); }
|
||||
}, { label: "One fewer way", disabled: ways <= 2 }));
|
||||
const label = document.createElement("span");
|
||||
label.className = "unevenChopWaysLabel";
|
||||
label.textContent = `${ways} ways`;
|
||||
stepper.appendChild(label);
|
||||
stepper.appendChild(this.makeStepButton("+", () => {
|
||||
if (ways < ceiling) { ways += 1; draw(); }
|
||||
}, { label: "One more way", disabled: ways >= ceiling }));
|
||||
detail.appendChild(stepper);
|
||||
}
|
||||
|
||||
const each = document.createElement("p");
|
||||
each.className = "unevenChopEach";
|
||||
@@ -368,4 +427,10 @@ const QuickSplit = {
|
||||
},
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => QuickSplit.mount());
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("DOMContentLoaded", () => QuickSplit.mount());
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = QuickSplit;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const Render = {
|
||||
this.lastHistorySignature = null;
|
||||
this.handTimeCache.clear();
|
||||
this.watchSeatSheetHeight();
|
||||
this.watchFieldExits();
|
||||
this.renderAll();
|
||||
},
|
||||
|
||||
@@ -113,21 +114,79 @@ const Render = {
|
||||
});
|
||||
},
|
||||
|
||||
// ---- Writing a value into a field somebody might be standing in ----
|
||||
//
|
||||
// A render says what the state is. It is not entitled to say what somebody is
|
||||
// half-way through typing, and when it did, it ate the decimal point.
|
||||
//
|
||||
// The six setup fields are wired `input` -> save -> renderAll, and renderAll comes
|
||||
// back round through renderSetupFields and assigns `.value`. Type "0.25" into the
|
||||
// small blind and the fourth keystroke is not the one that breaks it; the "." is:
|
||||
//
|
||||
// "0" -> reads "0" -> state 0 -> written back "0" (no change, fine)
|
||||
// "0." -> reads "0" -> state 0 -> written back "0" <- the "." is gone
|
||||
// then -> caret is at 0, because assigning .value to a number input parks it
|
||||
// at the start (number inputs do not expose selectionStart at all)
|
||||
// "2" -> "20"
|
||||
// "5" -> "250"
|
||||
//
|
||||
// A dealer setting $0.25/$0.50 blinds got a table playing 250/500, silently, with
|
||||
// every pot for the rest of the night computed off it. Measured on all six:
|
||||
// 0.25->250, 0.50->500, 12.50->5012, 2.5->52 (a 52% rake), 7.50->507, 0.75->750.
|
||||
//
|
||||
// So: never write into the control that has focus. It is the one place where the
|
||||
// person knows better than the state does, and the state is a keystroke behind them
|
||||
// anyway. Everything they type still lands in state through the `input` handler, so
|
||||
// skipping the write-back costs nothing and is not a deferral of it.
|
||||
//
|
||||
// This guards every control rather than the six that were caught, because the next
|
||||
// field wired to a re-render is the next one to eat a decimal point, and there is
|
||||
// nothing about it at the call site to warn whoever adds it.
|
||||
setField(el, value) {
|
||||
if (!el) return;
|
||||
if (typeof document !== "undefined" && el === document.activeElement) return;
|
||||
const next = value == null ? "" : String(value);
|
||||
// Assigning an identical value still moves the caret in some engines, so the
|
||||
// compare is part of the guard rather than an optimisation.
|
||||
if (el.value !== next) el.value = next;
|
||||
},
|
||||
|
||||
setToggle(el, on) {
|
||||
if (!el) return;
|
||||
if (typeof document !== "undefined" && el === document.activeElement) return;
|
||||
if (el.checked !== !!on) el.checked = !!on;
|
||||
},
|
||||
|
||||
// The other half of the rule above: a field that was skipped because somebody was
|
||||
// standing in it has to catch up when they step out, or it keeps showing what they
|
||||
// typed after the state has moved on (a blind that got clamped, say). Bound once,
|
||||
// on the document, because the controls it covers are rebuilt from templates.
|
||||
watchFieldExits() {
|
||||
if (this.fieldExitWatcher || typeof document === "undefined") return;
|
||||
this.fieldExitWatcher = event => {
|
||||
const el = event.target;
|
||||
if (!el || !el.matches || !el.matches("input, select, textarea")) return;
|
||||
// After the blur has settled, so activeElement is no longer this field.
|
||||
setTimeout(() => this.renderSetupFields(), 0);
|
||||
};
|
||||
document.addEventListener("focusout", this.fieldExitWatcher);
|
||||
},
|
||||
|
||||
renderSetupFields() {
|
||||
document.getElementById("seatCountInput").value = this.state.seatCount;
|
||||
document.getElementById("directionSelect").value = this.state.direction;
|
||||
document.getElementById("basePotInput").value = this.state.basePotAmount ?? "";
|
||||
document.getElementById("dedicatedDealerToggle").checked = !!this.state.dedicatedDealer;
|
||||
document.getElementById("trackBoardToggle").checked = !!this.state.trackBoard;
|
||||
document.getElementById("keepScreenAwakeToggle").checked = this.state.keepScreenAwake !== false;
|
||||
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
|
||||
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
|
||||
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
|
||||
this.setField(document.getElementById("seatCountInput"), this.state.seatCount);
|
||||
this.setField(document.getElementById("directionSelect"), this.state.direction);
|
||||
this.setField(document.getElementById("basePotInput"), this.state.basePotAmount ?? "");
|
||||
this.setToggle(document.getElementById("dedicatedDealerToggle"), this.state.dedicatedDealer);
|
||||
this.setToggle(document.getElementById("trackBoardToggle"), this.state.trackBoard);
|
||||
this.setToggle(document.getElementById("keepScreenAwakeToggle"), this.state.keepScreenAwake !== false);
|
||||
this.setField(document.getElementById("smallBlindInput"), this.state.smallBlind ?? "");
|
||||
this.setField(document.getElementById("bigBlindInput"), this.state.bigBlind ?? "");
|
||||
this.setField(document.getElementById("defaultStackInput"), this.state.defaultStack ?? "");
|
||||
|
||||
const rake = this.state.rake || {};
|
||||
document.getElementById("rakePercentInput").value = rake.percent || "";
|
||||
document.getElementById("rakeCapInput").value = rake.cap ?? "";
|
||||
document.getElementById("rakeNoFlopInput").checked = rake.noFlopNoDrop !== false;
|
||||
this.setField(document.getElementById("rakePercentInput"), rake.percent || "");
|
||||
this.setField(document.getElementById("rakeCapInput"), rake.cap ?? "");
|
||||
this.setToggle(document.getElementById("rakeNoFlopInput"), rake.noFlopNoDrop !== false);
|
||||
|
||||
// What the house has actually taken tonight, said where the rake is set rather
|
||||
// than only in the bank check — a dealer who changes the percentage mid-session
|
||||
@@ -161,8 +220,8 @@ const Render = {
|
||||
// on and stays away otherwise — nobody waiting, no notes, no hands yet — because a
|
||||
// row reading "Nobody waiting" all night is the clutter this is here to remove, and
|
||||
// eight rows that always say something are eight rows that say nothing again.
|
||||
PANEL_GLANCES: ["glanceTableSetup", "glanceStandings", "glanceWaiting",
|
||||
"glanceHands", "glanceChips", "glanceNotes"],
|
||||
PANEL_GLANCES: ["glanceTableSetup", "glanceWhileYouDeal", "glanceStandings",
|
||||
"glanceWaiting", "glanceHands", "glanceChips", "glanceNotes"],
|
||||
|
||||
// Pure, so the wording can be tested without a page to hang it on. Every value is
|
||||
// read off the state the panel below it renders from, never off the panel's own
|
||||
@@ -204,8 +263,24 @@ const Render = {
|
||||
// over an empty table would be answering for a count that was never taken.
|
||||
const nothingBanked = bank.cashIn === 0 && bank.chipsInPlay === 0;
|
||||
|
||||
// The three switches behind "While you deal", which is shut unless somebody
|
||||
// opens it: a switch nobody can see is a switch nobody remembers turning on, and
|
||||
// a dealer wondering why a card sheet keeps appearing should be told where it
|
||||
// lives without having to go looking.
|
||||
//
|
||||
// One of them, not all three. This row is 44px of a 320px column with a label
|
||||
// already on it, and the three joined run to 49 characters — which ellipsizes
|
||||
// into "House dealer · Asking for c…", a row that has told nobody anything. So
|
||||
// it names the one most likely to be the surprise, and opening the panel shows
|
||||
// the rest. Keeping the screen on is the only one that ships on, so it is its
|
||||
// absence that gets said.
|
||||
const dealing = state.trackBoard ? "Asking for cards"
|
||||
: state.dedicatedDealer ? "House dealer"
|
||||
: state.keepScreenAwake === false ? "Screen may sleep" : "";
|
||||
|
||||
return {
|
||||
glanceTableSetup: glance(`${state.seatCount} seats${blinds}`),
|
||||
glanceWhileYouDeal: glance(dealing),
|
||||
glanceStandings: nothingBanked ? glance("")
|
||||
: bank.balanced ? glance("Balanced", "ok")
|
||||
: glance(`Off by ${Money.format(Math.abs(bank.difference))}`, "warn"),
|
||||
@@ -246,6 +321,30 @@ const Render = {
|
||||
if (!misdeal) return;
|
||||
misdeal.hidden = !this.state.bettingOpen;
|
||||
|
||||
// "Next hand" names three different things in this tool: the bar's green button
|
||||
// deals one, the pot wizard finishes on "Start Next Hand", and this header
|
||||
// shortcut — the only one of the three that does not start a hand. Between hands
|
||||
// it moves the button a second time, so the seat that just paid the big blind
|
||||
// pays it again, and it said so in a toast that opens with the same two words
|
||||
// the wizard just used. Measured: wizard → hand 6, button seat 2; press this →
|
||||
// still hand 6, button seat 3, no confirmation asked.
|
||||
//
|
||||
// The mechanism is right — a dealer does pass the button along on a hand nobody
|
||||
// played — so what changes is the name. A command is named for the state the
|
||||
// system moves into (NN/g, Button States: "Show Tab Bar" / "Hide Tab Bar"), and
|
||||
// between hands the state it moves into is a moved button, not a new hand.
|
||||
const nextHand = document.getElementById("nextHandBtn");
|
||||
if (nextHand) {
|
||||
const live = !!this.state.bettingOpen;
|
||||
nextHand.textContent = live ? "Next Hand ⟳" : "Move Button ⟳";
|
||||
// Written every render, ahead of the borrow-and-hand-back below: that cache
|
||||
// is filled once from the markup, and a title that depends on the hand would
|
||||
// freeze on whichever one happened to be true at load.
|
||||
nextHand.dataset.titleWhenUsable = live
|
||||
? "End this hand and move the button, small blind and big blind to the next occupied seat"
|
||||
: "Pass the button on again without dealing — for a hand nobody played";
|
||||
}
|
||||
|
||||
// On a table nobody is sitting at, Next Hand has no button to move and Resolve
|
||||
// Pot has no pot, so both are visibly not-yet rather than sitting there looking
|
||||
// ready.
|
||||
@@ -260,12 +359,18 @@ const Render = {
|
||||
// here is only the part about what a control can do — which is still worth
|
||||
// saying, because a shortcut that cannot work yet should not look ready.
|
||||
//
|
||||
// Disabled rather than hidden: hiding them would move the header the moment
|
||||
// the first player is named, and a control that vanishes teaches nothing about
|
||||
// what it was for.
|
||||
// Dimmed rather than hidden: hiding them would move the header the moment the
|
||||
// first player is named, and a control that vanishes teaches nothing about what
|
||||
// it was for.
|
||||
//
|
||||
// Dimmed rather than `disabled`, because there is a reason and it was only ever
|
||||
// readable with a mouse. `disabled` took both of these out of the tab order, so
|
||||
// the sentence hung off them never reached a screen reader, and it swallowed the
|
||||
// tap on the device this tool is built for — leaving a dealer's first screen
|
||||
// holding two grey buttons that answered nothing when pressed. See Unavailable.
|
||||
const nothingToRunYet = !this.state.seats.some(seat => HoldemState.seatInPlay(seat));
|
||||
[["resolvePotBtn", "Nothing to resolve — nobody is at the table yet"],
|
||||
["nextHandBtn", "Nothing to move on from — nobody is at the table yet"]]
|
||||
[["resolvePotBtn", "Nothing to resolve yet — seat a player first."],
|
||||
["nextHandBtn", "Nothing to move on from — seat a player first."]]
|
||||
.forEach(([id, why]) => {
|
||||
const button = document.getElementById(id);
|
||||
if (!button) return;
|
||||
@@ -274,7 +379,7 @@ const Render = {
|
||||
if (button.dataset.titleWhenUsable === undefined) {
|
||||
button.dataset.titleWhenUsable = button.getAttribute("title") || "";
|
||||
}
|
||||
button.disabled = nothingToRunYet;
|
||||
Unavailable.set(button, nothingToRunYet, why);
|
||||
const title = nothingToRunYet ? why : button.dataset.titleWhenUsable;
|
||||
if (title) button.setAttribute("title", title);
|
||||
else button.removeAttribute("title");
|
||||
@@ -631,10 +736,14 @@ const Render = {
|
||||
SEAT_CLEARANCE_PX: 4,
|
||||
// And between a puck's inner edge and the chip sitting in front of it.
|
||||
BET_CLEARANCE_PX: 5,
|
||||
// How far a blind badge hangs off the puck it belongs to — the `top: -9px` and
|
||||
// `bottom: -9px` in holdem.css. It points away from the table at both ends, so it
|
||||
// takes no room between two seats; what it must not do is reach *sideways*, which is
|
||||
// what seatFootprint.test.js holds the stylesheet to.
|
||||
// How far a marker hangs off the puck it belongs to — the `top` and `bottom` on
|
||||
// .blindBadge and on .dealerChip in holdem.css, which seatFootprint.test.js holds this
|
||||
// to. It points away from the table at both ends; what it must not do is reach
|
||||
// *sideways*, which is the other thing that test checks.
|
||||
//
|
||||
// Charged once per seat, not once per marker, and the two markers are why that is
|
||||
// still right: they share one edge and sit at opposite ends of it, so a seat wearing
|
||||
// both is no taller than a seat wearing either.
|
||||
SEAT_BADGE_OVERHANG_PX: 5,
|
||||
|
||||
// How much space two neighbouring seats need between their centres.
|
||||
@@ -792,29 +901,36 @@ const Render = {
|
||||
return widths;
|
||||
},
|
||||
|
||||
// What the table has changed about the shape of its pucks. Anything that can make a
|
||||
// name wrap to a second line belongs here, because the height that comes back from
|
||||
// measuring is what the ring is sized against.
|
||||
// What the table has changed about the shape of its pucks — which is now almost
|
||||
// nothing, and that is the point.
|
||||
//
|
||||
// The hole-card line and the amount pill belong here for the same reason, now that
|
||||
// an empty one collapses instead of holding its space: a puck with either of them
|
||||
// filled is about 15px taller than the same puck without, and a ring still sized
|
||||
// from the measurement taken before the first chips went in is sized for a puck
|
||||
// that no longer exists.
|
||||
// This used to name every player's name, the dealer's seat, whether anybody had hole
|
||||
// cards and whether anybody had chips out, because each of those could change how
|
||||
// tall a puck was and the ring is sized against the tallest puck on it. Four things,
|
||||
// three of which flip during an ordinary hand, and every flip is a ten-candidate
|
||||
// re-search: ten widths applied and read back, ten forced layouts, on a phone, while
|
||||
// somebody is waiting to know whether the bet is called.
|
||||
//
|
||||
// Booleans over the whole table rather than a list per seat, because that is what
|
||||
// the measurement is — measuredSeatBox takes the tallest puck on the ring, so what
|
||||
// matters is whether *anybody* has one. A boolean also flips about twice a hand
|
||||
// rather than on every action, and each flip costs ten forced layouts, which is the
|
||||
// expense this cache exists to avoid.
|
||||
// A puck's height no longer depends on any of them. The name gets one line whatever
|
||||
// it says, the amount pill holds its row whether or not there is an amount in it, and
|
||||
// the button's chip changes a padding rather than a line count — see the row-height
|
||||
// tokens in holdem.css. What is left is the hole-card row, which still collapses when
|
||||
// the table isn't tracking cards, because that is a decision a table makes once and
|
||||
// not a thing that changes under a dealer's thumb.
|
||||
//
|
||||
// A boolean over the whole table rather than a list per seat, because that is what
|
||||
// the measurement is: measuredSeatBox takes the tallest puck on the ring, so what
|
||||
// matters is whether *anybody* has one.
|
||||
//
|
||||
// Being wrong here is a stale ring rather than a wrong one, and it would not stay
|
||||
// wrong: renderSeats measures the finished pucks against the box the search settled
|
||||
// on and asks again if they no longer match. That check is what catches a fifth thing
|
||||
// nobody thought of, and it is what catches the first render of all — where the pucks
|
||||
// are built empty and the search that used to run against them was measuring nine
|
||||
// blank boxes and caching the answer under this table's key.
|
||||
seatShapeSignature() {
|
||||
if (!this.state) return "";
|
||||
const seats = this.state.seats;
|
||||
const anyCards = seats.some(p => (p.cards || []).some(Boolean));
|
||||
const anyAmount = seats.some(p => Betting.handTotal(p) > 0 ||
|
||||
HoldemState.computeContribution(p, this.state.chipLegend, this.state.basePotAmount) > 0);
|
||||
return `${seats.map(p => p.name || "").join("")}|${this.state.dealerSeatIndex}` +
|
||||
`|${anyCards ? "c" : ""}${anyAmount ? "a" : ""}`;
|
||||
return this.state.seats.some(p => (p.cards || []).some(Boolean)) ? "c" : "";
|
||||
},
|
||||
|
||||
// Which width the pucks get, and how wide a ring they sit on.
|
||||
@@ -871,14 +987,19 @@ const Render = {
|
||||
this.applySeatWidth(layer, width);
|
||||
const puck = this.measuredSeatBox();
|
||||
if (!puck) {
|
||||
// Nothing laid out yet — the very first render. Take the widest and let
|
||||
// the resize that follows do this properly.
|
||||
const radii = this.seatRingRadii();
|
||||
if (key) this.seatLayoutCache = { key, width, radii };
|
||||
return { width, radii };
|
||||
// Nothing on the felt to measure at all. Take the widest and say nothing
|
||||
// about it — no `puck`, so renderSeats' second pass knows this answer was
|
||||
// not measured and asks again once there is something to measure.
|
||||
//
|
||||
// It used to cache this one, on the reasoning that "the resize that
|
||||
// follows" would put it right. Nothing resizes a phone. A table opened
|
||||
// upright kept a ring measured before it had any seats on it until the
|
||||
// dealer turned the phone sideways and back.
|
||||
return { width, radii: this.seatRingRadii() };
|
||||
}
|
||||
candidates.push({
|
||||
width,
|
||||
puck,
|
||||
contained: this.seatRingRadii(puck),
|
||||
footprint: this.seatFootprint(puck),
|
||||
tooTall: this.tooTallForTheTable(),
|
||||
@@ -896,7 +1017,10 @@ const Render = {
|
||||
rx: candidate.contained.rx + (this.RING_MAX_RX - candidate.contained.rx) * t,
|
||||
ry: candidate.contained.ry + (this.RING_MAX_RY - candidate.contained.ry) * t,
|
||||
};
|
||||
if (fits(candidate, radii)) { chosen = { width: candidate.width, radii }; break; }
|
||||
if (fits(candidate, radii)) {
|
||||
chosen = { width: candidate.width, radii, puck: candidate.puck };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,7 +1029,11 @@ const Render = {
|
||||
// so out loud rather than the layout quietly papering over it.
|
||||
if (!chosen) {
|
||||
const last = candidates[candidates.length - 1];
|
||||
chosen = { width: last.width, radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY } };
|
||||
chosen = {
|
||||
width: last.width,
|
||||
radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY },
|
||||
puck: last.puck,
|
||||
};
|
||||
}
|
||||
|
||||
this.applySeatWidth(layer, chosen.width);
|
||||
@@ -1408,7 +1536,7 @@ const Render = {
|
||||
// these read the layout back, so they are taken once, here, before the loop
|
||||
// starts writing to it: asked per seat they would force the browser to
|
||||
// recompute the layout on every iteration.
|
||||
const { radii } = this.resolveSeatLayout(count);
|
||||
let { radii, puck } = this.resolveSeatLayout(count);
|
||||
const aspect = this.feltAspect();
|
||||
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
|
||||
// A button on an empty chair normally means the table has drifted and wants
|
||||
@@ -1429,12 +1557,10 @@ const Render = {
|
||||
const dealerChip = node.querySelector(".dealerChip");
|
||||
const hasButton = index === dealerIndex;
|
||||
dealerChip.classList.toggle("active", hasButton);
|
||||
// Marked on the seat as well as the chip, so the puck's own text can keep
|
||||
// out from under it. A name is centred across the whole puck and the chip
|
||||
// sits in the corner on a layer above: measured, "Christopher" on the
|
||||
// button lost 20px of its 64px box to the chip and showed no ellipsis,
|
||||
// because the text believed it had the room it was painted out of.
|
||||
node.classList.toggle("seatHasButton", hasButton);
|
||||
// The seat used to be marked too — `seatHasButton` — so the puck's text could
|
||||
// keep out from under a chip that sat in its corner. The chip is on the rim
|
||||
// with the blind badges now and there is nothing to keep out of, so the seat
|
||||
// no longer needs to know which of nine chairs the chip is on.
|
||||
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
|
||||
// Every one of these announced itself as "D", so a screen reader heard a row
|
||||
// of nine identical buttons and couldn't tell which seat held the real one.
|
||||
@@ -1475,8 +1601,34 @@ const Render = {
|
||||
});
|
||||
|
||||
this.fitSeatNames(layer);
|
||||
// The names have just changed how tall some of these pucks are, so this comes
|
||||
// after them: a two-line name shortened to one line is 15px of height back.
|
||||
this.fitAmounts(this.seatAmountCells(layer));
|
||||
|
||||
// The pucks are only now what they will be drawn as, and the ring was sized
|
||||
// before any of it went in. Usually those agree — a puck's height is a stack of
|
||||
// rows the stylesheet holds open, and none of the writing above changes it.
|
||||
//
|
||||
// Twice they don't. The first render of all builds nine empty pucks and then
|
||||
// fills them, so a search run against that measured blank boxes; and any
|
||||
// assumption in seatShapeSignature that turns out to be wrong lands here too.
|
||||
// Both used to be permanent, because the answer was cached under a key naming a
|
||||
// table the measurement had never seen: a phone that never resizes kept a ring
|
||||
// sized for empty seats for the whole session.
|
||||
//
|
||||
// So the finished pucks are measured against the ones the ring was built from,
|
||||
// and if they have moved the search is asked again with the real thing in front
|
||||
// of it. Once. The second answer is cached and the render after this one reads it
|
||||
// back, which is what stops this being a loop.
|
||||
if (this.seatBoxMoved(puck)) {
|
||||
this.seatLayoutCache = null;
|
||||
({ radii, puck } = this.resolveSeatLayout(count));
|
||||
for (let index = 0; index < layer.childElementCount; index++) {
|
||||
this.placeSeat(layer.children[index], index, aspect, radii);
|
||||
}
|
||||
this.fitSeatNames(layer);
|
||||
// The puck is a different width than the one these were fitted to.
|
||||
this.fitAmounts(this.seatAmountCells(layer));
|
||||
}
|
||||
|
||||
const settled = this.settleSeatsOnTable(layer, aspect, radii);
|
||||
|
||||
// Kept so the board can ask for the chips to be laid out again if placing it
|
||||
@@ -1485,6 +1637,23 @@ const Render = {
|
||||
this.renderBets(aspect, settled);
|
||||
},
|
||||
|
||||
// Are the pucks on the felt still the puck the ring was measured for?
|
||||
//
|
||||
// A pixel of slack, the same as settleSeatsOnTable's, because the two measurements
|
||||
// are taken at different moments and a sub-pixel difference between them is a
|
||||
// rounding artefact rather than a table that has changed shape. Without it this
|
||||
// would ask the search to run again on every render for the rest of the session.
|
||||
//
|
||||
// No box at all means the search never measured one — the first render, before the
|
||||
// pucks were filled in. That is exactly the case worth asking again about.
|
||||
seatBoxMoved(puck) {
|
||||
if (!puck) return true;
|
||||
const now = this.measuredSeatBox();
|
||||
if (!now) return false;
|
||||
return Math.abs(now.width - puck.width) > this.SEAT_SETTLE_SLACK_PX ||
|
||||
Math.abs(now.height - puck.height) > this.SEAT_SETTLE_SLACK_PX;
|
||||
},
|
||||
|
||||
// One seat, onto the ring.
|
||||
placeSeat(node, index, aspect, radii) {
|
||||
const pos = this.seatPosition(index, this.state.seatCount, aspect, radii);
|
||||
@@ -1572,13 +1741,20 @@ const Render = {
|
||||
//
|
||||
// So a name that overflows drops whole trailing words until it fits: "Mike from
|
||||
// work" becomes "Mike from", then "Mike", and stops there with a real word rather
|
||||
// than a fragment. A single word that still doesn't fit is left to the ellipsis it
|
||||
// always had — there is no honest way to shorten "Christopher" that a dealer
|
||||
// glancing across a table would read faster than "Christo…".
|
||||
// than a fragment.
|
||||
//
|
||||
// Written as three passes over all the seats rather than a loop per seat: each pass
|
||||
// writes to every puck and then reads back from every puck, so the browser lays the
|
||||
// felt out three times whatever the table's size. Interleaved, nine seats would
|
||||
// What is left when there is no word to drop is one word too long for its puck, and
|
||||
// the ellipsis used to be the end of the story: "Christo…", which is a name only
|
||||
// until a Christine sits down. The amounts on the same puck have never accepted that
|
||||
// trade — see fitAmount, which steps a tournament stack's type down rather than
|
||||
// rounding it off — and a misread name is the same kind of failure as a misread
|
||||
// stack. So the last resort before the ellipsis is one step of type. Every name that
|
||||
// has one, not only the ones with a spare word, because "Christopher" is exactly the
|
||||
// case this is for.
|
||||
//
|
||||
// Written as passes over all the seats rather than a loop per seat: each pass writes
|
||||
// to every puck and then reads back from every puck, so the browser lays the felt out
|
||||
// a fixed number of times whatever the table's size. Interleaved, nine seats would
|
||||
// have cost nine layouts, and this runs on every render.
|
||||
fitSeatNames(layer) {
|
||||
if (!layer) return;
|
||||
@@ -1589,21 +1765,37 @@ const Render = {
|
||||
if (!el || !el.textContent) return;
|
||||
const full = (player.name || "").trim();
|
||||
// The tooltip is the whole name whether or not this one gets shortened: a
|
||||
// label that wraps to two clamped lines was already hiding some of it.
|
||||
// label clamped to its one line was already hiding some of it.
|
||||
el.title = full;
|
||||
const words = full.split(/\s+/).filter(Boolean);
|
||||
if (words.length > 1) labels.push({ el, words, kept: words.length });
|
||||
// Put back whatever a previous run of this took off, so what gets measured
|
||||
// below is the name against the puck it has now. Normally a no-op —
|
||||
// updatePuckContent has just written the full name — and not a no-op on the
|
||||
// second pass of a render that had to resolve the ring twice, which is the
|
||||
// one time a puck's width changes underneath a name already fitted to it.
|
||||
if (el.textContent !== full) el.textContent = full;
|
||||
// Cleared before anything is measured, so a name that fitted last render at
|
||||
// a smaller size is measured at the size it is meant to be read at. Left on,
|
||||
// it would be a ratchet: once a puck went small it could never come back.
|
||||
el.classList.remove("puckNameTight");
|
||||
labels.push({ el, words: full.split(/\s+/).filter(Boolean), kept: 0 });
|
||||
});
|
||||
if (!labels.length) return;
|
||||
labels.forEach(label => { label.kept = label.words.length; });
|
||||
|
||||
for (let pass = 0; pass < this.SEAT_NAME_PASSES; pass++) {
|
||||
const shorten = labels.filter(label => label.kept > 1 && this.overflowing(label.el));
|
||||
if (!shorten.length) return;
|
||||
if (!shorten.length) break;
|
||||
shorten.forEach(label => {
|
||||
label.kept = this.dropAWord(label.words, label.kept);
|
||||
label.el.textContent = label.words.slice(0, label.kept).join(" ");
|
||||
});
|
||||
}
|
||||
|
||||
// One read for the whole table, then one write, in that order — the same reason
|
||||
// the loop above is written in passes.
|
||||
labels
|
||||
.filter(label => this.overflowing(label.el))
|
||||
.forEach(label => label.el.classList.add("puckNameTight"));
|
||||
},
|
||||
|
||||
// Words a name can't end on. Dropping "work" off "Mike from work" leaves "Mike
|
||||
@@ -1632,9 +1824,9 @@ const Render = {
|
||||
// four plain words reaches its first in three.
|
||||
SEAT_NAME_PASSES: 3,
|
||||
|
||||
// Past the box in either direction. Both matter: the label clamps to two lines, so
|
||||
// a name too long runs out of height, while a single word too wide for the puck
|
||||
// runs out of width without ever reaching the second line.
|
||||
// Past the box in either direction. Width is the one that answers now that a name
|
||||
// cannot wrap — see .seatPuckName — and height is kept because it costs nothing and
|
||||
// is the honest question for anything else that ever asks this.
|
||||
overflowing(el) {
|
||||
return el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
|
||||
},
|
||||
@@ -1750,11 +1942,27 @@ const Render = {
|
||||
if (onStrip) {
|
||||
if (strip && row.parentElement !== strip) strip.appendChild(row);
|
||||
if (strip) strip.hidden = false;
|
||||
if (centre) { centre.style.maxWidth = ""; centre.style.maxHeight = ""; }
|
||||
if (centre) { centre.style.width = ""; centre.style.maxWidth = ""; centre.style.maxHeight = ""; }
|
||||
} else {
|
||||
if (centre && row.parentElement !== centre) centre.insertBefore(row, centre.firstChild);
|
||||
if (strip) strip.hidden = true;
|
||||
if (centre) centre.style.maxWidth = `${Math.round(width)}px`;
|
||||
if (centre) {
|
||||
// Both, and the width is the half that was missing.
|
||||
//
|
||||
// `max-width` caps a box; it does not claim anything. The centre is a
|
||||
// flex column that sizes to its content, so with 463px of measured room
|
||||
// written into its max-width it went on being 136px wide — and the board
|
||||
// row inside it is `width: 100%` of that, which is a loop that comes to
|
||||
// rest on the cards' own `min-width`. Measured with the board on: the
|
||||
// middle had 294px on an iPad, 463px on a desktop and 542px on a wide
|
||||
// one, and in all three the five community cards drew 24px across. On
|
||||
// the strip, where the row simply gets the width of the page, the same
|
||||
// cards draw 44. They are buttons — a tap on one opens the picker for
|
||||
// that card — so 24px was a little over half the floor this sweep holds
|
||||
// every other control on the felt to.
|
||||
centre.style.width = `${Math.round(width)}px`;
|
||||
centre.style.maxWidth = `${Math.round(width)}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// The chips were laid out against whatever was in the middle of the felt a
|
||||
@@ -1945,7 +2153,11 @@ const Render = {
|
||||
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
||||
const totalEl = node.querySelector(".seatPuckTotal");
|
||||
totalEl.textContent = total > 0 ? Money.format(total) : "";
|
||||
this.fitAmount(totalEl);
|
||||
// The figure as it is meant to be read, kept beside the one that gets drawn.
|
||||
// fitAmounts may take the separators out of what is on screen, and the pass
|
||||
// after it has to start from the grouped figure again or the puck can only ever
|
||||
// get further from it.
|
||||
totalEl.dataset.figure = totalEl.textContent;
|
||||
|
||||
// A player with nothing behind used to leave this line blank, so the felt drew
|
||||
// an all-in exactly like a seat whose stack nobody had entered. On a table with
|
||||
@@ -1956,7 +2168,7 @@ const Render = {
|
||||
const allIn = Betting.isAllIn(this.state, player);
|
||||
stackEl.textContent = empty ? "" : allIn ? "ALL IN" : stack > 0 ? Money.format(stack) : "";
|
||||
stackEl.classList.toggle("seatPuckAllIn", allIn);
|
||||
this.fitAmount(stackEl);
|
||||
stackEl.dataset.figure = stackEl.textContent;
|
||||
|
||||
node.classList.toggle("seatAllIn", allIn);
|
||||
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
||||
@@ -1966,35 +2178,90 @@ const Render = {
|
||||
node.classList.toggle("seatLive", player.inHand && !player.folded);
|
||||
},
|
||||
|
||||
// A seat puck is 64px of usable width. A tournament stack doesn't fit in that and,
|
||||
// because the number has no spaces to break on, it doesn't wrap or ellipsise
|
||||
// either — it just draws straight out over the neighbouring seat. Stepping the
|
||||
// font down keeps every digit, which matters: a dealer misreading a stack is a
|
||||
// worse outcome than a small one, so this shrinks rather than abbreviating.
|
||||
fitAmount(el) {
|
||||
const formatted = el.textContent || "";
|
||||
// The separators go before the type size does. "$1,234,567" is ten characters,
|
||||
// which used to drop the puck to 7px — and 7px is not a size anybody reads a
|
||||
// stack off a felt at, so the shrink-rather-than-abbreviate rule above was
|
||||
// buying precision the dealer then couldn't see. Without its commas the same
|
||||
// number is eight characters, and eight fits at 10px with every digit intact.
|
||||
// Grouping is a reading aid for a column of figures; this is one number on a
|
||||
// 64px puck, and the aid costs more than it gives.
|
||||
const text = formatted.length >= 10 ? formatted.replace(/,/g, "") : formatted;
|
||||
if (text !== formatted) el.textContent = text;
|
||||
// The two steps of type an amount can come down, in order, once taking the
|
||||
// separators out has not been enough.
|
||||
AMOUNT_STEPS: ["puckAmountLong", "puckAmountHuge"],
|
||||
|
||||
const length = text.length;
|
||||
el.classList.toggle("puckAmountLong", length >= 8 && length < 10);
|
||||
// Only a stack of $100,000,000 or more still lands here. It is an overflow
|
||||
// guard rather than a size anybody is expected to read.
|
||||
el.classList.toggle("puckAmountHuge", length >= 10);
|
||||
// Puts one amount back to the figure and the size it is meant to be read at, and
|
||||
// hands back the grouped figure. Everything below measures from here, so a puck that
|
||||
// once went small can come back — left as it was, this would be a ratchet.
|
||||
resetAmount(el) {
|
||||
const figure = el.dataset.figure === undefined ? el.textContent : el.dataset.figure;
|
||||
if (el.textContent !== figure) el.textContent = figure;
|
||||
this.AMOUNT_STEPS.forEach(step => el.classList.remove(step));
|
||||
return figure;
|
||||
},
|
||||
|
||||
// A seat puck is 64px of usable width, and 34 on the tightest ring in the sweep. A
|
||||
// tournament stack doesn't fit in that and, because a number has no spaces to break
|
||||
// on, it doesn't wrap or ellipsise either — it draws straight out over the seat next
|
||||
// to it. Stepping the type down keeps every digit, which matters: a dealer misreading
|
||||
// a stack is a worse outcome than a small one, so this shrinks rather than
|
||||
// abbreviating.
|
||||
//
|
||||
// Measured, and it used to be counted. The old rule was eight characters for one
|
||||
// step and ten for the other, which is a guess at how wide a figure is that happens
|
||||
// to hold at one text size and one puck width. Turn a phone's text up — 20px of root
|
||||
// text is what an Android slider gives you at 125%, and every size in this
|
||||
// stylesheet is in `rem` on purpose — and it stops holding: measured at 360x640
|
||||
// twelve-handed, "$1,200" is six characters, took no step at all, and wanted 38px of
|
||||
// a 34px puck. The sweep caught it as a stack figure painted over the seat next door
|
||||
// at every phone in the list. What the puck can hold is a question about the puck,
|
||||
// so it is asked of the puck.
|
||||
//
|
||||
// Written as passes over every amount on the ring rather than a loop per seat: each
|
||||
// pass writes to all of them and then reads back from all of them, so the browser
|
||||
// lays the felt out four times whatever the table's size. Interleaved, twelve seats
|
||||
// would have cost twenty-four layouts, and this runs on every render.
|
||||
fitAmounts(cells) {
|
||||
const live = cells.filter(el => el && (el.dataset.figure || el.textContent));
|
||||
if (!live.length) return;
|
||||
const figures = new Map(live.map(el => [el, this.resetAmount(el)]));
|
||||
|
||||
// The separators go before the type size does. "$1,234,567" is ten characters,
|
||||
// which used to drop the puck's figures to 7px — and 7px is not a size anybody
|
||||
// reads a stack off a felt at, so the shrink-rather-than-abbreviate rule above
|
||||
// was buying a precision the dealer then couldn't see. Without its commas the
|
||||
// same number is eight characters and fits two steps higher up this ladder.
|
||||
// Grouping is a reading aid for a column of figures; this is one number on a
|
||||
// 44px puck, and there the aid costs more than it gives.
|
||||
const ungroup = live.filter(el => figures.get(el).includes(",") && this.overflowing(el));
|
||||
ungroup.forEach(el => { el.textContent = figures.get(el).replace(/,/g, ""); });
|
||||
|
||||
let carried = null;
|
||||
this.AMOUNT_STEPS.forEach(step => {
|
||||
const over = live.filter(el => this.overflowing(el));
|
||||
const previous = carried;
|
||||
over.forEach(el => {
|
||||
if (previous) el.classList.remove(previous);
|
||||
el.classList.add(step);
|
||||
});
|
||||
carried = step;
|
||||
});
|
||||
},
|
||||
|
||||
// One puck's worth — for refreshPuck, which redraws a single seat.
|
||||
fitAmount(el) {
|
||||
this.fitAmounts([el]);
|
||||
},
|
||||
|
||||
// Every amount on the ring, so fitAmounts can do the whole table in four layouts.
|
||||
seatAmountCells(layer) {
|
||||
const cells = [];
|
||||
for (let index = 0; index < layer.childElementCount; index++) {
|
||||
const node = layer.children[index];
|
||||
cells.push(node.querySelector(".seatPuckTotal"), node.querySelector(".seatPuckStack"));
|
||||
}
|
||||
return cells;
|
||||
},
|
||||
|
||||
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
|
||||
// (which live in a separate panel) never lose focus.
|
||||
refreshPuck(index) {
|
||||
const node = document.querySelector(`.seat[data-seat-index="${index}"]`);
|
||||
if (node) this.updatePuckContent(node, this.state.seats[index], index);
|
||||
if (!node) return;
|
||||
this.updatePuckContent(node, this.state.seats[index], index);
|
||||
this.fitAmounts([node.querySelector(".seatPuckTotal"), node.querySelector(".seatPuckStack")]);
|
||||
},
|
||||
|
||||
// ---- Seat gestures ----
|
||||
@@ -2156,7 +2423,9 @@ const Render = {
|
||||
|
||||
const index = this.selectedSeatIndex;
|
||||
const player = this.state.seats[index];
|
||||
title.textContent = this.puckLabel(player, index);
|
||||
// Named and numbered. The chair number came off the puck's face this round —
|
||||
// see HoldemState.seatHeading — and this is one of the two places it went.
|
||||
title.textContent = HoldemState.seatHeading(player, index);
|
||||
|
||||
body.innerHTML = "";
|
||||
const template = document.getElementById("seatEditorTemplate");
|
||||
@@ -2253,6 +2522,65 @@ const Render = {
|
||||
this.renderPanelGlances();
|
||||
},
|
||||
|
||||
// The three things a seat can be, in the order a hand goes through them. `folded`
|
||||
// is only meaningful while `inHand` is set, which is why these are one choice and
|
||||
// not two: the pair (out, folded) has no meaning, and the old two-checkbox control
|
||||
// could be asked for it.
|
||||
SEAT_STATES: [
|
||||
{ key: "out", label: "Sitting out", inHand: false, folded: false },
|
||||
{ key: "in", label: "In the hand", inHand: true, folded: false },
|
||||
{ key: "folded", label: "Folded", inHand: true, folded: true },
|
||||
],
|
||||
|
||||
seatStateOf(player) {
|
||||
if (!player.inHand) return "out";
|
||||
return player.folded ? "folded" : "in";
|
||||
},
|
||||
|
||||
// Draw the current answer onto the three buttons. Called on every paint and again
|
||||
// whenever something other than a press changes the seat — typing a name into an
|
||||
// empty chair seats somebody, and the control has to say so.
|
||||
paintSeatState(form, player) {
|
||||
const current = this.seatStateOf(player);
|
||||
form.querySelectorAll(".seatStateOption").forEach(btn => {
|
||||
const chosen = btn.dataset.seatState === current;
|
||||
btn.setAttribute("aria-checked", chosen ? "true" : "false");
|
||||
// One Tab stop for the three, the way the APG asks: Tab reaches the group,
|
||||
// the arrows move inside it.
|
||||
btn.tabIndex = chosen ? 0 : -1;
|
||||
});
|
||||
},
|
||||
|
||||
wireSeatState(form, player, index) {
|
||||
const options = [...form.querySelectorAll(".seatStateOption")];
|
||||
const choose = (key, { focus = false } = {}) => {
|
||||
const state = this.SEAT_STATES.find(entry => entry.key === key);
|
||||
if (!state) return;
|
||||
player.inHand = state.inHand;
|
||||
player.folded = state.folded;
|
||||
this.paintSeatState(form, player);
|
||||
if (focus) {
|
||||
const chosen = options.find(btn => btn.dataset.seatState === key);
|
||||
if (chosen) chosen.focus({ preventScroll: true });
|
||||
}
|
||||
this.commitSeatChange(form, player, index);
|
||||
};
|
||||
|
||||
options.forEach(btn => {
|
||||
btn.addEventListener("click", () => choose(btn.dataset.seatState));
|
||||
btn.addEventListener("keydown", event => {
|
||||
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
|
||||
if (step === undefined) return;
|
||||
event.preventDefault();
|
||||
const at = this.SEAT_STATES.findIndex(entry => entry.key === this.seatStateOf(player));
|
||||
const to = (at + step + this.SEAT_STATES.length) % this.SEAT_STATES.length;
|
||||
choose(this.SEAT_STATES[to].key, { focus: true });
|
||||
});
|
||||
});
|
||||
|
||||
this.paintSeatState(form, player);
|
||||
},
|
||||
|
||||
wireSeatFields(form, player, index) {
|
||||
const nameInput = form.querySelector(".seatNameInput");
|
||||
nameInput.value = player.name;
|
||||
@@ -2271,32 +2599,13 @@ const Render = {
|
||||
if (wasEmpty && player.name.trim()) {
|
||||
player.inHand = true;
|
||||
player.folded = false;
|
||||
const inHandBox = form.querySelector(".inHandCheckbox");
|
||||
if (inHandBox) inHandBox.checked = true;
|
||||
const foldBox = form.querySelector(".foldCheckbox");
|
||||
if (foldBox) { foldBox.checked = false; foldBox.disabled = false; }
|
||||
this.paintSeatState(form, player);
|
||||
}
|
||||
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
|
||||
const inHandCheckbox = form.querySelector(".inHandCheckbox");
|
||||
inHandCheckbox.checked = player.inHand;
|
||||
inHandCheckbox.addEventListener("change", () => {
|
||||
player.inHand = inHandCheckbox.checked;
|
||||
if (!player.inHand) player.folded = false;
|
||||
foldCheckboxRef.checked = player.folded;
|
||||
foldCheckboxRef.disabled = !player.inHand;
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
|
||||
const foldCheckboxRef = form.querySelector(".foldCheckbox");
|
||||
foldCheckboxRef.checked = player.folded;
|
||||
foldCheckboxRef.disabled = !player.inHand;
|
||||
foldCheckboxRef.addEventListener("change", () => {
|
||||
player.folded = foldCheckboxRef.checked;
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
this.wireSeatState(form, player, index);
|
||||
|
||||
const slots = [form.querySelector(".card1Slot"), form.querySelector(".card2Slot")];
|
||||
slots.forEach((slot, cardIdx) => {
|
||||
@@ -2321,7 +2630,11 @@ const Render = {
|
||||
const stackInput = form.querySelector(".stackInput");
|
||||
stackInput.value = player.stack ?? "";
|
||||
stackInput.addEventListener("input", () => {
|
||||
player.stack = stackInput.value === "" ? null : Number(stackInput.value);
|
||||
// Null for empty *and* for anything that isn't a number — the box is
|
||||
// type="text" now, so a stray letter arrives here rather than being eaten
|
||||
// by the control, and a seat holding NaN chips is worse than one holding
|
||||
// none. See Money.readAmount.
|
||||
player.stack = Money.readAmount(stackInput.value);
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
|
||||
@@ -2376,8 +2689,21 @@ const Render = {
|
||||
const valueInput = row.querySelector(".customFieldValue");
|
||||
labelInput.value = field.label;
|
||||
valueInput.value = field.value;
|
||||
|
||||
// Same as the chip legend's remove button, and for the same reason: "×" is
|
||||
// the whole of this control, so unnamed it announces as a button and nothing
|
||||
// more, in a column of others exactly like it. The label the dealer typed is
|
||||
// what tells them apart, and it changes as it is typed.
|
||||
const removeBtn = row.querySelector(".removeFieldBtn");
|
||||
const nameRemove = () => {
|
||||
const typed = labelInput.value.trim();
|
||||
removeBtn.setAttribute("aria-label", typed ? `Remove "${typed}"` : "Remove this note");
|
||||
};
|
||||
nameRemove();
|
||||
|
||||
labelInput.addEventListener("input", () => {
|
||||
field.label = labelInput.value;
|
||||
nameRemove();
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeatDisclosures(form, player);
|
||||
});
|
||||
@@ -2386,7 +2712,7 @@ const Render = {
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeatDisclosures(form, player);
|
||||
});
|
||||
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
|
||||
removeBtn.addEventListener("click", () => {
|
||||
player.customFields.splice(fieldIdx, 1);
|
||||
this.renderCustomFields(form, player, index);
|
||||
this.hooks.save(this.state);
|
||||
@@ -2398,7 +2724,34 @@ const Render = {
|
||||
|
||||
// ---- Chip legend ----
|
||||
|
||||
// The legend itself: what each colour is worth, read left to right.
|
||||
//
|
||||
// The panel has always been named for this and has never shown it — what it showed
|
||||
// was the form for setting it, five rows deep, with the values in number boxes. A
|
||||
// dealer wanting to know what the green one is worth had to read it out of an input.
|
||||
// This is the same five facts in two lines, and it sits above the form now.
|
||||
renderChipLegendStrip() {
|
||||
const strip = document.getElementById("chipLegendStrip");
|
||||
if (!strip) return;
|
||||
strip.innerHTML = "";
|
||||
(this.state.chipLegend || []).forEach(chip => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "chipLegendChip";
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "chipSwatch";
|
||||
dot.style.background = chip.color;
|
||||
const worth = document.createElement("span");
|
||||
worth.textContent = Money.format(Number(chip.value) || 0);
|
||||
|
||||
item.appendChild(dot);
|
||||
item.appendChild(worth);
|
||||
strip.appendChild(item);
|
||||
});
|
||||
},
|
||||
|
||||
renderChipLegend() {
|
||||
this.renderChipLegendStrip();
|
||||
const list = document.getElementById("chipLegendList");
|
||||
list.innerHTML = "";
|
||||
const template = document.getElementById("chipLegendRowTemplate");
|
||||
@@ -2427,10 +2780,18 @@ const Render = {
|
||||
colorInput.addEventListener("input", () => {
|
||||
chip.color = colorInput.value;
|
||||
this.hooks.save(this.state);
|
||||
// The strip above is the same fact this field is editing, so it keeps up
|
||||
// with it. Only the strip, still — but for cost now rather than for
|
||||
// safety: setField refuses to write into a focused control, so the
|
||||
// mid-keystroke clobber this was dodging can no longer happen from any
|
||||
// direction. Naming the two renders that matter is simply cheaper than
|
||||
// laying the felt out again on every keystroke.
|
||||
this.renderChipLegendStrip();
|
||||
});
|
||||
valueInput.addEventListener("input", () => {
|
||||
chip.value = Number(valueInput.value) || 0;
|
||||
nameRemove();
|
||||
this.renderChipLegendStrip();
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeats();
|
||||
this.renderSeatEditor();
|
||||
@@ -2439,8 +2800,8 @@ const Render = {
|
||||
// legend's own summary is on it: shut, the panel reads "5 colours ·
|
||||
// $1–$500", and re-pricing the top chip is exactly the edit that
|
||||
// changes it. Called here for the same reason as the rest of the list:
|
||||
// renderAll would write this field's own value back into it mid-
|
||||
// keystroke.
|
||||
// it is what this edit actually moves, and renderAll would lay the whole
|
||||
// felt out again on every keystroke to find that out.
|
||||
//
|
||||
// Only that one. The bank check is not downstream of this, which is
|
||||
// worth saying because it looks as though it should be — a seat's chip
|
||||
@@ -3040,7 +3401,13 @@ const Render = {
|
||||
const emptyHint = document.getElementById("handHistoryEmptyHint");
|
||||
const history = this.state.handHistory || [];
|
||||
emptyHint.hidden = history.length > 0;
|
||||
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
|
||||
// This one was greyed out with no explanation anywhere — not even a tooltip —
|
||||
// so on every device it was a button that did nothing and said nothing about it.
|
||||
Unavailable.set(
|
||||
document.getElementById("exportHistoryBtn"),
|
||||
history.length === 0,
|
||||
"No hands to export yet — the file fills in as the night is played."
|
||||
);
|
||||
|
||||
const query = this.historyFilter || "";
|
||||
const shown = query ? history.filter(entry => this.handMatchesFilter(entry, query)) : history;
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
// The long-press menu on a seat. Everything a dealer does to one player mid-hand,
|
||||
// one press and one tap away, without opening the full editor and without hunting
|
||||
// for whose turn it is in the action bar.
|
||||
//
|
||||
// It grew to eleven of those, all drawn the same: eleven full-width bars filling 85%
|
||||
// of every screen it was measured on, scrolling on all of them, with Cancel below the
|
||||
// fold on all of them too — 252px of it hidden on a 360px phone, 448px in landscape.
|
||||
// Apple's action-sheet guidance puts the comfortable number at three to five and says
|
||||
// not to let one scroll at all, and a dealer folding a player mid-hand is reading
|
||||
// eleven choices to find the one they came for.
|
||||
//
|
||||
// The eleven have not been cut — every one of them is the only route to something, and
|
||||
// "Blinds follow the button again" is the only route in the whole tool to clearing an
|
||||
// override. They are sorted by *when* instead. A seat action is either something you
|
||||
// do while the cards are in the air, or housekeeping between hands, and those two want
|
||||
// different targets: the first wants a bar you can hit without looking, the second
|
||||
// wants to be findable and out of the way. So the hand keeps the full-width bars and
|
||||
// everything else goes to a two-column grid underneath it.
|
||||
//
|
||||
// The captions are not decoration. A half-width tile reading "Small blind" is a label,
|
||||
// and the puck already wears one of those; under "Give this seat" it is a verb again.
|
||||
// That is what lets three of the longest labels here lose ten characters each and
|
||||
// still fit a half tile on one line, which is the height the caption pays back.
|
||||
const SeatMenu = {
|
||||
state: null,
|
||||
hooks: null,
|
||||
@@ -8,6 +28,14 @@ const SeatMenu = {
|
||||
|
||||
mode: null,
|
||||
|
||||
// The two groups that sit under the hand, in the order they are drawn. `hand` has
|
||||
// no caption because it needs none — it is the top of the sheet, and its actions
|
||||
// are the two the sheet is coloured for.
|
||||
GROUPS: [
|
||||
{ name: "player", caption: "This player" },
|
||||
{ name: "position", caption: "Give this seat" },
|
||||
],
|
||||
|
||||
open(state, hooks, seatIndex) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
@@ -26,50 +54,68 @@ const SeatMenu = {
|
||||
|
||||
// Only the actions that make sense for this seat right now — a folded player has
|
||||
// nothing to go all-in with, and an empty chair has nothing to fold.
|
||||
//
|
||||
// `group` is when you do it, not what it touches: "hand" is what a dealer reaches
|
||||
// for with cards on the table, and it is the only group drawn at full width.
|
||||
actionsFor(player, index) {
|
||||
const actions = [];
|
||||
const dealtIn = player.inHand;
|
||||
const live = dealtIn && !player.folded;
|
||||
|
||||
if (live) {
|
||||
actions.push({ label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
|
||||
actions.push({ label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
|
||||
actions.push({ group: "hand", label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
|
||||
actions.push({ group: "hand", label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
|
||||
} else if (dealtIn && player.folded) {
|
||||
actions.push({ label: "Un-fold", run: () => this.hooks.unfold(index) });
|
||||
actions.push({ group: "hand", label: "Un-fold", run: () => this.hooks.unfold(index) });
|
||||
}
|
||||
|
||||
// The most common interruption in a cash game, and until now it meant opening
|
||||
// the editor and retyping a stack from memory.
|
||||
actions.push({ label: "Add chips…", run: () => this.hooks.addChips(index) });
|
||||
|
||||
actions.push({
|
||||
label: dealtIn ? "Sit out" : "Deal in",
|
||||
run: () => this.hooks.toggleDealtIn(index),
|
||||
});
|
||||
|
||||
// An empty chair with people waiting: the dealer taps the chair somebody is
|
||||
// about to sit in, which is what their hand is doing anyway. Only the front of
|
||||
// the queue is offered, for the same reason the panel only offers them — a list
|
||||
// that isn't kept in order is just a pile of names.
|
||||
//
|
||||
// In the hand group rather than with the housekeeping: the queue moves when a
|
||||
// seat opens, which is between two hands with people watching, not later.
|
||||
const waiting = (this.state.waitingList || [])[0];
|
||||
const emptyChair = !HoldemState.seatInPlay(player);
|
||||
if (waiting && emptyChair) {
|
||||
actions.push({
|
||||
group: "hand",
|
||||
label: `Seat ${waiting.name}`,
|
||||
className: "menuAccent",
|
||||
run: () => this.hooks.seatWaiting(waiting.id, index),
|
||||
});
|
||||
}
|
||||
|
||||
// The most common interruption in a cash game, and until now it meant opening
|
||||
// the editor and retyping a stack from memory.
|
||||
actions.push({ group: "player", label: "Add chips…", run: () => this.hooks.addChips(index) });
|
||||
|
||||
actions.push({
|
||||
group: "player",
|
||||
label: dealtIn ? "Sit out" : "Deal in",
|
||||
run: () => this.hooks.toggleDealtIn(index),
|
||||
});
|
||||
|
||||
// Leaving for the night, which is a different thing from sitting out a hand:
|
||||
// the chips go back over the rail and the chair opens. Offered only for a seat
|
||||
// that has somebody in it, since an empty chair has nobody to pay.
|
||||
if (HoldemState.seatInPlay(player)) {
|
||||
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
|
||||
actions.push({ group: "player", label: "Cash out…", run: () => this.hooks.cashOut(index) });
|
||||
}
|
||||
|
||||
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
|
||||
// generated from a touch screen — so on the device this tool is mostly used on,
|
||||
// the "drag to swap seats" the hint advertised did nothing at all. This is the
|
||||
// same move, by the route a thumb can actually take.
|
||||
if (this.state.seats.length > 1) {
|
||||
actions.push({ group: "player", label: "Move to…", run: () => this.openMovePicker() });
|
||||
}
|
||||
|
||||
actions.push({ group: "player", label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
||||
|
||||
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
|
||||
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
|
||||
actions.push({ group: "position", label: "The button", run: () => this.hooks.makeDealer(index) });
|
||||
}
|
||||
|
||||
// Naming a blind by hand. Only offered while nothing but the blinds are in —
|
||||
@@ -80,25 +126,25 @@ const SeatMenu = {
|
||||
if (dealtIn && Betting.onlyBlindsIn(this.state)) {
|
||||
const blinds = HoldemState.getBlindSeats(this.state);
|
||||
if (blinds.sbIndex !== index) {
|
||||
actions.push({ label: "Make small blind", run: () => this.hooks.assignBlind(index, "sb") });
|
||||
actions.push({ group: "position", label: "Small blind", run: () => this.hooks.assignBlind(index, "sb") });
|
||||
}
|
||||
if (blinds.bbIndex !== index) {
|
||||
actions.push({ label: "Make big blind", run: () => this.hooks.assignBlind(index, "bb") });
|
||||
actions.push({ group: "position", label: "Big blind", run: () => this.hooks.assignBlind(index, "bb") });
|
||||
}
|
||||
// Not about this seat at all — it puts the whole table back on automatic —
|
||||
// so it spans the grid instead of sitting in it as a fourth peer. It is
|
||||
// still here because this menu is the only place in the tool that offers
|
||||
// it, and it belongs beside the two overrides that caused it.
|
||||
if (this.state.blindOverride) {
|
||||
actions.push({ label: "Blinds follow the button again", run: () => this.hooks.clearBlindOverride() });
|
||||
actions.push({
|
||||
group: "position",
|
||||
label: "Blinds follow the button again",
|
||||
wide: true,
|
||||
run: () => this.hooks.clearBlindOverride(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
|
||||
// generated from a touch screen — so on the device this tool is mostly used on,
|
||||
// the "drag to swap seats" the hint advertised did nothing at all. This is the
|
||||
// same move, by the route a thumb can actually take.
|
||||
if (this.state.seats.length > 1) {
|
||||
actions.push({ label: "Move to…", run: () => this.openMovePicker() });
|
||||
}
|
||||
|
||||
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
|
||||
return actions;
|
||||
},
|
||||
|
||||
@@ -116,48 +162,117 @@ const SeatMenu = {
|
||||
this.render();
|
||||
},
|
||||
|
||||
closeMovePicker() {
|
||||
this.mode = null;
|
||||
this.render();
|
||||
},
|
||||
|
||||
button(label, className) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = className;
|
||||
btn.textContent = label;
|
||||
return btn;
|
||||
},
|
||||
|
||||
// A row of tiles. Two columns on a phone: a seat action's label is a verb and a
|
||||
// short one, and stacking nine of them full width was most of the height the sheet
|
||||
// was spending.
|
||||
grid(extra) {
|
||||
const el = document.createElement("div");
|
||||
el.className = `seatMenuGrid ${extra || ""}`.trim();
|
||||
return el;
|
||||
},
|
||||
|
||||
// The two tiers, as boxes rather than as a run of siblings.
|
||||
//
|
||||
// Upright they are what they always were: one column, in the order they are
|
||||
// written. The wrappers exist for the phone on its side, where the sheet has 306px
|
||||
// of height and 616 of width and the tiers stop being two halves of a column and
|
||||
// become two columns — see the `max-height: 560px` block in holdem.css. That is
|
||||
// only expressible if each tier is one box the layout can address; as five loose
|
||||
// children they can only ever stack.
|
||||
tier(name) {
|
||||
const el = document.createElement("div");
|
||||
el.className = name;
|
||||
return el;
|
||||
},
|
||||
|
||||
caption(text) {
|
||||
const el = document.createElement("p");
|
||||
el.className = "seatMenuCaption";
|
||||
el.textContent = text;
|
||||
return el;
|
||||
},
|
||||
|
||||
// Stepping into the move picker throws the whole list away, and the first thing it
|
||||
// throws away is the "Move this seat" the dealer just pressed — which left focus on
|
||||
// the body, inside a sheet that had just changed into a different sheet. The
|
||||
// heading is where to be: in move mode it has stopped saying who this is and
|
||||
// started saying what is being asked — "Move Alexandra to…".
|
||||
render() {
|
||||
Modal.preservingFocus(document.getElementById("seatMenuOverlay"), () => this.paint(), () => {
|
||||
const title = document.getElementById("seatMenuTitle");
|
||||
if (title) title.focus({ preventScroll: true });
|
||||
});
|
||||
},
|
||||
|
||||
paint() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
const title = document.getElementById("seatMenuTitle");
|
||||
const subtitle = document.getElementById("seatMenuSubtitle");
|
||||
const list = document.getElementById("seatMenuActions");
|
||||
const cancel = document.getElementById("seatMenuCancelBtn");
|
||||
list.innerHTML = "";
|
||||
|
||||
if (this.mode === "move") {
|
||||
title.textContent = `Move ${HoldemState.displayName(player, this.seatIndex)} to…`;
|
||||
subtitle.textContent = "Their chips, cards and bet go with them. The button and the blinds stay on the chair.";
|
||||
// Opening the picker used to be a one-way door: it replaced the sheet, and
|
||||
// the only button left closed the whole menu. The sheet's own back is the
|
||||
// button already at the thumb end, so it says Back while there is somewhere
|
||||
// to go back to.
|
||||
cancel.textContent = "Back";
|
||||
const keeping = this.tier("seatMenuKeeping");
|
||||
// Chairs, not verbs: "Seat 3 — Alexandra" sets 116px where "Sit out" sets
|
||||
// about 50, so this grid asks for a wider column than the tiles do.
|
||||
const grid = this.grid("seatMenuGridNames");
|
||||
this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "seatMenuBtn";
|
||||
// displayName falls back to "Seat 5" for a chair with nobody in it,
|
||||
// which reads as "Seat 5 — Seat 5". An empty chair says it's empty.
|
||||
const named = (other.name || "").trim();
|
||||
btn.textContent = named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`;
|
||||
const btn = this.button(
|
||||
named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`,
|
||||
"seatMenuBtn menuTile");
|
||||
btn.addEventListener("click", () => {
|
||||
const from = this.seatIndex;
|
||||
this.close();
|
||||
this.hooks.swapSeats(from, seat);
|
||||
});
|
||||
list.appendChild(btn);
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
keeping.appendChild(grid);
|
||||
list.appendChild(keeping);
|
||||
return;
|
||||
}
|
||||
|
||||
cancel.textContent = "Cancel";
|
||||
title.textContent = HoldemState.displayName(player, this.seatIndex);
|
||||
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
const owed = Betting.toCall(this.state, player);
|
||||
const bits = [];
|
||||
// Which chair this is leads the line. It came off the puck's face this round —
|
||||
// see HoldemState.seatHeading — and this is the other place it went. It goes in
|
||||
// the subtitle rather than the heading because the heading is a name and names
|
||||
// are long: this header is stuck to the top of the sheet that scrolls, and a
|
||||
// second line of h2 is height every action below it pays for.
|
||||
const bits = [`Seat ${this.seatIndex + 1}`];
|
||||
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
|
||||
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
|
||||
subtitle.textContent = bits.join(" · ");
|
||||
|
||||
this.actionsFor(player, this.seatIndex).forEach(action => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `seatMenuBtn ${action.className || ""}`;
|
||||
btn.textContent = action.label;
|
||||
const actions = this.actionsFor(player, this.seatIndex);
|
||||
const wire = (btn, action) => {
|
||||
btn.addEventListener("click", () => {
|
||||
// "Move to…" stays on the sheet and swaps it for the seat list; every
|
||||
// other action is the end of the interaction.
|
||||
@@ -168,8 +283,37 @@ const SeatMenu = {
|
||||
this.close();
|
||||
action.run();
|
||||
});
|
||||
list.appendChild(btn);
|
||||
};
|
||||
|
||||
const hand = this.tier("seatMenuHand");
|
||||
actions.filter(a => a.group === "hand").forEach(action => {
|
||||
const btn = this.button(action.label, `seatMenuBtn ${action.className || ""}`.trim());
|
||||
wire(btn, action);
|
||||
hand.appendChild(btn);
|
||||
});
|
||||
// An empty tier is left out rather than drawn empty — a folded player in a game
|
||||
// with nobody waiting has nothing in the air to do, and the sideways layout
|
||||
// reads the tier's absence to know it has one column rather than two.
|
||||
if (hand.childElementCount) list.appendChild(hand);
|
||||
|
||||
const keeping = this.tier("seatMenuKeeping");
|
||||
this.GROUPS.forEach(({ name, caption }) => {
|
||||
const inGroup = actions.filter(a => a.group === name);
|
||||
// A caption for nothing is worse than no caption. A group empties often —
|
||||
// past the blinds there is nothing to give a seat but the button.
|
||||
if (inGroup.length === 0) return;
|
||||
keeping.appendChild(this.caption(caption));
|
||||
const grid = this.grid();
|
||||
inGroup.forEach(action => {
|
||||
const btn = this.button(action.label,
|
||||
`seatMenuBtn menuTile ${action.wide ? "menuTileWide" : ""} ${action.className || ""}`
|
||||
.replace(/\s+/g, " ").trim());
|
||||
wire(btn, action);
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
keeping.appendChild(grid);
|
||||
});
|
||||
if (keeping.childElementCount) list.appendChild(keeping);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ const Shortcuts = {
|
||||
{ keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" },
|
||||
{ keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" },
|
||||
{ keys: ["A"], action: "allIn", what: "Put this player all-in" },
|
||||
{ keys: ["U"], action: "undo", what: "Take back the last action" },
|
||||
// Both undos, because there are two and a dealer has no reason to know that:
|
||||
// the bar's pill takes back a bet, and the toast's takes back a whole step.
|
||||
{ keys: ["U"], action: "undo", what: "Take back the last thing you did" },
|
||||
{ keys: ["Space", "Enter"], action: "advance", what: "Deal, or move to the next street" },
|
||||
{ keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" },
|
||||
],
|
||||
|
||||
@@ -476,6 +476,23 @@ const HoldemState = {
|
||||
return player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
|
||||
},
|
||||
|
||||
// The same seat, named and numbered, for the two sheets that are about one seat.
|
||||
//
|
||||
// The chair number used to be a grey digit in the corner of the puck, and the corner
|
||||
// of the puck is where the blind badge lands and where a long name reaches: measured
|
||||
// across 348 seats on 36 tables, 117 of them had it printed under something else —
|
||||
// every seat posting a blind, and one in seven with it sitting on the first letter of
|
||||
// the player's own name. The felt has no free corner to move it to, so it stopped
|
||||
// being on the felt. These two headings are where a dealer goes when the question is
|
||||
// about one seat, and they answer it now.
|
||||
//
|
||||
// An unnamed seat is already called "Seat 5" by displayName, so it doesn't say it
|
||||
// twice.
|
||||
seatHeading(player, seatIndex) {
|
||||
const name = player && player.name ? player.name.trim() : "";
|
||||
return name ? `${name} · Seat ${seatIndex + 1}` : `Seat ${seatIndex + 1}`;
|
||||
},
|
||||
|
||||
// Contribution the table calculator will treat this player as having put in.
|
||||
//
|
||||
// Chips already pushed into the middle count whether or not the player is still
|
||||
|
||||
@@ -1017,6 +1017,53 @@ lose. Chromium and Safari answer silently on their own engagement heuristics; Fi
|
||||
asks. A no is not an error worth reporting at a table, so the only thing that changes is
|
||||
how confident Save & reset is allowed to sound, and whether it offers the ask as a button.
|
||||
|
||||
## The sweep had never once been run at a size anybody sets
|
||||
|
||||
Every length in `holdem.css` that isn't a structural clearance is in `rem`. That is the
|
||||
right way round: it means the whole page follows the browser's font size, which is what
|
||||
a phone's text-size slider changes. It also means the page had a whole axis of behaviour
|
||||
that 320 audits had never touched, because every one of them ran at the default 16px.
|
||||
|
||||
A third pass now runs the same surfaces on the eight phone-shaped viewports with the
|
||||
root at **20px** — the 125% an Android accessibility slider gives you. It found four
|
||||
things on its first run, and every check that caught them was already written:
|
||||
|
||||
- **The page slid sideways.** `#holdemHeaderActions` was `flex-wrap: nowrap` with three
|
||||
`white-space: nowrap` labels inside it, so when they stopped fitting they had nowhere
|
||||
to go: "Resolve Pot" ended 23px past the right edge of a 360px window and put a
|
||||
horizontal scrollbar under the whole document. At 24px it was 60px past the edge of a
|
||||
390px one. `page never scrolls sideways` had been in the audit for months and had never
|
||||
had anything to say.
|
||||
- **Figures painted over the seat next door.** Which of the two type steps an amount got
|
||||
was decided by *counting characters* — eight for one, ten for the other — which is a
|
||||
guess at how wide a figure is that happens to hold at one text size and one puck width.
|
||||
At 20px, "$1,200" is six characters, took no step at all, and wanted 38px of a 34px
|
||||
puck. `Render.fitAmounts` measures now, in the same batched passes `fitSeatNames` uses,
|
||||
and takes the separators out before it takes a step of type.
|
||||
- **Pucks grew and the ring could not answer.** The row heights are `px` tokens so that
|
||||
an amount stepping its type down shortens its digits and not its row — but a token is
|
||||
a floor, not a ceiling, and text bigger than its row makes the row taller. The puck
|
||||
went from 55px to 65 and twelve of them came down on each other, because at the 44px
|
||||
floor there is nowhere narrower for the search to go. The two sizes a puck is written
|
||||
at are capped at exactly the sizes they have always been drawn at, so nothing changes
|
||||
at an ordinary text size; the felt is a diagram, and the rule that already governed a
|
||||
puck's width now governs its height. `.seatsRoomy` is exempt — those pucks have room.
|
||||
- **A check that was crying wolf.** `no control is parked where scrolling can't reach it`
|
||||
flagged the raise pad's Cancel and Raise-to in the landscape rail. The rail is
|
||||
`overflow-y: auto` on purpose and they were one flick away, so the message was untrue;
|
||||
it now asks whether a scrollable ancestor can bring the control back. What was true is
|
||||
that the rail opened at its *top*, so the overflow came off the bottom — where the two
|
||||
buttons that end a raise are. It opens at the end now, which is the same reason it is
|
||||
anchored low in the first place.
|
||||
|
||||
One shape it is not held to, with the arithmetic in `runUiAudit.js`: twelve players
|
||||
all-in on a 360x640 phone. The action bar is at its tallest there — 283px of the 640 at
|
||||
20px — and with the header that leaves the felt 290x163 where it had 296x262. Twelve
|
||||
pucks 44px wide (the floor a thumb needs) and 62px tall do not fit around an ellipse
|
||||
163px deep. That is a geometry with no answer rather than a fault with a fix, so it is
|
||||
reported as a note every run instead of a failure every run — a sweep that is always red
|
||||
stops being read.
|
||||
|
||||
## Why the hand evaluator is tested the way it is
|
||||
|
||||
`handRank.js` is the only module here that can pay the wrong person without anybody
|
||||
|
||||
@@ -51,11 +51,13 @@ const valuesIn = (properties, source = body) => {
|
||||
|
||||
describe("the spacing scale", () => {
|
||||
// Hairlines and structural clearances, each one deliberate:
|
||||
// 0px/1px/3px/4px/22px — borders, hairline offsets, and the 22px of room a seat
|
||||
// puck's name gives the dealer button beside it.
|
||||
// 0px/1px/3px/4px/25px — borders, hairline offsets, and the 25px of room a seat
|
||||
// puck's name gives the dealer button beside it: the chip's
|
||||
// own 22px plus the 3px it is inset by, which theRail's
|
||||
// "as wide as the chip it stands in for" holds it to.
|
||||
// 5rem/9rem — how far the page has to scroll past the fixed action
|
||||
// bar. Not rhythm; clearance for a thing of a known size.
|
||||
const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "22px", "5rem", "9rem"]);
|
||||
const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "25px", "5rem", "9rem"]);
|
||||
|
||||
test("every gap, margin and padding comes off the scale", () => {
|
||||
const offenders = valuesIn(
|
||||
@@ -77,12 +79,26 @@ describe("the spacing scale", () => {
|
||||
describe("the type scale", () => {
|
||||
// 16px — the size below which iOS zooms the page when a field is focused, so
|
||||
// every text input states it. Not a typographic choice.
|
||||
// 1.05em, 0.44rem — relative bumps inside a component, and the shrink-to-fit for
|
||||
// a six-figure stack on a 58px seat puck.
|
||||
const ALLOWED = new Set(["16px", "1.05em", "0.44rem"]);
|
||||
// 1.05em — a relative bump inside a component.
|
||||
// 9px — the floor under the seat puck's shrink-to-fit applied to a name. It is a
|
||||
// limit rather than a size: past it the word stops being legible from
|
||||
// across a table and an honest ellipsis is the better answer.
|
||||
// 10px, 12px — the ceilings on a seat puck's two sizes, which are the one place in
|
||||
// this file where type does not follow the browser's font size. Each is
|
||||
// exactly the size that row has always been drawn at, so a cap only ever
|
||||
// engages when a phone's text is turned up; see the #seatsLayer token block
|
||||
// for why a ring whose pucks are already at the 44px floor cannot answer a
|
||||
// taller row any other way. Limits rather than sizes.
|
||||
const ALLOWED = new Set(["16px", "1.05em", "9px", "10px", "12px"]);
|
||||
|
||||
// The seat puck's two sizes are custom properties that feed `font-size` through
|
||||
// `var()`, so the sweep above walks straight past them — which is exactly where a
|
||||
// raw number would end up hiding. They are held to the same scale.
|
||||
const TYPE_TOKENS = ["--seatNameSize", "--seatAmountSize"];
|
||||
|
||||
test("every font-size comes off the scale", () => {
|
||||
const offenders = valuesIn(["font-size"]).filter(v => !ALLOWED.has(v.value));
|
||||
const offenders = valuesIn(["font-size", ...TYPE_TOKENS])
|
||||
.filter(v => !ALLOWED.has(v.value));
|
||||
assert.deepEqual(offenders, [],
|
||||
"use var(--fs-2xs … --fs-2xl), or add the value to ALLOWED with a reason");
|
||||
});
|
||||
@@ -147,18 +163,82 @@ describe("numbers that change while the dealer watches", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("what an open chair is allowed to weigh", () => {
|
||||
// An empty seat used to be drawn as a whole puck of near-black on the green: the same
|
||||
// box a seated player gets, at the same weight, saying nothing. Five players
|
||||
// nine-handed is four of them, and they read as holes punched in the felt rather than
|
||||
// as chairs nobody is in — the eye had to sort the loud empty boxes from the loud full
|
||||
// ones before it could find who was in the hand.
|
||||
//
|
||||
// The wash the box carried was there for one reason: the chair number's contrast
|
||||
// cannot depend on whether its chair happens to sit over the brown rim, the dark green
|
||||
// or the light, which is about Lc 5 across one ring. So the wash moved off the chair
|
||||
// and under the number, which is the only thing an open chair has to say.
|
||||
|
||||
test("the chair is quieter than the player who could sit in it", () => {
|
||||
const empty = css.match(/\.seat\.seatEmpty \{([^}]*)\}/);
|
||||
const seat = css.match(/\n\.seat \{([^}]*)\}/);
|
||||
assert.ok(empty && seat);
|
||||
assert.match(seat[1], /background:\s*linear-gradient/,
|
||||
"a taken seat is a puck with a ground of its own");
|
||||
const alpha = empty[1].match(/background:\s*rgba\([^)]*?([0-9.]+)\s*\)/);
|
||||
assert.ok(alpha, "an open chair should still be washed, only faintly");
|
||||
assert.ok(Number(alpha[1]) <= 0.35,
|
||||
`an open chair at ${alpha[1]} reads as a hole in the felt, not a chair`);
|
||||
});
|
||||
|
||||
test("and it still says it is open", () => {
|
||||
const empty = css.match(/\.seat\.seatEmpty \{([^}]*)\}/)[1];
|
||||
assert.match(empty, /border-style:\s*dashed/,
|
||||
"the dashes are what carry the meaning; they always were");
|
||||
});
|
||||
|
||||
test("the number keeps a ground of its own, because it is the part that is read", () => {
|
||||
const rule = css.match(/\.seatEmpty \.seatChairNumber \{([^}]*)\}/);
|
||||
assert.ok(rule, "no ground under the one thing an open chair says");
|
||||
assert.match(rule[1], /background:\s*rgba/);
|
||||
assert.match(rule[1], /border-radius:\s*var\(--r-pill\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("controls that must not resize under a thumb", () => {
|
||||
test("the undo pill has a fixed width, not a content width", () => {
|
||||
// It names the last action, so its label changes on every tap. Measured across
|
||||
// one hand before this: 148, 110, 109, 108, 109, 110, 120.
|
||||
const rule = css.match(/\.actionUndo\s*\{[^}]*\}/);
|
||||
assert.ok(rule, ".actionUndo rule not found");
|
||||
assert.match(rule[0], /width:\s*[0-9.]+rem/);
|
||||
//
|
||||
// A length at every breakpoint, never a percentage: `min(10rem, 34%)` was tried
|
||||
// and the percentage resolves against the row it sits in, which is sized to its
|
||||
// own contents — the two chase each other, and the sweep caught the pill drawn
|
||||
// 38px wide at ten viewports. A phone gets a narrower one through a media query
|
||||
// instead, which is still one width at a time.
|
||||
const rules = [...css.matchAll(/\.actionUndo\s*\{([^}]*)\}/g)].map(m => m[1]);
|
||||
assert.ok(rules.length, ".actionUndo rule not found");
|
||||
rules.filter(body => /(?<![\w-])width:/.test(body)).forEach(body =>
|
||||
assert.match(body, /(?<![\w-])width:\s*([0-9.]+rem|100%)/,
|
||||
"a content width would let the pill resize under the dealer's thumb"));
|
||||
assert.ok(rules.some(body => /(?<![\w-])width:\s*[0-9.]+rem/.test(body)),
|
||||
"the pill should still state a fixed width somewhere");
|
||||
});
|
||||
|
||||
test("and its label is the part that ellipsizes", () => {
|
||||
test("and its label is the part that gives, never the keycap", () => {
|
||||
const rule = css.match(/\.actionUndoLabel\s*\{[^}]*\}/);
|
||||
assert.ok(rule, ".actionUndoLabel rule not found");
|
||||
assert.match(rule[0], /text-overflow:\s*ellipsis/);
|
||||
assert.match(rule[0], /overflow:\s*hidden/);
|
||||
assert.match(css.match(/\.actionUndoKey\s*\{[^}]*\}/)[0], /flex:\s*none/);
|
||||
});
|
||||
|
||||
test("and within the label, the act outlives the actor", () => {
|
||||
// Three items, not one string: the arrow, who it was, and what they did. Cut as
|
||||
// one string, "↶ Mike from work's fold" reads "↶ Mike from wor…" on a phone —
|
||||
// everything except the thing you would be taking back. The actor is dropped
|
||||
// whole instead, so the act is what survives, and only the act may ellipsize.
|
||||
const lead = css.match(/\.actionUndoGlyph,\s*\n\.actionUndoLead\s*\{([^}]*)\}/);
|
||||
assert.ok(lead, "the arrow and the actor should be fixed items");
|
||||
assert.match(lead[1], /flex:\s*none/);
|
||||
assert.match(css, /\.actionUndoLead\[hidden\]\s*\{[^}]*display:\s*none/);
|
||||
const act = css.match(/\.actionUndoAct\s*\{([^}]*)\}/);
|
||||
assert.ok(act, ".actionUndoAct rule not found");
|
||||
assert.match(act[1], /text-overflow:\s*ellipsis/);
|
||||
assert.match(act[1], /min-width:\s*0/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,7 +140,18 @@ describe("what stops the oval", () => {
|
||||
// The oval is a preference and the room is a fact. Whatever shape is asked for
|
||||
// above, the felt gives way to the measurement rather than the other way round,
|
||||
// because the thing on the other side of the cap is a seat behind the action bar.
|
||||
assert.equal(phoneRule, "var(--feltRoomPx, 100vh)");
|
||||
assert.match(phoneRule, /^var\(--feltRoomPx,/,
|
||||
"something other than the measured room decides how tall the felt may be");
|
||||
});
|
||||
|
||||
test("and what it falls back on is a near guess rather than the whole window", () => {
|
||||
// The fallback is only ever used for the frame before actionBar.js has measured
|
||||
// anything — but that frame is one a dealer sees, because the scripts run at the
|
||||
// end of the body. `100vh` was what stood here, and it says the whole window is
|
||||
// free: measured at 360x640 nine-handed, the table came up 336x504 and dropped to
|
||||
// 336x374 as soon as the bar was measured. See whatTheFeltIsToldFirst.
|
||||
assert.doesNotMatch(phoneRule, /var\(--feltRoomPx,\s*100vh\)/);
|
||||
assert.match(phoneRule, /var\(--actionBarHeight, 9rem\)/);
|
||||
});
|
||||
|
||||
test("and it is not floored, however tempting that is", () => {
|
||||
|
||||
@@ -43,6 +43,31 @@ const CardPicker = load("cardPicker");
|
||||
// navigator.wakeLock — see wakeLock.test.js.
|
||||
const WakeLock = load("wakeLock");
|
||||
|
||||
// feedback.js exports two namespaces and a third. Two of them answer here: Unavailable,
|
||||
// which is nearly all logic (see theUnansweredPress), and Toast, whose timer and focus
|
||||
// bookkeeping ask little enough of a document that a stand-in can answer (see
|
||||
// theWayBack). All three are also set as globals, the way the browser hands them to
|
||||
// render.js and cardPicker.js.
|
||||
const { Toast, Dialog, Unavailable } = require(path.join(SRC, "feedback.js"));
|
||||
globalThis.Toast = Toast;
|
||||
globalThis.Dialog = Dialog;
|
||||
globalThis.Unavailable = Unavailable;
|
||||
|
||||
// Modal is DOM behaviour through and through, but two pieces of it answer here: the
|
||||
// list of what counts as a Tab stop, which is a string, and preservingFocus, which asks
|
||||
// so little of a document that a stand-in can answer it. See theKeyboardInASheet.
|
||||
const Modal = load("modal");
|
||||
|
||||
// Quick Split draws itself entirely from the DOM, but the arithmetic that decides what
|
||||
// its two steppers are allowed to reach — and how many ways a pot may be chopped — is
|
||||
// plain and answers here. See theWaysControl.
|
||||
const QuickSplit = load("quickSplit");
|
||||
|
||||
// The seat menu's list — which actions a seat is offered and which group each one
|
||||
// lands in — is decided before anything is drawn, so actionsFor() answers here. Only
|
||||
// render() and open() touch the DOM.
|
||||
const SeatMenu = load("seatMenu");
|
||||
|
||||
// ---- Table builders ----------------------------------------------------------
|
||||
|
||||
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
|
||||
@@ -99,6 +124,11 @@ module.exports = {
|
||||
ActionBar,
|
||||
CardPicker,
|
||||
WakeLock,
|
||||
SeatMenu,
|
||||
Modal,
|
||||
QuickSplit,
|
||||
Toast,
|
||||
Unavailable,
|
||||
makeTable,
|
||||
chipsOnTable,
|
||||
contributions,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// The half of the interface the stylesheet does not draw.
|
||||
//
|
||||
// The browser draws the checkboxes, the menu a <select> opens, the scrollbars, the
|
||||
// caret, the spellcheck underline and the autofill wash, and it draws all of them for a
|
||||
// light page unless the page says otherwise. This one never did: measured,
|
||||
// `color-scheme: normal` on a #141414 ground, with three checkboxes in the Table Setup
|
||||
// panel rendering as bright white squares — the loudest objects on the screen, brighter
|
||||
// than the gold — and two more in the seat sheet doing the same over the felt.
|
||||
//
|
||||
// The comment on the pot wizard's hand-painted checkbox called it "the last raw browser
|
||||
// control in the tool". Six others were still the platform's, and three of those were
|
||||
// still 13x13 against a 24px floor this stylesheet had already written down once and
|
||||
// applied to exactly one box.
|
||||
//
|
||||
// Painting is the expensive way to fix that, and the pot wizard's box shows the bill:
|
||||
// a tick drawn as a `background` on a pseudo-element is not a colour forced-colors mode
|
||||
// knows how to replace, so it simply disappears — no visible checked state at all, on
|
||||
// the dialog that pays the table. Declaring the scheme fixes six controls natively and
|
||||
// costs nothing; the seventh stays painted on purpose and pays its own way.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "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));
|
||||
};
|
||||
|
||||
// Every block of a given at-rule, braces balanced, joined.
|
||||
//
|
||||
// This used to take the first one and stop, which held for exactly as long as there was
|
||||
// one of them. A second forced-colors block was added nearer the top of the file for a
|
||||
// control that dropped out of the OS palette, and the bill-paying test below started
|
||||
// reading that block instead and reporting the pot wizard's answers missing — a false
|
||||
// failure about a rule nobody had touched. There is no reason a stylesheet should
|
||||
// answer a media query in one place, so this asks about all of them.
|
||||
const atRule = query => {
|
||||
const open = `@media ${query} {`;
|
||||
const blocks = [];
|
||||
for (let at = CSS.indexOf(open); at >= 0; at = CSS.indexOf(open, at + 1)) {
|
||||
let depth = 0;
|
||||
for (let i = CSS.indexOf("{", at); i < CSS.length; i++) {
|
||||
if (CSS[i] === "{") depth++;
|
||||
else if (CSS[i] === "}" && --depth === 0) { blocks.push(CSS.slice(at, i + 1)); break; }
|
||||
}
|
||||
assert.ok(blocks.length && depth === 0, `\`${open}\` never closes`);
|
||||
}
|
||||
assert.ok(blocks.length, `there is no \`@media ${query}\` block`);
|
||||
return blocks.join("\n");
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the page says what colour it is", () => {
|
||||
const root = ruleFor(":root");
|
||||
|
||||
test("dark, to the browser and not only to the eye", () => {
|
||||
assert.match(root, /color-scheme:\s*dark/);
|
||||
});
|
||||
|
||||
// The checked colour is the green this tool already uses for yes, so the one
|
||||
// painted checkbox and the six native ones agree rather than nearly agree.
|
||||
test("and the checked colour is the tool's own green, not the platform's blue", () => {
|
||||
assert.match(root, /--accent-on:\s*#1e8449/);
|
||||
assert.match(root, /accent-color:\s*var\(--accent-on\)/);
|
||||
assert.match(ruleFor(".potWizardChoice input:checked"), /background:\s*#1e8449/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a checkbox is one size", () => {
|
||||
test("every one of them, not the one that was noticed", () => {
|
||||
assert.match(ruleFor('input[type="checkbox"]'), /width:\s*24px/);
|
||||
assert.match(ruleFor('input[type="checkbox"]'), /height:\s*24px/);
|
||||
});
|
||||
|
||||
test("and no box carries a size of its own any more", () => {
|
||||
assert.ok(!/#dedicatedDealerToggle\s*\{/.test(CSS),
|
||||
"one checkbox is still sized by id, which is how five got left behind");
|
||||
assert.ok(!/width:\s*22px/.test(ruleFor(".potWizardChoice input")),
|
||||
"the painted box restates its size instead of taking the one above");
|
||||
});
|
||||
|
||||
// A stated width is only a suggestion to a flex item. The coarse-pointer block had
|
||||
// already been bitten by this and said so; the mouse case had not.
|
||||
test("a swatch in a crowded row keeps the width it was given", () => {
|
||||
assert.match(ruleFor(".chipColorInput"), /flex:\s*none/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what painting a control costs, and who pays it", () => {
|
||||
// If a future round paints another one, this is the bill it has to settle too.
|
||||
test("nothing gives up the platform's drawing without answering forced colours", () => {
|
||||
const forced = atRule("(forced-colors: active)");
|
||||
const painted = [...CSS.matchAll(/\n([^\n{}]+)\s*\{[^}]*appearance:\s*none/g)]
|
||||
.map(m => m[1].trim());
|
||||
assert.ok(painted.length > 0, "nothing is painted, so this test is measuring nothing");
|
||||
painted.forEach(selector => {
|
||||
assert.ok(forced.includes(selector),
|
||||
`\`${selector}\` drops the platform's control but has no forced-colors answer`);
|
||||
});
|
||||
});
|
||||
|
||||
// A tick drawn as a background is gone in forced-colors mode; a system keyword is
|
||||
// the only colour guaranteed to survive, because it *is* the palette.
|
||||
test("the tick that is drawn rather than typed survives the OS repainting it", () => {
|
||||
const forced = atRule("(forced-colors: active)");
|
||||
assert.match(forced, /\.potWizardChoice input::before\s*\{[^}]*background:\s*CanvasText/);
|
||||
});
|
||||
|
||||
test("and so does the row's own chosen state, which is a gradient", () => {
|
||||
const forced = atRule("(forced-colors: active)");
|
||||
assert.match(forced, /\.potWizardChoice\.chosen\s*\{[^}]*outline:\s*2px solid Highlight/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the other page of the same tool", () => {
|
||||
const QUICKSPLIT = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "quicksplit.css"), "utf8");
|
||||
|
||||
// It is the darker of the two pages and is opened from the felt mid-hand. It has no
|
||||
// checkboxes to go white, but scrollbars, caret and selection were still drawn for
|
||||
// a light page.
|
||||
test("Quick Split says what colour it is too", () => {
|
||||
assert.match(QUICKSPLIT, /:root\s*\{[^}]*color-scheme:\s*dark/);
|
||||
});
|
||||
});
|
||||
@@ -175,8 +175,8 @@ describe("the rows that stay quiet until they have news", () => {
|
||||
});
|
||||
|
||||
describe("what the row is allowed to be", () => {
|
||||
const ROWS = ["glanceTableSetup", "glanceStandings", "glanceWaiting",
|
||||
"glanceHands", "glanceChips", "glanceNotes"];
|
||||
const ROWS = ["glanceTableSetup", "glanceWhileYouDeal", "glanceStandings",
|
||||
"glanceWaiting", "glanceHands", "glanceChips", "glanceNotes"];
|
||||
|
||||
test("every glance the sidebar renders is one this knows about", () => {
|
||||
// The list drives the DOM writes, so a glance computed and never listed is a
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// The number the pot wizard prints beside a name under the words "up to".
|
||||
//
|
||||
// It was the player's own remaining contribution. That is not what winning pays them,
|
||||
// and on the screen that moves the money the gap was large and in the wrong direction:
|
||||
// three players in for $250 each showed "up to $250" on all three rows while the prompt
|
||||
// directly above said "$765 is in play" — and $765 is what the winner was actually
|
||||
// handed. A dealer who trusted the row would have short-paid by two thirds. With a real
|
||||
// side pot it was worse in a more confusing way: the breakdown box at the top of the
|
||||
// same dialog announced "Main pot $765 · all 3", and Bob's row three lines under it
|
||||
// said "up to $250".
|
||||
//
|
||||
// The ceiling is now what resolveRound will actually pay, and the test that matters is
|
||||
// that it stays that: every case below checks the label against the function that moves
|
||||
// the chips rather than against a number written out by hand.
|
||||
//
|
||||
// It is also printed only where it binds. A ceiling equal to everything on the table is
|
||||
// the prompt's own figure repeated once per row — redundant data-ink, in Tufte's sense
|
||||
// — and the rows it does appear on are exactly the hands where picking that winner
|
||||
// leaves a side pot still to award, which is the one time a dealer needs telling.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { PotCalculator, Money } = require("./load");
|
||||
|
||||
const p = (id, contribution, folded = false) => ({ id, contribution, folded });
|
||||
const entriesOf = (...players) => PotCalculator.buildEntries(players);
|
||||
|
||||
// What the wizard would hand this player if their row were the one ticked.
|
||||
const paysOut = (entries, id) => PotCalculator.resolveRound(entries, [id]).payoutPool;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what a row promises is what the round pays", () => {
|
||||
test("a pot everybody covered pays the whole thing, dead blinds included", () => {
|
||||
// Three in for $250, two blinds folded before the raise.
|
||||
const entries = entriesOf(p("chris", 250), p("bob", 250), p("dee", 250),
|
||||
p("sb", 5, true), p("bb", 10, true));
|
||||
for (const id of ["chris", "bob", "dee"]) {
|
||||
assert.equal(PotCalculator.ceilingFor(entries, id), 765);
|
||||
assert.equal(PotCalculator.ceilingFor(entries, id), paysOut(entries, id));
|
||||
}
|
||||
});
|
||||
|
||||
test("the short all-in's ceiling is the main pot, not their own stake", () => {
|
||||
// Bob is all in for 250; Christopher and Dee go on to 600.
|
||||
const entries = entriesOf(p("chris", 600), p("bob", 250), p("dee", 600),
|
||||
p("sb", 5, true), p("bb", 10, true));
|
||||
assert.equal(PotCalculator.ceilingFor(entries, "bob"), 765);
|
||||
assert.equal(PotCalculator.ceilingFor(entries, "chris"), 1465);
|
||||
assert.equal(PotCalculator.ceilingFor(entries, "dee"), 1465);
|
||||
});
|
||||
|
||||
// The property, over every shape the wizard can reach: a ceiling that disagreed
|
||||
// with the payout would be the same defect again under a different number.
|
||||
test("across every shape of pot, the ceiling is the payout", () => {
|
||||
const shapes = [
|
||||
[p("a", 10), p("b", 10)],
|
||||
[p("a", 10), p("b", 25), p("c", 25)],
|
||||
[p("a", 5), p("b", 50), p("c", 120), p("d", 120)],
|
||||
[p("a", 33.33), p("b", 66.67), p("c", 100)],
|
||||
[p("a", 20), p("b", 20), p("folder", 20, true)],
|
||||
[p("a", 1), p("b", 2), p("c", 3), p("d", 4), p("e", 5)],
|
||||
[p("short", 0.05), p("deep", 1000)],
|
||||
];
|
||||
shapes.forEach((shape, n) => {
|
||||
const entries = entriesOf(...shape);
|
||||
PotCalculator.getContenders(entries).forEach(c => {
|
||||
assert.equal(PotCalculator.ceilingFor(entries, c.id), paysOut(entries, c.id),
|
||||
`shape ${n}: ${c.id}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("a second round asks about what is left, not what was there", () => {
|
||||
const entries = entriesOf(p("chris", 600), p("bob", 250), p("dee", 600));
|
||||
const after = PotCalculator.resolveRound(entries, ["bob"]).updatedEntries;
|
||||
// Bob took $750 of the $1,450; $700 is left between the two deep stacks.
|
||||
assert.equal(PotCalculator.totalPot(after), 700);
|
||||
assert.equal(PotCalculator.ceilingFor(after, "chris"), 700);
|
||||
assert.equal(PotCalculator.ceilingFor(after, "dee"), 700);
|
||||
});
|
||||
|
||||
test("somebody already paid out has nothing left to be owed", () => {
|
||||
const entries = entriesOf(p("a", 100), p("b", 100));
|
||||
const after = PotCalculator.resolveRound(entries, ["a"]).updatedEntries;
|
||||
assert.equal(PotCalculator.ceilingFor(after, "a"), 0);
|
||||
});
|
||||
|
||||
test("a name nobody put chips in for is worth nothing rather than an error", () => {
|
||||
assert.equal(PotCalculator.ceilingFor(entriesOf(p("a", 10)), "ghost"), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("and it is only said where it binds", () => {
|
||||
test("a pot everybody covered says its figure once, in the prompt", () => {
|
||||
const entries = entriesOf(p("chris", 250), p("bob", 250), p("dee", 250),
|
||||
p("sb", 5, true), p("bb", 10, true));
|
||||
["chris", "bob", "dee"].forEach(id =>
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, id), null));
|
||||
});
|
||||
|
||||
test("only the player who is in for less carries a number", () => {
|
||||
const entries = entriesOf(p("chris", 600), p("bob", 250), p("dee", 600),
|
||||
p("sb", 5, true), p("bb", 10, true));
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, "bob"), 765);
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, "chris"), null);
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, "dee"), null);
|
||||
});
|
||||
|
||||
// Two short stacks at different heights: each is capped by the one above, so both
|
||||
// rows have something to say and they do not say the same thing.
|
||||
test("two all-ins at different heights each carry their own", () => {
|
||||
const entries = entriesOf(p("small", 50), p("middle", 120), p("big", 400));
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, "small"), 150);
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, "middle"), 290);
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, "big"), null);
|
||||
});
|
||||
|
||||
test("the deepest player is never the one capped", () => {
|
||||
const entries = entriesOf(p("a", 10), p("b", 50), p("c", 900));
|
||||
assert.equal(PotCalculator.bindingCeilingFor(entries, "c"), null);
|
||||
assert.equal(PotCalculator.ceilingFor(entries, "c"), PotCalculator.totalPot(entries));
|
||||
});
|
||||
|
||||
// Dead money is owed to whoever wins the layer it fell in, so it lifts every
|
||||
// ceiling — including the short stack's, which is the whole reason Bob's row reads
|
||||
// $765 rather than $750.
|
||||
test("a folded player's chips lift the ceilings rather than sitting outside them", () => {
|
||||
const live = entriesOf(p("a", 100), p("b", 100));
|
||||
const withDead = entriesOf(p("a", 100), p("b", 100), p("folder", 40, true));
|
||||
assert.equal(PotCalculator.ceilingFor(live, "a"), 200);
|
||||
assert.equal(PotCalculator.ceilingFor(withDead, "a"), 240);
|
||||
});
|
||||
|
||||
test("money in cents does not drift", () => {
|
||||
const entries = entriesOf(p("a", 0.05), p("b", 0.05), p("c", 0.05));
|
||||
assert.equal(PotCalculator.ceilingFor(entries, "a"), Money.round(0.15));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wizard asks the calculator rather than reaching for the stake", () => {
|
||||
const SRC = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "potWizard.js"), "utf8");
|
||||
|
||||
test("the row's number comes from bindingCeilingFor", () => {
|
||||
assert.match(SRC, /PotCalculator\.bindingCeilingFor\(this\.entries, c\.id\)/);
|
||||
});
|
||||
|
||||
test("and nothing prints a contender's own stake under those words any more", () => {
|
||||
assert.ok(!/up to \$\{Money\.format\(c\.remaining\)\}/.test(SRC),
|
||||
"the wizard still prints the player's own contribution as their ceiling");
|
||||
});
|
||||
});
|
||||
+142
-29
@@ -28,7 +28,25 @@ const VIEWPORTS = [
|
||||
const OVERLAYS = [
|
||||
["rulings", () => document.getElementById("openRulingsBtn").click()],
|
||||
["seat menu", () => SeatMenu.open(HoldemState.load(), {}, 0)],
|
||||
["card picker", () => { document.getElementById("cardPickerOverlay").hidden = false; }],
|
||||
// Opened the way a dealer opens it, with cards already out. This used to un-hide the
|
||||
// overlay and nothing else, so the grid inside it was whatever `paint()` had last
|
||||
// drawn — which on a freshly loaded page is nothing at all. For the whole life of
|
||||
// this sweep the card picker was audited as an empty sheet: no slots, no fifty-two
|
||||
// targets, no dealt cards, none of the three things it is made of.
|
||||
["card picker", () => {
|
||||
const state = HoldemState.load();
|
||||
const playing = state.seats.filter(seat => seat.inHand);
|
||||
if (playing[0]) playing[0].cards = ["AS", "KH"];
|
||||
if (playing[1]) playing[1].cards = ["QD", "QC"];
|
||||
state.board = ["7C", "2H", "TS", "", ""];
|
||||
const empty = playing[2] || playing[0];
|
||||
CardPicker.open(state, { onPick: () => {}, onDone: () => {} }, state.seats.indexOf(empty), 0);
|
||||
// And with its answer showing. The line that says where a dealt card went is the
|
||||
// one piece of this sheet that is empty until something is pressed, so pressing
|
||||
// one is the only way the sweep ever sees it holding text.
|
||||
const out = document.querySelector('#cardPickerGrid .pickerCard[aria-disabled="true"]');
|
||||
if (out) out.click();
|
||||
}],
|
||||
["add chips", () => AddChips.open(HoldemState.load(), 0, () => {})],
|
||||
["pot wizard", () => document.getElementById("resolvePotBtn").click()],
|
||||
];
|
||||
@@ -110,23 +128,88 @@ async function main() {
|
||||
const warnings = [];
|
||||
let checks = 0;
|
||||
|
||||
// Twice over: once with a mouse, once with a thumb. The 44px rule stands itself
|
||||
// down where `hover: hover` says there's a pointer, and a headless desktop browser
|
||||
// is a pointer — so a single pass here has never once measured a touch target,
|
||||
// however many viewports it visited. The phone-sized ones are exactly the pass that
|
||||
// needs it.
|
||||
for (const touch of [false, true]) {
|
||||
// Three passes over the same surfaces.
|
||||
//
|
||||
// A mouse and a thumb, because the 44px rule stands itself down where `hover: hover`
|
||||
// says there's a pointer, and a headless desktop browser is a pointer — so a single
|
||||
// pass has never once measured a touch target, however many viewports it visited.
|
||||
//
|
||||
// And a thumb on a phone whose owner has turned the text up. Every length in this
|
||||
// stylesheet that isn't a structural clearance is in `rem`, which is the right way
|
||||
// round and means the whole page follows the browser's font size — including out of
|
||||
// the side of the window, which is what it was doing: at 20px, which is the 125%
|
||||
// an Android text-size slider gives you, "Resolve Pot" ran 23px off the right of a
|
||||
// 360px phone and took the document's scroll width with it. Every check in the
|
||||
// audit already knew how to catch that; not one of them had ever been run at a size
|
||||
// anybody actually sets.
|
||||
//
|
||||
// One shape it is not held to, and the arithmetic for why. Twelve players all-in on
|
||||
// a 360x640 phone is the tallest the action bar ever gets — the status block and the
|
||||
// pot breakdown both grow with the text — and at 20px it stands 283px of the 640,
|
||||
// which with the header leaves the felt 290x163 where it had 296x262. Twelve pucks
|
||||
// 44px wide (the floor a thumb needs; there is nothing narrower to go to) and 62px
|
||||
// tall do not fit around an ellipse 163px deep: two per side is all the height there
|
||||
// is, and the sweep reports it as seats 9, 10 and 11 written across each other.
|
||||
//
|
||||
// That is a geometry with no answer rather than a fault with a fix, so it is
|
||||
// reported as a note every run instead of a failure every run. A sweep that is
|
||||
// always red stops being read — the same reasoning as the 30% furniture warning.
|
||||
//
|
||||
// The packed table is the same arithmetic and was only ever passing by luck.
|
||||
// Measured on the same 360x640 phone at 20px, with nobody all-in: the felt is
|
||||
// 330x294, the pucks are 44x63, and five of them sit down each side — 315px of puck
|
||||
// into 294px of felt, with the pitch between neighbours at 35px and 51px against a
|
||||
// puck 63px tall. They overlap by construction, worst case 12px, and they always
|
||||
// have. What kept the sweep green was the hit-test probes landing in the gaps
|
||||
// between the glyphs; a change anywhere that moves the ring by three pixels moves
|
||||
// the probes onto the overlap and the run goes red for something nothing in the
|
||||
// change touched. Twice this round, from a stylesheet reset and from a sidebar
|
||||
// panel getting shorter.
|
||||
//
|
||||
// So both twelve-handed shapes are notes at this text size rather than failures,
|
||||
// for the same stated reason. Not an exemption for the felt in general: at 16px the
|
||||
// same table is held to the same standard as everything else, and the two passes
|
||||
// above it are unchanged.
|
||||
const CROWDED_BEYOND_HELP = ["all in", "packed"];
|
||||
const LARGE_TEXT_PX = 20;
|
||||
const PHONES = VIEWPORTS.filter(([w, h]) => Math.min(w, h) <= 430);
|
||||
const PASSES = [
|
||||
{ hand: "mouse", touch: false, viewports: VIEWPORTS },
|
||||
{ hand: "touch", touch: true, viewports: VIEWPORTS },
|
||||
{ hand: "large text", touch: true, viewports: PHONES, rootPx: LARGE_TEXT_PX, soft: CROWDED_BEYOND_HELP },
|
||||
];
|
||||
|
||||
for (const { hand, touch, viewports, rootPx, soft } of PASSES) {
|
||||
const context = await browser.newContext(touch
|
||||
? { hasTouch: true, isMobile: true, deviceScaleFactor: 3 }
|
||||
: {});
|
||||
// Before any of the page's own scripts, so its listener is registered first and
|
||||
// the size is in place before main.js measures anything. A stylesheet rather
|
||||
// than an inline style because that is what a browser font-size setting is: the
|
||||
// root's used size, from first paint, not something applied afterwards.
|
||||
if (rootPx) {
|
||||
await context.addInitScript((px) => {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `html { font-size: ${px}px }`;
|
||||
document.head.appendChild(style);
|
||||
}, { once: true });
|
||||
}, rootPx);
|
||||
}
|
||||
const page = await context.newPage();
|
||||
const hand = touch ? "touch" : "mouse";
|
||||
|
||||
for (const [width, height, rawLabel] of VIEWPORTS) {
|
||||
for (const [width, height, rawLabel] of viewports) {
|
||||
await page.setViewportSize({ width, height });
|
||||
|
||||
for (const [shape, seats, players, board, shoves] of TABLES) {
|
||||
const label = `${rawLabel} · ${shape} · ${hand}`;
|
||||
// Measured either way; where the shape has no answer at this text size,
|
||||
// said in the notes rather than counted against the run.
|
||||
const record = (where, offenders) => {
|
||||
if (!offenders.length) return;
|
||||
if (soft && soft.includes(shape)) warnings.push(`${where}: ${offenders.join("; ")}`);
|
||||
else failures.push({ where, offenders });
|
||||
};
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.evaluate(seedTable, [seats, players, board]);
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
@@ -201,7 +284,7 @@ async function main() {
|
||||
|
||||
const table = await page.evaluate(() => LayoutAudit.runAll());
|
||||
checks++;
|
||||
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
|
||||
record(`table · ${label} ${width}x${height}`, table.failed.flatMap(f => f.offenders));
|
||||
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
|
||||
|
||||
// The raise pad, which is part of the bar rather than an overlay and so
|
||||
@@ -224,12 +307,8 @@ async function main() {
|
||||
const pad = await page.evaluate(() => LayoutAudit.runAll({
|
||||
occlusionSelector: "#actionBar button, #actionBar input",
|
||||
}));
|
||||
if (!pad.clean) {
|
||||
failures.push({
|
||||
where: `raise pad · ${label} ${width}x${height}`,
|
||||
offenders: pad.failed.flatMap(f => f.offenders),
|
||||
});
|
||||
}
|
||||
record(`raise pad · ${label} ${width}x${height}`,
|
||||
pad.failed.flatMap(f => f.offenders));
|
||||
await page.evaluate(() => {
|
||||
const cancel = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /^Cancel/.test(b.textContent.trim()));
|
||||
@@ -243,34 +322,67 @@ async function main() {
|
||||
// spent already.
|
||||
if (shape !== TABLES[0][0]) continue;
|
||||
|
||||
// The whole audit, not the one check.
|
||||
//
|
||||
// This ran `occlusion()` and nothing else — one rule of twenty-five — so
|
||||
// for the sweep's entire life the five sheets a dealer spends half the
|
||||
// night in had never been measured for touch targets, contrast, control
|
||||
// names, text spill or anything else. The three rules that made that a
|
||||
// reasonable thing to do are modal-aware now: two of them ask about the
|
||||
// fixed bar, which is not a question while a scrim is covering it, and
|
||||
// the third called every card below the fold of a sheet that scrolls an
|
||||
// unreachable button.
|
||||
for (const [name, open] of OVERLAYS) {
|
||||
await page.evaluate(open);
|
||||
await page.waitForTimeout(180);
|
||||
checks++;
|
||||
const result = await page.evaluate(() => {
|
||||
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
|
||||
if (!overlay) return { offenders: [] };
|
||||
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
|
||||
const sheet = LayoutAudit.runAll({
|
||||
occlusionSelector: overlay
|
||||
? `#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`
|
||||
: undefined,
|
||||
});
|
||||
return { offenders: sheet.failed.flatMap(f => f.offenders), warnings: sheet.warnings };
|
||||
});
|
||||
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
|
||||
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
|
||||
record(`${name} · ${label} ${width}x${height}`, result.offenders);
|
||||
result.warnings.forEach(w => warnings.push(`${name} · ${label}: ${w}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [width, height, rawLabel] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
|
||||
for (const [width, height, rawLabel] of viewports.filter((_, i) => i % 4 === 0)) {
|
||||
const label = `${rawLabel} · ${hand}`;
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
|
||||
await page.evaluate(() => {
|
||||
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
|
||||
if (button) button.click();
|
||||
});
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
checks++;
|
||||
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
|
||||
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
|
||||
result.warnings.forEach(w => warnings.push(`quick split · ${label}: ${w}`));
|
||||
|
||||
// Both halves of the screen. The sweep used to switch straight to the all-in
|
||||
// mode and audit only that, so the mode the screen opens in — the common one,
|
||||
// a pot and a number of ways — was never looked at once, which is how a bar
|
||||
// with 252px of nothing in it lasted this long. The ways stepper only exists
|
||||
// past five, so the even pass opens it before measuring.
|
||||
const modes = [
|
||||
["even", () => {
|
||||
const beyond = [...document.querySelectorAll("#waysChoices .choiceChip")]
|
||||
.find(b => /\+$/.test(b.textContent.trim()));
|
||||
if (beyond) beyond.click();
|
||||
}],
|
||||
["all-in", () => {
|
||||
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
|
||||
if (button) button.click();
|
||||
}],
|
||||
];
|
||||
|
||||
for (const [mode, arrange] of modes) {
|
||||
await page.evaluate(arrange);
|
||||
await page.waitForTimeout(120);
|
||||
checks++;
|
||||
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
|
||||
if (!result.clean) failures.push({ where: `quick split · ${mode} · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
|
||||
result.warnings.forEach(w => warnings.push(`quick split · ${mode} · ${label}: ${w}`));
|
||||
}
|
||||
}
|
||||
|
||||
await context.close();
|
||||
@@ -278,7 +390,8 @@ async function main() {
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`${checks} audits across ${VIEWPORTS.length} viewports, with a mouse and with a thumb`);
|
||||
console.log(`${checks} audits across ${VIEWPORTS.length} viewports — with a mouse, with a thumb, ` +
|
||||
`and with a thumb at ${LARGE_TEXT_PX}px of root text on ${PHONES.length} phones`);
|
||||
warnings.forEach(w => console.log(` note: ${w}`));
|
||||
if (failures.length === 0) {
|
||||
console.log("clean");
|
||||
|
||||
@@ -204,6 +204,36 @@ describe("the blind badge counts", () => {
|
||||
"the same distance at both ends, or one arc is charged for the other's badge");
|
||||
});
|
||||
|
||||
test("and the dealer chip shares that edge on exactly the same terms", () => {
|
||||
// It moved out of the puck's top-right corner and onto the rim beside the badges,
|
||||
// which is only free while it hangs off by the same 5px they do. A pixel more and
|
||||
// the ring is charging every chair for a distance it isn't measuring.
|
||||
const top = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])top:\s*(-?[\d.]+)px/);
|
||||
const bottom = CSS.match(/\.seat\.seatBelowCentre \.dealerChip \{[^}]*?(?<![\w-])bottom:\s*(-?[\d.]+)px/);
|
||||
assert.ok(top && bottom, "the chip should hang off both arcs, like the badge");
|
||||
assert.equal(Math.abs(Number(top[1])), Render.SEAT_BADGE_OVERHANG_PX);
|
||||
assert.equal(Math.abs(Number(bottom[1])), Render.SEAT_BADGE_OVERHANG_PX);
|
||||
});
|
||||
|
||||
test("and it stays inside the puck's own width, where the badges are", () => {
|
||||
// Sideways is the direction that lands on the seat next door. This chip has been
|
||||
// there: at -10px it took the centre of seat 1's taps and gave the button to seat
|
||||
// 9. `right` is a positive inset for the same reason `left: 2px` is.
|
||||
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(-?[\d.]+)px/);
|
||||
assert.ok(inset, "the chip should be inset from the right edge");
|
||||
assert.ok(Number(inset[1]) >= 0, "a negative inset hangs it over the neighbour");
|
||||
});
|
||||
|
||||
test("and it is short enough to clear the name it sits above", () => {
|
||||
// The tight ring is the close one: 1px of border, 3px of padding and 4px of
|
||||
// margin put the name's top 8px below the puck's, and the chip reaches
|
||||
// height - overhang below it.
|
||||
const height = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])height:\s*([\d.]+)px/);
|
||||
assert.ok(height, "the chip should have a height to check");
|
||||
assert.ok(Number(height[1]) - Render.SEAT_BADGE_OVERHANG_PX <= 8,
|
||||
"the chip dips into the name row on the tight ring");
|
||||
});
|
||||
|
||||
test("and it is counted once, not once per seat", () => {
|
||||
// seatsCollide is given one box and compares centres against it, so the
|
||||
// separation it asks for is one puck plus one badge. That is exactly right for
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// What the long-press menu offers, and in what shape.
|
||||
//
|
||||
// It had grown to eleven actions drawn as eleven identical full-width bars. Measured
|
||||
// on the fullest menu the tool can build — a live seat that isn't the dealer, blinds
|
||||
// still the only money in, an override already set — the sheet came to 796px and took
|
||||
// 85% of every viewport in the sweep. It scrolled on all four, and Cancel was off the
|
||||
// bottom of the screen on all four: 79px past it on a 390px phone, 252px on a 360px
|
||||
// one, 448px in landscape. Apple puts the comfortable number for an action sheet at
|
||||
// three to five and says not to let one scroll at all.
|
||||
//
|
||||
// Nothing was cut — every one of the eleven is the only route to something, and
|
||||
// "Blinds follow the button again" is the only route in the whole tool to clearing an
|
||||
// override. They were sorted by *when* instead: what you tap with cards in the air
|
||||
// keeps the full-width bars, and the housekeeping goes to a two-column grid under a
|
||||
// caption. Same fullest menu afterwards: 650px, 77%, no scroll, and the way out is in
|
||||
// a header that can't scroll away.
|
||||
//
|
||||
// Two of these are answered by running the code — which actions a seat is offered,
|
||||
// and which group each lands in. The rest are layout decisions, checked as text
|
||||
// against the markup and the stylesheet.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Betting, HoldemState, SeatMenu, makeTable } = require("./load");
|
||||
|
||||
const PAGE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
const sheetMarkup = () => {
|
||||
const at = PAGE.indexOf(`<div id="seatMenuOverlay"`);
|
||||
assert.ok(at >= 0, "there is no seat menu overlay");
|
||||
return PAGE.slice(at, PAGE.indexOf("</div>\n\n", at));
|
||||
};
|
||||
|
||||
// The rule block for a selector, so a test asks about one rule rather than about
|
||||
// whether a string happens to appear anywhere in 6000 lines.
|
||||
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));
|
||||
};
|
||||
|
||||
// The fullest menu the tool can build, aimed at seat 3.
|
||||
function fullestMenu() {
|
||||
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3, 4], stack: 500, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
state.blindOverride = { sbIndex: 1, bbIndex: 2 };
|
||||
state.waitingList = [{ id: "w1", name: "Nina" }];
|
||||
SeatMenu.state = state;
|
||||
SeatMenu.hooks = {};
|
||||
return { state, actions: SeatMenu.actionsFor(state.seats[3], 3) };
|
||||
}
|
||||
|
||||
const labels = actions => actions.map(a => a.label);
|
||||
const inGroup = (actions, group) => labels(actions.filter(a => a.group === group));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("everything the menu used to offer, it still offers", () => {
|
||||
test("all eleven are there on the fullest menu", () => {
|
||||
const { actions } = fullestMenu();
|
||||
assert.deepEqual(labels(actions).sort(), [
|
||||
"Add chips…",
|
||||
"All-in",
|
||||
"Big blind",
|
||||
"Blinds follow the button again",
|
||||
"Cash out…",
|
||||
"Edit seat…",
|
||||
"Fold",
|
||||
"Move to…",
|
||||
"Sit out",
|
||||
"Small blind",
|
||||
"The button",
|
||||
]);
|
||||
});
|
||||
|
||||
// The three that lost words lost them to the caption above them, not to brevity
|
||||
// for its own sake: "Give this seat" is what makes "Small blind" a verb, and a
|
||||
// verb that short is what fits a half-width tile on one line.
|
||||
test("the three that were shortened are the three the caption speaks for", () => {
|
||||
const { actions } = fullestMenu();
|
||||
assert.deepEqual(inGroup(actions, "position"), [
|
||||
"The button", "Small blind", "Big blind", "Blinds follow the button again",
|
||||
]);
|
||||
});
|
||||
|
||||
// This menu is the only place in the tool that offers it. If it ever stops being
|
||||
// offered here, a dealer who names a blind by hand can never take it back.
|
||||
test("the only way out of a blind override is still here", () => {
|
||||
const { actions } = fullestMenu();
|
||||
assert.ok(labels(actions).includes("Blinds follow the button again"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("a group is when you do it, not what it touches", () => {
|
||||
test("the hand keeps only what a dealer reaches for with cards on the table", () => {
|
||||
const { actions } = fullestMenu();
|
||||
assert.deepEqual(inGroup(actions, "hand"), ["Fold", "All-in"]);
|
||||
});
|
||||
|
||||
test("a folded player's one move is in the hand too", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
|
||||
Betting.beginHand(state);
|
||||
state.seats[2].folded = true;
|
||||
SeatMenu.state = state;
|
||||
SeatMenu.hooks = {};
|
||||
const actions = SeatMenu.actionsFor(state.seats[2], 2);
|
||||
assert.deepEqual(inGroup(actions, "hand"), ["Un-fold"]);
|
||||
});
|
||||
|
||||
// Seating the front of the queue is not housekeeping. A chair opens between two
|
||||
// hands with people watching it, and the dealer fills it then or not at all.
|
||||
test("seating the next player off the rail is a hand action", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
|
||||
// makeTable names every chair; an open one has nobody on it.
|
||||
state.seats[4].name = "";
|
||||
state.waitingList = [{ id: "w1", name: "Nina" }];
|
||||
SeatMenu.state = state;
|
||||
SeatMenu.hooks = {};
|
||||
const actions = SeatMenu.actionsFor(state.seats[4], 4);
|
||||
assert.deepEqual(inGroup(actions, "hand"), ["Seat Nina"]);
|
||||
});
|
||||
|
||||
test("every action lands in a group the sheet knows how to draw", () => {
|
||||
const { actions } = fullestMenu();
|
||||
const known = ["hand", ...SeatMenu.GROUPS.map(g => g.name)];
|
||||
actions.forEach(action => {
|
||||
assert.ok(known.includes(action.group),
|
||||
`"${action.label}" is in group "${action.group}", which nothing draws`);
|
||||
});
|
||||
});
|
||||
|
||||
// A caption for nothing is worse than no caption, and this group empties often:
|
||||
// past the blinds there is nothing to give a seat but the button.
|
||||
test("a group that has nothing to say is empty rather than thin", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 3);
|
||||
SeatMenu.state = state;
|
||||
SeatMenu.hooks = {};
|
||||
const actions = SeatMenu.actionsFor(state.seats[3], 3);
|
||||
assert.deepEqual(inGroup(actions, "position"), ["The button"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the one action here that is not about this seat", () => {
|
||||
// It puts the whole table's blinds back on automatic. Spanning the grid is what
|
||||
// stops it reading as a fourth position to give.
|
||||
test("the override reset spans the grid and nothing else does", () => {
|
||||
const { actions } = fullestMenu();
|
||||
assert.deepEqual(labels(actions.filter(a => a.wide)),
|
||||
["Blinds follow the button again"]);
|
||||
});
|
||||
|
||||
test("and it is drawn lighter than the Cancel underneath it", () => {
|
||||
const rule = ruleFor(".seatMenuBtn.menuTileWide");
|
||||
assert.match(rule, /grid-column:\s*1 \/ -1/);
|
||||
assert.match(rule, /border-style:\s*dashed/);
|
||||
// Lighter, not smaller. A thumb does not care how rare an action is, so it
|
||||
// keeps the 44px every other tile has rather than shrinking out of reach.
|
||||
assert.ok(!/min-height/.test(rule),
|
||||
"the wide row sets its own height, which takes it under the touch floor");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sheet a dealer reads it in", () => {
|
||||
test("the header is stuck to the top, so whose menu this is cannot scroll away", () => {
|
||||
const rule = ruleFor(".seatMenuHeader");
|
||||
assert.match(rule, /position:\s*sticky/);
|
||||
assert.match(rule, /top:\s*0/);
|
||||
// A sticky header inside a padded scroller shows the list sliding through the
|
||||
// gap either side of it unless it reaches the sheet's own edges.
|
||||
assert.match(rule, /margin:\s*0 calc\(var\(--sp-4\) \* -1\)/);
|
||||
assert.match(rule, /background:/);
|
||||
});
|
||||
|
||||
test("and the sheet keeps no padding above it to slide through", () => {
|
||||
assert.match(ruleFor("#seatMenuSheet"), /padding:\s*0 var\(--sp-4\)/);
|
||||
});
|
||||
|
||||
// NN/g is explicit that a grab handle is not a close affordance. This sheet is the
|
||||
// one that can outgrow the screen, so it is also the one that most needs a way out
|
||||
// in the part that cannot leave.
|
||||
test("there is a real close button, not just a grip", () => {
|
||||
const markup = sheetMarkup();
|
||||
assert.match(markup, /id="seatMenuCloseBtn"/);
|
||||
assert.match(markup, /aria-label="Close this menu"/);
|
||||
});
|
||||
|
||||
// Modal focuses a dialog's first control, and the first control used to be Fold.
|
||||
// The APG says to keep initial focus off the destructive choice; a close button at
|
||||
// the top of the markup is what puts it there without a special case.
|
||||
test("the first control in the sheet is the one that does nothing", () => {
|
||||
const markup = sheetMarkup();
|
||||
assert.ok(markup.indexOf("seatMenuCloseBtn") < markup.indexOf("seatMenuActions"),
|
||||
"the actions come before the close button, so focus opens on Fold again");
|
||||
});
|
||||
|
||||
// Escape finds the first [data-modal-close] in the overlay. That has to be the one
|
||||
// that shuts the sheet, not the one at the foot that steps back inside it.
|
||||
test("Escape closes the sheet rather than stepping back inside it", () => {
|
||||
const markup = sheetMarkup();
|
||||
const closer = markup.indexOf("data-modal-close");
|
||||
assert.ok(closer >= 0 && closer < markup.indexOf("seatMenuActions"),
|
||||
"Escape does not reach a closer above the action list");
|
||||
assert.ok(!/id="seatMenuCancelBtn"[^>]*data-modal-close/.test(markup),
|
||||
"the button at the foot still answers Escape, so Escape only goes back");
|
||||
});
|
||||
});
|
||||
|
||||
describe("two columns, and what they are for", () => {
|
||||
test("the housekeeping half is a grid", () => {
|
||||
assert.match(ruleFor(".seatMenuGrid"), /grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/);
|
||||
});
|
||||
|
||||
// Half the width and a step quieter, but not below the floor a fingertip needs.
|
||||
test("a tile is quieter than a bar without being smaller than a thumb", () => {
|
||||
const rule = ruleFor(".seatMenuBtn.menuTile");
|
||||
assert.match(rule, /min-height:\s*44px/);
|
||||
assert.match(rule, /font-size:\s*var\(--fs-base\)/);
|
||||
});
|
||||
|
||||
test("a bar is still a bar", () => {
|
||||
assert.match(ruleFor(".seatMenuBtn"), /min-height:\s*52px/);
|
||||
});
|
||||
|
||||
// The dashed row and the Cancel under it are both full width. Two rows of the same
|
||||
// size touching, one of which changes the table and one of which changes nothing,
|
||||
// is a mistake waiting at the end of a scroll.
|
||||
test("the last action and the way out are not two rows touching", () => {
|
||||
assert.match(ruleFor("#seatMenuCancelBtn"), /margin-top:\s*var\(--sp-3\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the move picker is no longer a one-way door", () => {
|
||||
const MAIN = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "main.js"), "utf8");
|
||||
|
||||
// Opening it used to replace the whole sheet, and the only control left closed the
|
||||
// menu outright — a dealer who opened the seat list by mistake lost their place.
|
||||
test("the button at the foot goes back while there is an inside to go back to", () => {
|
||||
const wiring = MAIN.slice(MAIN.indexOf('getElementById("seatMenuCancelBtn")'));
|
||||
const body = wiring.slice(0, wiring.indexOf("});"));
|
||||
assert.match(body, /SeatMenu\.mode === "move"/);
|
||||
assert.match(body, /SeatMenu\.closeMovePicker\(\)/);
|
||||
assert.match(body, /SeatMenu\.close\(\)/);
|
||||
});
|
||||
|
||||
test("and the menu knows how to come back", () => {
|
||||
assert.equal(typeof SeatMenu.closeMovePicker, "function");
|
||||
SeatMenu.mode = "move";
|
||||
SeatMenu.state = { seats: [] };
|
||||
SeatMenu.seatIndex = 0;
|
||||
// render() needs a document, so the mode change is checked where it is made.
|
||||
assert.match(SeatMenu.closeMovePicker.toString(), /this\.mode = null/);
|
||||
});
|
||||
|
||||
test("every other chair is a destination, including the empty ones", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
SeatMenu.state = state;
|
||||
const seats = SeatMenu.destinationsFor(2).map(d => d.seat);
|
||||
assert.deepEqual(seats, [0, 1, 3, 4, 5]);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,28 @@
|
||||
// What the ring is allowed to be sized from.
|
||||
//
|
||||
// Two rows in every seat puck are usually empty — the hole-card line, and the pill that
|
||||
// shows what a player has put in. Both used to hold their space anyway, and the pill,
|
||||
// which has a background and a pill radius and side padding, painted an 8x11px green
|
||||
// lozenge when it had nothing to say: measured on seven of nine seats at an ordinary
|
||||
// table, a coloured mark on almost every seat standing for nothing.
|
||||
// resolveSeatLayout buys its answer with ten forced layouts — ten candidate widths, each
|
||||
// applied to the felt and read back — and caches it against this signature. So the
|
||||
// signature has to move whenever a puck's height can, and must not move otherwise: one
|
||||
// that changed on every action would throw the cache away on every tap, which is the
|
||||
// whole cost it exists to avoid.
|
||||
//
|
||||
// The space was not free either. seatRingRadii sizes the ring from the tallest puck on
|
||||
// it, so two rows nobody had filled were setting how far apart the seats had to sit. On
|
||||
// an iPhone 14 nine-handed the puck measured 78x88 with 25px of that reserved for
|
||||
// nothing; collapsed, the width search settles on 78x63. Twelve-handed on a Galaxy S8
|
||||
// the seats overlapped by 1556px² and now overlap by 87px².
|
||||
// It used to name four things: every player's name, the dealer's seat, whether anybody
|
||||
// had hole cards, and whether anybody had chips out. Three of those flip during an
|
||||
// ordinary hand. A dealer calling a bet paid ten forced layouts for it, and so did the
|
||||
// next street closing, and so did editing a name.
|
||||
//
|
||||
// The collapse is only safe if the layout re-measures when one of those rows fills,
|
||||
// because resolveSeatLayout caches its ten forced layouts against this signature. That
|
||||
// is what is checked here: the cache key has to move when the puck's shape can, and
|
||||
// stay still when it cannot — a signature that changed on every action would throw the
|
||||
// cache away on every tap, which is the cost it exists to avoid.
|
||||
// None of them can change a puck's height any more. The stylesheet holds three rows open
|
||||
// — name, amount, stack — at a height that does not depend on what is written in them:
|
||||
// the name gets one line whatever it says, the amount pill keeps its row when it is
|
||||
// empty, and the dealer's chip changes a padding rather than a line count. What is left
|
||||
// is the hole-card row, which still collapses, because whether a table tracks hole cards
|
||||
// is something it decides once rather than something that changes under a thumb.
|
||||
//
|
||||
// The other half of that bargain is in renderSeats: it measures the finished pucks
|
||||
// against the box the search settled on and asks again if they disagree. That is what
|
||||
// makes a wrong assumption here a stale frame rather than a stale session — and it is
|
||||
// what catches the first render of all, where the pucks are built empty and the search
|
||||
// that used to run against them was measuring nine blank boxes.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
@@ -32,37 +38,39 @@ const signatureOf = state => {
|
||||
const table = () => makeTable({ seatCount: 6, stack: 200 });
|
||||
|
||||
describe("the signature the ring layout is cached against", () => {
|
||||
test("moves when the first chips go in", () => {
|
||||
// Before the blinds every pill is empty and the pucks are short; after them two
|
||||
// seats are ~15px taller. A ring still sized from the first measurement is sized
|
||||
// for a puck that no longer exists.
|
||||
const state = table();
|
||||
const empty = signatureOf(state);
|
||||
state.seats[1].committed = 10;
|
||||
assert.notEqual(signatureOf(state), empty,
|
||||
"the ring would still be sized for pucks with no amount on them");
|
||||
});
|
||||
|
||||
test("and when a hole card is entered", () => {
|
||||
test("moves when a hole card is entered", () => {
|
||||
// The one row still allowed to collapse, and the one thing still worth
|
||||
// re-measuring for: a puck with cards on it is about 15px taller.
|
||||
const state = table();
|
||||
const none = signatureOf(state);
|
||||
state.seats[0].cards = ["AS", "KC"];
|
||||
assert.notEqual(signatureOf(state), none);
|
||||
});
|
||||
|
||||
test("but not again for the second player to put chips in", () => {
|
||||
// The whole point of a boolean over the table. The ring is sized from the
|
||||
// tallest puck on it, so once anybody has an amount the answer cannot change —
|
||||
// and re-measuring here would mean ten forced layouts on every single action.
|
||||
test("but not again for the second player to be dealt one", () => {
|
||||
// The point of a boolean over the table. The ring is sized from the tallest puck
|
||||
// on it, so once anybody has cards the answer cannot change.
|
||||
const state = table();
|
||||
state.seats[1].committed = 10;
|
||||
state.seats[0].cards = ["AS", "KC"];
|
||||
const first = signatureOf(state);
|
||||
state.seats[2].committed = 10;
|
||||
assert.equal(signatureOf(state), first);
|
||||
state.seats[3].committed = 250;
|
||||
state.seats[1].cards = ["2H", "7D"];
|
||||
assert.equal(signatureOf(state), first);
|
||||
});
|
||||
|
||||
test("and it stands still while the chips move", () => {
|
||||
// The expensive one. Somebody has money out for most of every hand and which
|
||||
// seats it is changes on every action, so this used to be a ten-layout re-search
|
||||
// twice a hand — and the pucks it was re-measuring for are the same height now
|
||||
// whether there is an amount in them or not.
|
||||
const state = table();
|
||||
const quiet = signatureOf(state);
|
||||
state.seats[1].committed = 10;
|
||||
assert.equal(signatureOf(state), quiet,
|
||||
"the amount pill holds its row whether or not it has an amount in it");
|
||||
state.seats[2].committed = 250;
|
||||
assert.equal(signatureOf(state), quiet);
|
||||
});
|
||||
|
||||
test("nor when an amount merely changes size", () => {
|
||||
const state = table();
|
||||
state.seats[1].committed = 10;
|
||||
@@ -72,54 +80,278 @@ describe("the signature the ring layout is cached against", () => {
|
||||
"a wider number is the amount fitter's problem, not the ring's");
|
||||
});
|
||||
|
||||
test("and it still moves for the things it always moved for", () => {
|
||||
// Renaming can wrap a name onto a second line, and the button's chip floats
|
||||
// into the name's first line, so both change how tall a puck measures.
|
||||
test("and it stands still for a rename", () => {
|
||||
// Renaming used to be able to wrap a name onto a second line. A name gets one
|
||||
// line now, and one that does not fit drops a word or steps its type down — see
|
||||
// fitSeatNames. Neither moves the row it sits in.
|
||||
const state = table();
|
||||
const base = signatureOf(state);
|
||||
state.seats[0].name = "Bartholomew Fairweather";
|
||||
const renamed = signatureOf(state);
|
||||
assert.notEqual(renamed, base);
|
||||
state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length;
|
||||
assert.notEqual(signatureOf(state), renamed);
|
||||
assert.equal(signatureOf(state), base);
|
||||
});
|
||||
|
||||
test("and two tables that differ only in where a name is split stay different", () => {
|
||||
// The names are joined on a control character no name can contain. Joined on
|
||||
// nothing, "ann" + "abel" and "anna" + "bel" are both "annabel" and one of those
|
||||
// tables would be laid out with the other's measurements — different names, so
|
||||
// different wrapping, so different puck heights.
|
||||
//
|
||||
// The first version of this used "Ann"/"Abel" against "Anna"/"Bel", which can
|
||||
// never collide: the capital letters land in different places, so it passed with
|
||||
// the separator taken out and proved nothing at all. Lower case is what makes it
|
||||
// a real test.
|
||||
const a = table();
|
||||
const b = table();
|
||||
a.seats[0].name = "ann"; a.seats[1].name = "abel";
|
||||
b.seats[0].name = "anna"; b.seats[1].name = "bel";
|
||||
assert.notEqual(signatureOf(a), signatureOf(b));
|
||||
test("and for the button moving round the table", () => {
|
||||
// The chip in the puck's corner takes 22px off the name's line. That is a
|
||||
// narrower name, not a taller puck, so the ring it sits on is the same ring.
|
||||
const state = table();
|
||||
const base = signatureOf(state);
|
||||
state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length;
|
||||
assert.equal(signatureOf(state), base);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a table nobody has sat down at", () => {
|
||||
test("has no amounts and no cards on it", () => {
|
||||
test("carries no flag at all", () => {
|
||||
// The empty-table case is the one the collapse is most visible on, and it is
|
||||
// also the one where a stray default could quietly keep both rows open forever.
|
||||
// also the one where a stray default could quietly keep the row open forever.
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
assert.match(signatureOf(state), /\|$/,
|
||||
"an untouched table should carry neither flag");
|
||||
assert.equal(signatureOf(state), "",
|
||||
"an untouched table should carry nothing");
|
||||
});
|
||||
|
||||
test("but a table with an ante on it does have an amount", () => {
|
||||
// computeContribution answers for a seat that has never been given a stack —
|
||||
// the table's base pot stands in for it — so a table dealt with "everyone antes
|
||||
// 5" has amounts on its pucks before anybody acts, and the ring has to know.
|
||||
test("and neither does one with an ante on it", () => {
|
||||
// computeContribution answers for a seat that has never been given a stack — the
|
||||
// table's base pot stands in for it — so a table dealt with "everyone antes 5"
|
||||
// has amounts on its pucks before anybody acts. That used to have to be in here.
|
||||
// The pucks are the same height with it as without, so it does not any more.
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 6);
|
||||
state.basePotAmount = 5;
|
||||
state.seats.forEach(p => { p.inHand = true; });
|
||||
assert.match(signatureOf(state), /a$/);
|
||||
assert.equal(signatureOf(state), "");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The other half: the stylesheet has to actually hold those rows open, or the signature
|
||||
// above is a promise about a puck that doesn't exist.
|
||||
//
|
||||
// Read as text, the way the seat sheet's rules and the reduced-motion rules are. What
|
||||
// height a row is, is a decision that lives in the file — the browser is where it is
|
||||
// proved, and runUiAudit.js is what proves it there.
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
const ruleFor = selector => {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const found = CSS.match(new RegExp(`(^|[},])\\s*${escaped}\\s*\\{([^}]*)\\}`, "m"));
|
||||
return found ? found[2] : null;
|
||||
};
|
||||
|
||||
describe("every puck on the ring is the same puck", () => {
|
||||
test("each of the three rows is a row high whether or not it says anything", () => {
|
||||
// A blank label had no height at all, so the one puck with nobody in it was the
|
||||
// one puck a different size from the rest — and a seat that had put chips in was
|
||||
// a different size again from one that hadn't.
|
||||
["name", "total", "stack"].forEach(row => {
|
||||
const selector = `.seatPuck${row[0].toUpperCase()}${row.slice(1)}`;
|
||||
const body = ruleFor(selector) || "";
|
||||
assert.match(body, /min-height:\s*var\(--seatRow(Name|Money)\)/,
|
||||
`${selector} does not hold its row open`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and the row heights are tokens, not ems", () => {
|
||||
// fitAmount steps a tournament stack's type down so every digit stays on the
|
||||
// puck, and .puckNameTight does the same to a name with no word to drop. In ems
|
||||
// each of those would have taken its row down with it, and the puck with it.
|
||||
["--seatRowName", "--seatRowMoney"].forEach(token =>
|
||||
assert.match(CSS, new RegExp(`\\${token}:\\s*\\d+px`), `${token} is not a fixed height`));
|
||||
});
|
||||
|
||||
test("the amount pill hides its paint rather than giving up its place", () => {
|
||||
// Collapsing it is what made a puck grow 17px when its player called and shrink
|
||||
// again when the street closed. Painting an empty pill is what collapsing it was
|
||||
// introduced to stop — an 8x11px green lozenge on seven of nine seats, standing
|
||||
// for nothing. Neither, now: the row stays and the pill is not drawn.
|
||||
assert.match(ruleFor(".seatPuckTotal:empty") || "", /visibility:\s*hidden/);
|
||||
assert.doesNotMatch(ruleFor(".seatPuckTotal:empty") || "", /display:\s*none/);
|
||||
});
|
||||
|
||||
test("but the hole-card row still collapses, because a table decides that once", () => {
|
||||
// Holding 15px open on nine seats all night for a row most tables never fill is
|
||||
// the crowding this is meant to avoid, and it is the one thing left that the
|
||||
// signature above still watches for.
|
||||
assert.match(ruleFor(".seatPuckCards:empty") || "", /display:\s*none/);
|
||||
});
|
||||
|
||||
test("an empty pill sits on its top edge, not on a baseline it hasn't got", () => {
|
||||
// An inline-block sits on its last line's baseline, and an empty one has no last
|
||||
// line — so it sits on its bottom edge instead and the line box comes out 2px
|
||||
// shorter than the same pill with a figure in it. That put the seats with money
|
||||
// out 2px below everyone else: the one difference this row exists to remove,
|
||||
// reappearing as its own mirror image.
|
||||
assert.match(ruleFor(".seatPuckTotal") || "", /vertical-align:\s*top/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("and the ring checks its own measurement afterwards", () => {
|
||||
// The signature above is an assumption about what can change a puck. This is what
|
||||
// happens when the assumption is wrong, and it is also what happens on the first
|
||||
// render of all: buildSeatNodes puts nine empty pucks on the felt and the search runs
|
||||
// before anything is written into them, so it was measuring nine blank boxes and
|
||||
// caching the answer under a key naming a table it had never seen.
|
||||
//
|
||||
// That used to be permanent. The comment where it was cached said "let the resize
|
||||
// that follows do this properly" — and nothing resizes a phone. A table opened
|
||||
// upright kept a ring measured for empty seats until the dealer turned the phone
|
||||
// sideways and back, which is the only thing that ever changed the key.
|
||||
const withBox = (box, run) => {
|
||||
const before = Render.measuredSeatBox;
|
||||
Render.measuredSeatBox = () => box;
|
||||
try { return run(); } finally { Render.measuredSeatBox = before; }
|
||||
};
|
||||
|
||||
test("a layout that measured nothing is always asked again", () => {
|
||||
// No box on the answer means the search never had a puck in front of it.
|
||||
assert.equal(withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved(null)), true);
|
||||
assert.equal(withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved(undefined)), true);
|
||||
});
|
||||
|
||||
test("one that measured the puck that is now on the felt is not", () => {
|
||||
assert.equal(
|
||||
withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved({ width: 72, height: 56 })),
|
||||
false);
|
||||
});
|
||||
|
||||
test("and a pixel of difference is a rounding artefact, not a table that moved", () => {
|
||||
// The two measurements are taken at different moments. Without the slack this
|
||||
// would ask the search to run again on every render for the rest of the session,
|
||||
// which is ten forced layouts a tap — the exact cost the cache exists to avoid.
|
||||
assert.equal(
|
||||
withBox({ width: 72.4, height: 56.4 }, () => Render.seatBoxMoved({ width: 72, height: 56 })),
|
||||
false);
|
||||
assert.equal(
|
||||
withBox({ width: 72, height: 58 }, () => Render.seatBoxMoved({ width: 72, height: 56 })),
|
||||
true, "two pixels taller is a puck that has changed shape");
|
||||
});
|
||||
|
||||
test("nothing on the felt to measure is not a reason to ask again", () => {
|
||||
// Between a table being resized and the new pucks being built there is a moment
|
||||
// with no seats at all. Asking again there would clear a good answer and replace
|
||||
// it with one measured from nothing.
|
||||
assert.equal(withBox(null, () => Render.seatBoxMoved({ width: 72, height: 56 })), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("how much room a figure has on the puck it is written on", () => {
|
||||
// The two steps of type an amount comes down used to be chosen by counting
|
||||
// characters: eight for the first, ten for the second. That is a guess at how wide a
|
||||
// figure is, and it happens to hold at one text size and one puck width.
|
||||
//
|
||||
// Turn a phone's text up — 20px of root text is what an Android slider gives you at
|
||||
// 125%, and every size in the stylesheet is in `rem` on purpose — and it stops
|
||||
// holding. Measured at 360x640 twelve-handed: the puck is at its 44px floor, which
|
||||
// is 34px of usable width, and "$1,200" is six characters, took no step at all, and
|
||||
// wanted 38. The sweep caught it as a stack figure painted over the seat next door
|
||||
// at every phone in the list, and as "ALL IN" doing the same on the all-in table.
|
||||
//
|
||||
// So it is measured. A cell is a stand-in for the real one: what it wants against
|
||||
// what it has, and a class list to write the answer into.
|
||||
const cell = (figure, wants, has) => ({
|
||||
dataset: { figure },
|
||||
textContent: figure,
|
||||
classes: new Set(),
|
||||
classList: {
|
||||
add(name) { this.owner.classes.add(name); },
|
||||
remove(name) { this.owner.classes.delete(name); },
|
||||
},
|
||||
// What the text wants is proportional to how many characters are in it and how
|
||||
// far down the ladder it has come — which is what a browser would tell us.
|
||||
get scrollWidth() {
|
||||
const step = this.classes.has("puckAmountHuge") ? 0.7
|
||||
: this.classes.has("puckAmountLong") ? 0.85 : 1;
|
||||
return Math.round((wants * this.textContent.length / figure.length) * step);
|
||||
},
|
||||
clientWidth: has,
|
||||
});
|
||||
|
||||
const make = (...args) => {
|
||||
const c = cell(...args);
|
||||
c.classList.owner = c;
|
||||
return c;
|
||||
};
|
||||
|
||||
const fit = cells => {
|
||||
const before = Render.overflowing;
|
||||
Render.overflowing = el => el.scrollWidth > el.clientWidth + 1;
|
||||
try { Render.fitAmounts(cells); } finally { Render.overflowing = before; }
|
||||
};
|
||||
|
||||
test("a figure that fits is left at the size it is meant to be read at", () => {
|
||||
const c = make("$600", 30, 34);
|
||||
fit([c]);
|
||||
assert.equal(c.textContent, "$600");
|
||||
assert.deepEqual([...c.classes], []);
|
||||
});
|
||||
|
||||
test("one that doesn't gives up its separators first", () => {
|
||||
// Grouping is a reading aid for a column of figures. This is one number on a
|
||||
// 44px puck, and a step of type costs more than the commas do.
|
||||
const c = make("$1,200", 38, 34);
|
||||
fit([c]);
|
||||
assert.equal(c.textContent, "$1200");
|
||||
assert.deepEqual([...c.classes], [], "no type step needed once the commas are out");
|
||||
});
|
||||
|
||||
test("then a step of type, and then another", () => {
|
||||
const c = make("$1,234,567", 90, 34);
|
||||
fit([c]);
|
||||
assert.equal(c.textContent, "$1234567");
|
||||
assert.deepEqual([...c.classes], ["puckAmountHuge"]);
|
||||
});
|
||||
|
||||
test("and only ever one step at a time is worn", () => {
|
||||
// The two rules have the same specificity, so a puck wearing both would be sized
|
||||
// by whichever the stylesheet happens to mention last.
|
||||
const c = make("$1,234,567", 45, 34);
|
||||
fit([c]);
|
||||
assert.equal(c.classes.has("puckAmountHuge") && c.classes.has("puckAmountLong"), false);
|
||||
});
|
||||
|
||||
test("a figure with no separators to give goes straight to the type", () => {
|
||||
// "ALL IN" is the one thing on this row that isn't a number, and it is the widest
|
||||
// — six characters and a space, at 700 weight with its letters spread apart.
|
||||
const c = make("ALL IN", 39, 34);
|
||||
fit([c]);
|
||||
assert.equal(c.textContent, "ALL IN");
|
||||
assert.deepEqual([...c.classes], ["puckAmountLong"]);
|
||||
});
|
||||
|
||||
test("a puck that went small can come back", () => {
|
||||
// Everything is measured from the figure and the size it is meant to be read at,
|
||||
// not from whatever the last render left behind. Otherwise this is a ratchet: one
|
||||
// crowded frame and the seat is small for the rest of the night.
|
||||
const c = make("$600", 30, 34);
|
||||
c.classes.add("puckAmountHuge");
|
||||
c.textContent = "$600";
|
||||
fit([c]);
|
||||
assert.deepEqual([...c.classes], []);
|
||||
});
|
||||
|
||||
test("and the grouped figure is what it comes back to", () => {
|
||||
const c = make("$1,200", 38, 34);
|
||||
fit([c]);
|
||||
assert.equal(c.textContent, "$1200");
|
||||
c.clientWidth = 96;
|
||||
fit([c]);
|
||||
assert.equal(c.textContent, "$1,200", "a wider puck should get its separators back");
|
||||
});
|
||||
|
||||
test("an empty row is not an amount", () => {
|
||||
// The pill keeps its place on a seat with nothing in the pot — that is what makes
|
||||
// every puck on the ring the same puck — and there is nothing there to fit.
|
||||
const c = make("", 0, 34);
|
||||
assert.doesNotThrow(() => fit([c]));
|
||||
assert.deepEqual([...c.classes], []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,9 +41,9 @@ describe("what the sheet puts in front of a dealer at rest", () => {
|
||||
// <details> blocks are stripped whole, so what is left is the flat part.
|
||||
const alwaysVisible = () => seatForm().replace(/<details[\s\S]*?<\/details>/g, "");
|
||||
|
||||
test("the flat part is the name, the two status boxes and the stack, and nothing else", () => {
|
||||
test("the flat part is the name, what the seat is doing and the stack, and nothing else", () => {
|
||||
const flat = alwaysVisible();
|
||||
["seatNameInput", "inHandCheckbox", "foldCheckbox", "stackInput", "seatBuyIns"]
|
||||
["seatNameInput", "seatState", "seatStateOption", "stackInput", "seatBuyIns"]
|
||||
.forEach(cls => assert.ok(flat.includes(cls),
|
||||
`${cls} is what a dealer opens a seat for — it cannot be behind a disclosure`));
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// The five cards in the middle of the felt, and the sheet of glass over them.
|
||||
//
|
||||
// Two faults, one on top of the other, and the sweep could see neither.
|
||||
//
|
||||
// #seatsLayer is a transparent `inset: 0` sibling of the table that holds the seat
|
||||
// pucks. The pucks ring the felt; the middle of it is empty layer — and an element with
|
||||
// no background still hit-tests. So every tap aimed at the community cards landed on the
|
||||
// layer instead. Measured at 1280x900 with the board on and five cards out: all five
|
||||
// hit-tested to `div#seatsLayer`, on every screen wide enough to keep the board in the
|
||||
// middle rather than on the strip. Each one is a button that opens the picker for that
|
||||
// card, and not one of them could be pressed. #betsLayer, the other sheet over the felt,
|
||||
// has been `pointer-events: none` since it was written.
|
||||
//
|
||||
// Underneath that, the cards were a little over half the size they were meant to be.
|
||||
// `placeBoard` measures how much middle the felt has and writes it to the centre's
|
||||
// `max-width` — but a max-width caps a box, it does not claim anything, and the centre
|
||||
// is a flex column that sizes to its content. So with 463px of measured room it went on
|
||||
// being 136px wide, the board row inside it is `width: 100%` of that, and five cards
|
||||
// sharing 136px come to rest on their own `min-width`: 24px, against a 44px floor this
|
||||
// sweep holds every other control on the felt to. On the strip, where the row simply
|
||||
// gets the width of the page, the same cards have always drawn 44.
|
||||
//
|
||||
// Neither was visible to a scripted probe — `.click()` on a node skips hit-testing
|
||||
// entirely — and the sweep's occlusion check had only ever been shown three selectors'
|
||||
// worth of the page: the action bar, the header, and the seats.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
const AUDIT = fs.readFileSync(path.join(__dirname, "uiAudit.js"), "utf8");
|
||||
const DRIVER = fs.readFileSync(path.join(__dirname, "runUiAudit.js"), "utf8");
|
||||
const src = name => fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.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));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the sheet over the felt", () => {
|
||||
test("it does not take taps meant for what is under it", () => {
|
||||
assert.match(ruleFor("#seatsLayer"), /pointer-events:\s*none/);
|
||||
});
|
||||
|
||||
// Which is only safe because the thing in it that *is* a control says so itself.
|
||||
test("but a seat still does", () => {
|
||||
assert.match(ruleFor(".seat"), /pointer-events:\s*auto/);
|
||||
});
|
||||
|
||||
// The two chips that want to stay untouchable set their own value and are inside a
|
||||
// seat, so they override what they inherit rather than depending on the layer.
|
||||
test("and the two pucks that are markers rather than controls still are", () => {
|
||||
assert.match(ruleFor(".dealerChip:not(.active)"), /pointer-events:\s*none/);
|
||||
assert.match(ruleFor(".dealerChip.active"), /pointer-events:\s*none/);
|
||||
});
|
||||
|
||||
test("the other sheet is unchanged, and was always right", () => {
|
||||
assert.match(ruleFor("#betsLayer"), /pointer-events:\s*none/);
|
||||
});
|
||||
|
||||
test("what it measured is written beside the rule", () => {
|
||||
const at = CSS.indexOf("/* A transparent sheet over the whole felt");
|
||||
assert.ok(at >= 0, "the rule carries no note saying why");
|
||||
const note = CSS.slice(at, CSS.indexOf("*/", at));
|
||||
assert.match(note, /1280x900/);
|
||||
assert.match(note, /seatsLayer/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("how wide the middle actually is", () => {
|
||||
test("the room it measured is claimed, not only capped", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("placeBoard(row, strip) {");
|
||||
assert.ok(at >= 0, "there is no placeBoard");
|
||||
const body = render.slice(at, render.indexOf("\n },", at));
|
||||
assert.match(body, /centre\.style\.width = `\$\{Math\.round\(width\)\}px`/,
|
||||
"the centre is still given a ceiling and no floor, so it shrink-wraps");
|
||||
assert.match(body, /centre\.style\.maxWidth = `\$\{Math\.round\(width\)\}px`/);
|
||||
});
|
||||
|
||||
// Both come off together when the board leaves the felt, or the centre would keep a
|
||||
// width measured for a board that is no longer in it.
|
||||
test("and both are given back when the board moves to the strip", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("placeBoard(row, strip) {");
|
||||
const body = render.slice(at, render.indexOf("} else {", at));
|
||||
assert.match(body, /centre\.style\.width = ""/);
|
||||
assert.match(body, /centre\.style\.maxWidth = ""/);
|
||||
});
|
||||
|
||||
// The floor is what it always said it was; what changed is that the cards can now
|
||||
// reach past it to the ceiling.
|
||||
test("a card is still allowed to grow to a thumb", () => {
|
||||
const rule = ruleFor(".boardCard");
|
||||
assert.match(rule, /max-width:\s*44px/);
|
||||
assert.match(rule, /min-height:\s*44px/);
|
||||
assert.match(rule, /flex:\s*1 1 0/);
|
||||
});
|
||||
|
||||
test("what it measured is written beside the code", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("// Both, and the width is the half that was missing.");
|
||||
assert.ok(at >= 0, "the fix carries no note saying why");
|
||||
const note = render.slice(at, at + 1400);
|
||||
assert.match(note, /463px/);
|
||||
assert.match(note, /24px/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("why the sweep could not see either of them", () => {
|
||||
// Three selectors: the bar, the header and the seats. Nothing else on the page had
|
||||
// ever been asked whether something was sitting on top of it.
|
||||
test("the occlusion check looks at every control now, not three groups", () => {
|
||||
const at = AUDIT.indexOf("occlusion(selector = null) {");
|
||||
assert.ok(at >= 0, "occlusion still hard-codes its own list");
|
||||
assert.match(AUDIT.slice(at, at + 200), /selector = selector \|\| this\.CONTROL_SELECTOR/);
|
||||
});
|
||||
|
||||
// Widening it put three kinds of non-fault in front of the rule for the first time.
|
||||
test("a control that opted out of the pointer is not reported as covered", () => {
|
||||
const at = AUDIT.indexOf("occlusion(selector = null) {");
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /getComputedStyle\(el\)\.pointerEvents === "none"/);
|
||||
});
|
||||
|
||||
test("nor is one inside a disclosure nobody has opened", () => {
|
||||
const at = AUDIT.indexOf("occlusion(selector = null) {");
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /el\.checkVisibility/,
|
||||
"a shut <details> still hands out a real rect, and a size check lets it through");
|
||||
});
|
||||
|
||||
test("and flow content under a fixed layer is said rather than failed", () => {
|
||||
const at = AUDIT.indexOf("occlusion(selector = null) {");
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /scrolls clear of it/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rest of what the sweep had never run", () => {
|
||||
// It opened five sheets and asked one question of them.
|
||||
test("an overlay gets the whole audit, not the occlusion check alone", () => {
|
||||
const at = DRIVER.indexOf("for (const [name, open] of OVERLAYS) {");
|
||||
assert.ok(at >= 0);
|
||||
const body = DRIVER.slice(at, DRIVER.indexOf("record(`${name}", at));
|
||||
assert.match(body, /LayoutAudit\.runAll\(/);
|
||||
assert.ok(!/return LayoutAudit\.occlusion\(/.test(body),
|
||||
"the overlay pass still runs one rule");
|
||||
});
|
||||
|
||||
// Two of the rules that made running only one of them reasonable ask about the
|
||||
// fixed bar, which is not a question while a scrim is covering the page.
|
||||
test("the two questions about the fixed bar stand down while a sheet is open", () => {
|
||||
["focusHasRoom() {", "furnitureBudget() {"].forEach(signature => {
|
||||
const at = AUDIT.indexOf(signature);
|
||||
assert.ok(at >= 0, `there is no \`${signature}\``);
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /if \(this\.modalScope\(\)\) \{/,
|
||||
`${signature} still reads a full-screen scrim as furniture`);
|
||||
});
|
||||
});
|
||||
|
||||
// And the third called the bottom four rows of a sheet that scrolls unreachable.
|
||||
test("a dialog's buttons below the fold are only a fault if nothing scrolls to them", () => {
|
||||
const at = AUDIT.indexOf("dialogActionsReachable() {");
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /if \(this\.reachableByScrolling\(button\)\) return;/);
|
||||
});
|
||||
|
||||
// The helper all three of those lean on had a blind spot of its own: it asked
|
||||
// whether the element had left the box entirely, which is true of something well
|
||||
// below the fold and false of something hanging eight pixels over the edge.
|
||||
test("and reachability counts a control that is only part-way out", () => {
|
||||
const at = AUDIT.indexOf("reachableByScrolling(el) {");
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /const below = rect\.bottom - box\.bottom/);
|
||||
assert.match(body, /const above = box\.top - rect\.top/);
|
||||
// The comment above the fix quotes the old test, so the code is read on its own.
|
||||
const code = body.split("\n").filter(line => !/^\s*\/\//.test(line)).join("\n");
|
||||
assert.ok(!/rect\.top >= box\.bottom/.test(code),
|
||||
"it still asks whether the element has left the box entirely");
|
||||
});
|
||||
});
|
||||
|
||||
describe("two controls that were a glyph and nothing else", () => {
|
||||
test("the pot wizard's close is named for what leaving does", () => {
|
||||
const markup = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const at = markup.indexOf('id="closePotWizardBtn"');
|
||||
assert.ok(at >= 0);
|
||||
assert.match(markup.slice(at, markup.indexOf(">", at)), /aria-label="Close without paying the pot"/);
|
||||
});
|
||||
|
||||
// Named from the label the dealer typed, and kept up with it — the same shape the
|
||||
// chip legend's remove button already used, in a column of identical crosses.
|
||||
test("and a custom field's remove says which field", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("renderCustomFields(form, player, index) {");
|
||||
const body = render.slice(at, render.indexOf("\n },", at));
|
||||
assert.match(body, /removeBtn\.setAttribute\("aria-label"/);
|
||||
assert.match(body, /labelInput\.value\.trim\(\)/);
|
||||
assert.match(body, /nameRemove\(\);/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
// The one control in the header that is named for a hand and never starts one.
|
||||
//
|
||||
// "Next hand" names three different actions in this tool. The action bar's green button
|
||||
// deals one. The pot wizard finishes on "Start Next Hand" and hands back a table ready
|
||||
// to deal. And the header shortcut — which, between hands, deals nothing: it moves the
|
||||
// button on a second time, so the seat that was about to pay the big blind is skipped
|
||||
// and the next one pays it instead.
|
||||
//
|
||||
// Measured before this changed: settle a pot through the wizard, and the table is on
|
||||
// hand 6 with the button on seat 2 and the bar offering "Post blinds & deal". Press the
|
||||
// header's "Next Hand ⟳" — no confirmation, because there is nothing in the middle to
|
||||
// warn about — and the table is still on hand 6, with the button on seat 3. The toast
|
||||
// then opened with the same two words the wizard's button had just used, which reads as
|
||||
// a confirmation of the thing that was already done rather than a report of a second
|
||||
// rotation.
|
||||
//
|
||||
// The mechanism is right and stays: a dealer does pass the button along on a hand nobody
|
||||
// played, and state.startNextHand says so in as many words. What was wrong was the name.
|
||||
// A command is named for the state the system moves into — NN/g's Button States, "Show
|
||||
// Tab Bar" against "Hide Tab Bar" — and between hands the state this moves into is a
|
||||
// moved button, not a new hand.
|
||||
//
|
||||
// So the label is read off the hand: "Next Hand ⟳" while one is out, "Move Button ⟳"
|
||||
// when none is. Both claims are checked here, along with the trap underneath them — the
|
||||
// title is cached once from the markup for the dimmed-reason machinery to borrow and
|
||||
// hand back, and a title that depends on the hand has to be written past that cache on
|
||||
// every render or it freezes on whichever state happened to be true at load.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render, HoldemState, Betting, makeTable } = require("./load");
|
||||
|
||||
// Stand-ins for the three header buttons. Enough of an element for Unavailable and for
|
||||
// renderHeaderActions: attributes, a dataset, text, and a hidden flag.
|
||||
const element = title => {
|
||||
const attributes = title ? { title } : {};
|
||||
return {
|
||||
hidden: false,
|
||||
textContent: "",
|
||||
dataset: {},
|
||||
getAttribute: name => (name in attributes ? attributes[name] : null),
|
||||
setAttribute: (name, value) => { attributes[name] = String(value); },
|
||||
removeAttribute: name => { delete attributes[name]; },
|
||||
};
|
||||
};
|
||||
|
||||
// The header as the page ships it, with the markup's own title on the shortcut.
|
||||
const header = () => {
|
||||
const buttons = {
|
||||
misdealBtn: element("Take this hand back"),
|
||||
nextHandBtn: element("Pass the button on again without dealing — for a hand nobody played"),
|
||||
resolvePotBtn: element(""),
|
||||
};
|
||||
global.document = { getElementById: id => buttons[id] || null };
|
||||
return buttons;
|
||||
};
|
||||
|
||||
const renderOn = state => {
|
||||
const before = Render.state;
|
||||
Render.state = state;
|
||||
try { Render.renderHeaderActions(); } finally { Render.state = before; }
|
||||
};
|
||||
|
||||
const table = () => makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 5, bigBlind: 10 });
|
||||
|
||||
test.afterEach(() => { delete global.document; });
|
||||
|
||||
describe("what the shortcut calls itself", () => {
|
||||
test("between hands it offers to move the button, because that is all it does", () => {
|
||||
const buttons = header();
|
||||
renderOn(table());
|
||||
|
||||
assert.equal(buttons.nextHandBtn.textContent, "Move Button ⟳");
|
||||
assert.match(buttons.nextHandBtn.getAttribute("title"), /without dealing/,
|
||||
"the title has to say the thing the label can't fit");
|
||||
});
|
||||
|
||||
test("and with a hand out it offers the next one, because that is what it moves to", () => {
|
||||
const buttons = header();
|
||||
const state = table();
|
||||
Betting.beginHand(state);
|
||||
state.bettingOpen = true;
|
||||
renderOn(state);
|
||||
|
||||
assert.equal(buttons.nextHandBtn.textContent, "Next Hand ⟳");
|
||||
assert.match(buttons.nextHandBtn.getAttribute("title"), /End this hand/);
|
||||
});
|
||||
|
||||
test("the name follows the table rather than sticking where it started", () => {
|
||||
// The failure this guards is a one-way trip: the label is right on the state the
|
||||
// page loaded in and wrong for the rest of the night.
|
||||
const buttons = header();
|
||||
const state = table();
|
||||
|
||||
renderOn(state);
|
||||
const betweenHands = buttons.nextHandBtn.textContent;
|
||||
Betting.beginHand(state);
|
||||
state.bettingOpen = true;
|
||||
renderOn(state);
|
||||
const midHand = buttons.nextHandBtn.textContent;
|
||||
HoldemState.startNextHand(state);
|
||||
renderOn(state);
|
||||
|
||||
assert.notEqual(betweenHands, midHand, "the label never moved off the loaded state");
|
||||
assert.equal(buttons.nextHandBtn.textContent, betweenHands, "and it never came back");
|
||||
});
|
||||
|
||||
test("including the title, which the dimmed-reason cache would otherwise freeze", () => {
|
||||
// renderHeaderActions fills dataset.titleWhenUsable once, from the markup, so the
|
||||
// sentence hung off a dimmed control can be handed back when it is usable again.
|
||||
// A state-dependent title has to be written into that slot on every render — this
|
||||
// is the assertion that the write happens before the borrow, not after it.
|
||||
const buttons = header();
|
||||
const state = table();
|
||||
|
||||
renderOn(state);
|
||||
const betweenHands = buttons.nextHandBtn.getAttribute("title");
|
||||
Betting.beginHand(state);
|
||||
state.bettingOpen = true;
|
||||
renderOn(state);
|
||||
|
||||
assert.notEqual(buttons.nextHandBtn.getAttribute("title"), betweenHands);
|
||||
assert.match(buttons.nextHandBtn.getAttribute("title"), /End this hand/);
|
||||
});
|
||||
|
||||
test("and an empty table still gets the reason it can't be pressed", () => {
|
||||
// Naming the two states must not cost the third one its sentence: with nobody
|
||||
// seated there is no button to move, and that is what the title says.
|
||||
const buttons = header();
|
||||
// A table as the tool first opens it: chairs, and nobody named in any of them.
|
||||
renderOn(HoldemState.createDefault());
|
||||
|
||||
assert.match(buttons.nextHandBtn.getAttribute("title"), /seat a player first/);
|
||||
|
||||
// And seating the table hands the state's own sentence back.
|
||||
renderOn(table());
|
||||
assert.match(buttons.nextHandBtn.getAttribute("title"), /without dealing/);
|
||||
});
|
||||
|
||||
test("Misdeal still comes and goes with the hand", () => {
|
||||
const buttons = header();
|
||||
const state = table();
|
||||
|
||||
renderOn(state);
|
||||
assert.equal(buttons.misdealBtn.hidden, true, "there is nothing to take back yet");
|
||||
|
||||
Betting.beginHand(state);
|
||||
state.bettingOpen = true;
|
||||
renderOn(state);
|
||||
assert.equal(buttons.misdealBtn.hidden, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it does when there is no hand to end", () => {
|
||||
test("the button moves on and the hand number doesn't", () => {
|
||||
// This is the behaviour the label now names, pinned so the name stays true.
|
||||
const state = table();
|
||||
state.handNumber = 6;
|
||||
state.dealerSeatIndex = 1;
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
|
||||
assert.equal(state.dealerSeatIndex, 2, "the button passed to the next occupied seat");
|
||||
assert.equal(state.handNumber, 6, "no hand was dealt, so none was counted");
|
||||
assert.equal(state.bettingOpen, false);
|
||||
assert.equal(state.postedBlinds, null, "nothing is posted until the dealer says so");
|
||||
});
|
||||
|
||||
test("so pressing it twice skips a seat's big blind", () => {
|
||||
// Not a bug — a dealer passing the button along twice means it — but it is the
|
||||
// reason the control cannot be called "Next Hand" while it is doing it.
|
||||
const state = table();
|
||||
const first = HoldemState.getBlindSeats({ ...state });
|
||||
|
||||
HoldemState.startNextHand(state);
|
||||
const second = HoldemState.getBlindSeats(state);
|
||||
HoldemState.startNextHand(state);
|
||||
const third = HoldemState.getBlindSeats(state);
|
||||
|
||||
assert.notEqual(second.bbIndex, first.bbIndex);
|
||||
assert.notEqual(third.bbIndex, second.bbIndex,
|
||||
"the seat that was about to pay the big blind never did");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// The one mark on a puck that isn't about this hand.
|
||||
//
|
||||
// A seat's chair number was a grey digit in the top-left corner, and the top-left corner
|
||||
// is where the blind badge drops off the rim and where a long centred name reaches.
|
||||
// Measured across 348 seats on 36 tables: 117 of them had the number printed under
|
||||
// something else. All 66 seats posting a blind had it covered outright — the badge is
|
||||
// 22px wide over a 6px digit, at z-index 4 — and 51 more had it sitting on the first
|
||||
// letter of the player's own name, the whole glyph in the worst case, on a 54px puck.
|
||||
//
|
||||
// There is nowhere on the felt to move it to. The button's chip owns the right-hand end
|
||||
// of the top rim. Bets land on the rim between a seat and the pot, which is the bottom
|
||||
// edge for the seats above centre and the top edge for the ones below. The middle of the
|
||||
// puck is three rows — name, bet, stack — and all three are about this hand. So the mark
|
||||
// that isn't gave way, and the two sheets that are about one seat say which seat.
|
||||
//
|
||||
// The sweep had never mentioned any of it, because the check that asks whether a puck
|
||||
// covers itself up only knew about two of the three things a puck wears, and only ever
|
||||
// tested them against writing.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { HoldemState, makeTable } = require("./load");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
const AUDIT = fs.readFileSync(path.join(__dirname, "uiAudit.js"), "utf8");
|
||||
const src = name => fs.readFileSync(
|
||||
path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.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));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a chair with somebody in it", () => {
|
||||
test("does not draw its number on the felt at all", () => {
|
||||
assert.match(ruleFor(".seat:not(.seatEmpty) .seatChairNumber"), /display:\s*none/);
|
||||
});
|
||||
|
||||
// Where the number is the thing being looked for rather than a footnote on somebody
|
||||
// else's name — which chair is open — it is still large and centred and washed.
|
||||
test("and an open one still wears its number, large and in the middle", () => {
|
||||
const rule = ruleFor(".seatEmpty .seatChairNumber");
|
||||
assert.match(rule, /top:\s*50%/);
|
||||
assert.match(rule, /left:\s*50%/);
|
||||
assert.match(rule, /background:/);
|
||||
});
|
||||
|
||||
// Removing it from the felt is only defensible because the felt is not the only
|
||||
// place it appears. A seat nobody has named says "Seat 5" as its name already.
|
||||
test("a seat with no name typed in still says which seat it is", () => {
|
||||
const state = makeTable({ seatCount: 6 });
|
||||
state.seats[4].name = "";
|
||||
assert.equal(HoldemState.displayName(state.seats[4], 4), "Seat 5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the two sheets that are about one seat", () => {
|
||||
test("name the chair as well as the player", () => {
|
||||
assert.equal(HoldemState.seatHeading({ name: "Alexandra" }, 2), "Alexandra · Seat 3");
|
||||
});
|
||||
|
||||
test("and don't say it twice when the name is already the number", () => {
|
||||
assert.equal(HoldemState.seatHeading({ name: "" }, 4), "Seat 5");
|
||||
assert.equal(HoldemState.seatHeading({ name: " " }, 4), "Seat 5");
|
||||
assert.equal(HoldemState.seatHeading({}, 0), "Seat 1");
|
||||
assert.equal(HoldemState.seatHeading(null, 0), "Seat 1");
|
||||
});
|
||||
|
||||
test("a typed name keeps its own spacing off the ends", () => {
|
||||
assert.equal(HoldemState.seatHeading({ name: " Mike from work " }, 1), "Mike from work · Seat 2");
|
||||
});
|
||||
|
||||
test("the seat editor's heading is the one that says it", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("renderSeatEditor() {");
|
||||
assert.ok(at >= 0);
|
||||
const body = render.slice(at, render.indexOf("\n },", at));
|
||||
assert.match(body, /title\.textContent = HoldemState\.seatHeading\(player, index\)/);
|
||||
});
|
||||
|
||||
// Not the heading here: this header is stuck to the top of the sheet that scrolls,
|
||||
// and a name long enough to wrap would cost every action below it a line.
|
||||
test("the seat menu says it on its first line instead, and says it first", () => {
|
||||
const menu = src("seatMenu");
|
||||
const at = menu.indexOf("const stack = Betting.stackOf(this.state, player);");
|
||||
assert.ok(at >= 0);
|
||||
const body = menu.slice(at, menu.indexOf("subtitle.textContent", at));
|
||||
assert.match(body, /const bits = \[`Seat \$\{this\.seatIndex \+ 1\}`\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sweep can see it now", () => {
|
||||
const check = () => {
|
||||
const at = AUDIT.indexOf(" seatLabelsClear() {");
|
||||
assert.ok(at >= 0, "the check that asks whether a puck covers itself up is gone");
|
||||
return AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
};
|
||||
|
||||
// The mark that was sitting on names was neither of the two this check knew about.
|
||||
test("the chair number counts as something a puck wears", () => {
|
||||
assert.match(check(), /const MARKS = "\.dealerChip, \.blindBadge, \.seatChairNumber"/);
|
||||
});
|
||||
|
||||
// And a badge painted over the number is a puck covering itself up, which is the
|
||||
// whole subject of this check, and was invisible to it.
|
||||
test("and the marks are held against each other, not only against the writing", () => {
|
||||
const body = check();
|
||||
assert.match(body, /marks\.forEach\(\(a, i\) => marks\.slice\(i \+ 1\)/);
|
||||
assert.match(body, /px across/);
|
||||
});
|
||||
|
||||
test("a mark that isn't drawn isn't an offender", () => {
|
||||
const body = check();
|
||||
assert.match(body, /style\.display === "none"/);
|
||||
assert.match(body, /parseFloat\(style\.opacity\) < 0\.05/);
|
||||
// An empty badge slot has a box and no text; three of them sit on every puck.
|
||||
assert.match(body, /if \(!\(mark\.textContent \|\| ""\)\.trim\(\)\) return null;/);
|
||||
});
|
||||
|
||||
test("and the check says what it now covers", () => {
|
||||
assert.match(AUDIT, /name: "nothing a seat wears covers its name or another mark"/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// The panel named after a legend that never showed one.
|
||||
//
|
||||
// Measured on an iPhone: 793px, of which 420 were the colour form — five rows of
|
||||
// swatch, value box and a filled red delete block, then an add button and a paragraph
|
||||
// of instructions. Under all of it sat "Stacks in chips", which is the thing a dealer
|
||||
// opens this panel during a game to read: the fewest chips that make each stack, for
|
||||
// colouring up or counting one out. Setting the colours is something you do once, on
|
||||
// the first night, and it was sitting on top of the thing you do at every colour-up.
|
||||
//
|
||||
// So the order is the other way round. The legend itself — five colours and five
|
||||
// amounts, which is what the panel is named for and what it had never actually
|
||||
// displayed, the values being locked inside number inputs — leads, in two lines. The
|
||||
// colour-up list follows. The form goes behind a disclosure, which is the shape Table
|
||||
// Setup already uses twice. Panel: 793px to 441px.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
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 RENDER = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "render.js"), "utf8");
|
||||
const DRIVER = fs.readFileSync(path.join(__dirname, "runUiAudit.js"), "utf8");
|
||||
|
||||
const panel = () => {
|
||||
const at = PAGE.indexOf('<summary><h2>Chip Legend</h2>');
|
||||
assert.ok(at >= 0, "there is no Chip Legend panel");
|
||||
return PAGE.slice(at, PAGE.indexOf('<details class="sidebarPanel" id="keyboardPanel">', at));
|
||||
};
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what the panel leads with", () => {
|
||||
test("the legend, then the stacks, then the form", () => {
|
||||
const markup = panel();
|
||||
const strip = markup.indexOf('id="chipLegendStrip"');
|
||||
const stacks = markup.indexOf('id="colourUp"');
|
||||
const form = markup.indexOf('id="chipLegendList"');
|
||||
assert.ok(strip >= 0, "the panel still doesn't show the legend it is named for");
|
||||
assert.ok(strip < stacks, "the legend is below the colour-up list");
|
||||
assert.ok(stacks < form, "the form a dealer fills in once is above the list they read all night");
|
||||
});
|
||||
|
||||
test("and the form is behind a disclosure, shut", () => {
|
||||
const markup = panel();
|
||||
const at = markup.indexOf('<details class="subPanel">');
|
||||
assert.ok(at >= 0, "the colour form is not behind a disclosure");
|
||||
assert.ok(!/<details class="subPanel"[^>]*\bopen\b/.test(markup));
|
||||
assert.ok(at < markup.indexOf('id="chipLegendList"'), "the disclosure doesn't contain the form");
|
||||
assert.match(markup.slice(at, at + 200), /<summary>Colours and values<\/summary>/);
|
||||
});
|
||||
|
||||
// The add button and the instructions belong to the form, not to the panel.
|
||||
test("the instructions went with it", () => {
|
||||
const markup = panel();
|
||||
const sub = markup.indexOf('<details class="subPanel">');
|
||||
assert.ok(markup.indexOf('id="addChipColorBtn"') > sub);
|
||||
assert.ok(markup.indexOf("Give each chip color a value") > sub);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the legend line", () => {
|
||||
test("is built from the same list the form edits", () => {
|
||||
const at = RENDER.indexOf("renderChipLegendStrip() {");
|
||||
assert.ok(at >= 0, "nothing draws the legend");
|
||||
const body = RENDER.slice(at, RENDER.indexOf("\n },", at));
|
||||
assert.match(body, /this\.state\.chipLegend/);
|
||||
assert.match(body, /Money\.format/);
|
||||
assert.match(body, /chipSwatch/);
|
||||
});
|
||||
|
||||
test("and is drawn whenever the list is", () => {
|
||||
const at = RENDER.indexOf("renderChipLegend() {");
|
||||
assert.ok(at >= 0);
|
||||
assert.match(RENDER.slice(at, at + 160), /this\.renderChipLegendStrip\(\);/);
|
||||
});
|
||||
|
||||
// Typing in the form is the one moment the line above it goes stale. Only the
|
||||
// strip is redrawn — redrawing the list would write the field's own value back
|
||||
// into it mid-keystroke, which is why everything else here is called one at a time.
|
||||
test("it keeps up with the field being typed into, and nothing else does", () => {
|
||||
// Anchored inside renderChipLegend: the seat editor's custom fields have
|
||||
// listeners spelled the same way, earlier in the file.
|
||||
const from = RENDER.indexOf("renderChipLegend() {");
|
||||
assert.ok(from >= 0);
|
||||
["colorInput.addEventListener(\"input\"", "valueInput.addEventListener(\"input\""].forEach(hook => {
|
||||
const at = RENDER.indexOf(hook, from);
|
||||
assert.ok(at >= 0, `there is no ${hook}`);
|
||||
const body = RENDER.slice(at, RENDER.indexOf("});", at));
|
||||
assert.match(body, /this\.renderChipLegendStrip\(\);/, `${hook} leaves the legend line stale`);
|
||||
assert.ok(!/this\.renderChipLegend\(\)/.test(body),
|
||||
"redrawing the whole list would rewrite the field being typed in");
|
||||
});
|
||||
});
|
||||
|
||||
test("it wraps rather than running off the side of a 320px sidebar", () => {
|
||||
const rule = ruleFor(".chipLegendStrip");
|
||||
assert.match(rule, /display:\s*flex/);
|
||||
assert.match(rule, /flex-wrap:\s*wrap/);
|
||||
assert.match(rule, /list-style:\s*none/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("five ways to delete something", () => {
|
||||
// The target stays the size the touch rules give it. Only the paint changes: height
|
||||
// is not what makes a destructive control safe, and these were the loudest thing in
|
||||
// the panel — one filled red block per row, brighter than the values beside them.
|
||||
test("the delete buttons are quiet without being small", () => {
|
||||
const rule = ruleFor("#chipLegendList .removeChipColorBtn");
|
||||
assert.match(rule, /background:\s*none/);
|
||||
assert.ok(!/min-height/.test(rule), "de-emphasis is a colour change, never a height change");
|
||||
assert.ok(!/width/.test(rule));
|
||||
});
|
||||
|
||||
// The dialog that asks "reset the table?" is also holdemBtnDanger, and it is meant
|
||||
// to be loud.
|
||||
test("and the confirm dialog's own danger button is untouched", () => {
|
||||
const at = CSS.indexOf("\n.holdemBtnDanger {");
|
||||
assert.ok(at >= 0);
|
||||
assert.match(CSS.slice(at, CSS.indexOf("}", at)), /background:\s*linear-gradient/);
|
||||
});
|
||||
|
||||
test("the red comes back on the way to pressing one", () => {
|
||||
assert.match(CSS, /#chipLegendList \.removeChipColorBtn:hover \{[^}]*color:\s*#ffd6d6/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the list that runs into its own footnote", () => {
|
||||
test("the colour-up note is spaced off the last stack", () => {
|
||||
assert.match(ruleFor("#colourUp .colourUpList + .fieldHint"), /margin-top:\s*var\(--sp-\d\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the sweep is allowed to shrug at", () => {
|
||||
// Both twelve-handed shapes, for one stated reason, and only at 20px text.
|
||||
test("both crowded shapes are notes, not failures, at large text", () => {
|
||||
const at = DRIVER.indexOf("const CROWDED_BEYOND_HELP");
|
||||
assert.ok(at >= 0);
|
||||
assert.match(DRIVER.slice(at, DRIVER.indexOf(";", at)), /\["all in", "packed"\]/);
|
||||
assert.match(DRIVER, /soft && soft\.includes\(shape\)/);
|
||||
});
|
||||
|
||||
// The arithmetic has to be written down, or the next round reads this as the sweep
|
||||
// being softened to make something pass.
|
||||
test("and the arithmetic for why is written down beside it", () => {
|
||||
const at = DRIVER.indexOf("// The packed table is the same arithmetic");
|
||||
assert.ok(at >= 0, "the packed shape is exempt with no reason given");
|
||||
const why = DRIVER.slice(at, DRIVER.indexOf("const CROWDED_BEYOND_HELP", at));
|
||||
assert.match(why, /315px of puck[\s/]*\/\/?\s*into 294px of felt/);
|
||||
assert.match(why, /44x63/);
|
||||
});
|
||||
|
||||
// Only at 20px. The same table at the browser's normal size is held to the same
|
||||
// standard as everything else on the felt.
|
||||
test("the other two passes are not softened", () => {
|
||||
assert.match(DRIVER, /\{ hand: "mouse", touch: false, viewports: VIEWPORTS \}/);
|
||||
assert.match(DRIVER, /\{ hand: "touch", touch: true, viewports: VIEWPORTS \}/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
// Typing 0.25 into a box and getting 0.25.
|
||||
//
|
||||
// The six setup fields are wired `input` -> save -> Render.renderAll(), and renderAll
|
||||
// comes back round through renderSetupFields and assigns `.value` to the very box being
|
||||
// typed in. The decimal point is where that turns into a wrong number rather than a
|
||||
// wasted render, because a `type="number"` reports a half-typed "0." as "0" — so the
|
||||
// write-back erased the point, and assigning `.value` to a number input parks the caret
|
||||
// at position 0, so every digit after it landed at the front.
|
||||
//
|
||||
// Measured in Chrome against the page as it stood, keystroke by keystroke:
|
||||
//
|
||||
// small blind "0.25" 0 -> 0 -> 20 -> 250 a table playing 250/500
|
||||
// big blind "0.50" 0 -> 0 -> 50 -> 500
|
||||
// start stack "12.50" 1 -> 12 -> 12 -> 512 -> 5012
|
||||
// rake percent "2.5" 2 -> 2 -> 52 a 52% rake
|
||||
// rake cap "7.50" 7 -> 7 -> 57 -> 507
|
||||
// base pot "0.75" 0 -> 0 -> 70 -> 750
|
||||
//
|
||||
// Nothing warned: every one of those is a valid number, so no validation could have
|
||||
// caught it, and the dealer's own table is the only place it shows.
|
||||
//
|
||||
// Two rules hold it shut, and they are separate on purpose. One stops a render writing
|
||||
// over somebody's half-finished typing at all. The other takes the fields off
|
||||
// `type="number"`, which is what made a half-finished number unreadable in the first
|
||||
// place — and is what GOV.UK's design system concluded after user testing, for this and
|
||||
// four other reasons.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Render, Money } = require("./load");
|
||||
|
||||
const MARKUP = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
|
||||
// A stand-in for one form control, and a document that knows which one has focus.
|
||||
const field = (value = "") => ({ value, checked: false });
|
||||
const withFocus = focused => {
|
||||
global.document = { activeElement: focused || null };
|
||||
};
|
||||
|
||||
test.afterEach(() => { delete global.document; });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a render does not type for you", () => {
|
||||
test("the box somebody is standing in is left exactly as they left it", () => {
|
||||
// The whole fix in one assertion. "0." is not a number and state has read it as
|
||||
// 0; writing that 0 back is what ate the point.
|
||||
const box = field("0.");
|
||||
withFocus(box);
|
||||
|
||||
Render.setField(box, 0);
|
||||
|
||||
assert.equal(box.value, "0.",
|
||||
"the render wrote over a half-typed number, which is how 0.25 became 250");
|
||||
});
|
||||
|
||||
test("and the box nobody is in is brought up to date", () => {
|
||||
const box = field("2");
|
||||
withFocus(null);
|
||||
|
||||
Render.setField(box, 5);
|
||||
|
||||
assert.equal(box.value, "5");
|
||||
});
|
||||
|
||||
test("a value that has not changed is not written at all", () => {
|
||||
// Assigning an identical value still moves the caret in some engines, so this
|
||||
// is part of the guard rather than a saving.
|
||||
const box = field("5");
|
||||
let writes = 0;
|
||||
Object.defineProperty(box, "value", {
|
||||
get: () => "5",
|
||||
set: () => { writes += 1; },
|
||||
});
|
||||
withFocus(null);
|
||||
|
||||
Render.setField(box, 5);
|
||||
|
||||
assert.equal(writes, 0, "an unchanged value was assigned anyway");
|
||||
});
|
||||
|
||||
test("null and undefined clear the box rather than spelling themselves out", () => {
|
||||
withFocus(null);
|
||||
const a = field("9");
|
||||
const b = field("9");
|
||||
|
||||
Render.setField(a, null);
|
||||
Render.setField(b, undefined);
|
||||
|
||||
assert.equal(a.value, "");
|
||||
assert.equal(b.value, "", "an unset blind would have read the word 'undefined'");
|
||||
});
|
||||
|
||||
test("and a box that isn't there is not an error", () => {
|
||||
withFocus(null);
|
||||
assert.doesNotThrow(() => Render.setField(null, 5));
|
||||
assert.doesNotThrow(() => Render.setToggle(null, true));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the same rule for a switch", () => {
|
||||
test("a checkbox somebody is on is not flipped under them", () => {
|
||||
const box = field();
|
||||
box.checked = true;
|
||||
withFocus(box);
|
||||
|
||||
Render.setToggle(box, false);
|
||||
|
||||
assert.equal(box.checked, true,
|
||||
"a render flipped a switch the dealer had their finger on");
|
||||
});
|
||||
|
||||
test("and one nobody is on keeps up with the state", () => {
|
||||
const box = field();
|
||||
box.checked = true;
|
||||
withFocus(null);
|
||||
|
||||
Render.setToggle(box, false);
|
||||
|
||||
assert.equal(box.checked, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a typed-in box is worth", () => {
|
||||
// The fields are type="text" now, so what arrives is what was typed — including
|
||||
// things that are not numbers. Number("abc") is NaN, and NaN must not become a blind.
|
||||
test("a number is worth that number", () => {
|
||||
assert.equal(Money.readAmount("0.25"), 0.25);
|
||||
assert.equal(Money.readAmount("12.50"), 12.5);
|
||||
assert.equal(Money.readAmount("200"), 200);
|
||||
});
|
||||
|
||||
test("nothing typed yet is not zero", () => {
|
||||
// Null and 0 mean different things to the fields that use this: an unset
|
||||
// starting stack falls back to the table default, a zero one does not.
|
||||
assert.equal(Money.readAmount(""), null);
|
||||
assert.equal(Money.readAmount(" "), null);
|
||||
assert.equal(Money.readAmount(null), null);
|
||||
assert.equal(Money.readAmount(undefined), null);
|
||||
});
|
||||
|
||||
test("and neither is something that is not a number", () => {
|
||||
["abc", "1.2.3", "$5", "--", "1,5"].forEach(text => {
|
||||
assert.equal(Money.readAmount(text), null,
|
||||
`"${text}" came back as something other than null`);
|
||||
});
|
||||
});
|
||||
|
||||
test("infinity is not a stack", () => {
|
||||
assert.equal(Money.readAmount("Infinity"), null);
|
||||
assert.equal(Money.readAmount("1e999"), null);
|
||||
});
|
||||
|
||||
test("a half-typed number is read as what it is so far", () => {
|
||||
// "0." arrives here now instead of being flattened to "0" by the control. It is
|
||||
// worth 0 at this instant, and the next keystroke will say otherwise — what
|
||||
// matters is that the box still reads "0." when it does.
|
||||
assert.equal(Money.readAmount("0."), 0);
|
||||
assert.equal(Money.readAmount(".5"), 0.5);
|
||||
});
|
||||
|
||||
test("and it comes back rounded to the cent the tool works in", () => {
|
||||
assert.equal(Money.readAmount("0.005"), 0.01);
|
||||
assert.equal(Money.readAmount("1.004"), 1);
|
||||
});
|
||||
|
||||
test("a negative is left alone rather than quietly clamped", () => {
|
||||
// A typed "-5" is a different mistake from a mistyped one, and the callers that
|
||||
// care clamp for themselves. Folding it in here would change what the dealer
|
||||
// asked for without saying so.
|
||||
assert.equal(Money.readAmount("-5"), -5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the boxes themselves", () => {
|
||||
const inputs = MARKUP.match(/<input\b[^>]*>/g) || [];
|
||||
const attr = (tag, name) => {
|
||||
const m = tag.match(new RegExp(`${name}="([^"]*)"`));
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
const named = tag => attr(tag, "id") || attr(tag, "class") || tag.slice(0, 40);
|
||||
|
||||
test("none of them is a type=number any more", () => {
|
||||
const spinners = inputs.filter(t => attr(t, "type") === "number");
|
||||
assert.deepEqual(spinners.map(named), [],
|
||||
"a type=number is back: it reports a half-typed '0.' as '0', which is the " +
|
||||
"read that let a render write the decimal point away");
|
||||
});
|
||||
|
||||
test("every box that takes digits says which keypad it wants", () => {
|
||||
// The digits fields are the ones this round converted. Identified by name rather
|
||||
// than by type, because their type is exactly what no longer distinguishes them.
|
||||
const DIGITS = [
|
||||
"seatCountInput", "smallBlindInput", "bigBlindInput", "defaultStackInput",
|
||||
"rakePercentInput", "rakeCapInput", "basePotInput",
|
||||
"stackInput", "amountOverrideInput", "chipValueInput", "chipCountInput",
|
||||
"addChipsInput",
|
||||
];
|
||||
DIGITS.forEach(name => {
|
||||
const tag = inputs.find(t => named(t) === name);
|
||||
assert.ok(tag, `${name} is gone from the markup`);
|
||||
const mode = attr(tag, "inputmode");
|
||||
assert.ok(mode === "decimal" || mode === "numeric",
|
||||
`${name} has inputmode="${mode}" — without one a phone offers a ` +
|
||||
`full keyboard for a number, which is the slowest thing on the page`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the money ones ask for the separator, the counting ones do not", () => {
|
||||
// "decimal" carries the reader's decimal separator; "numeric" is bare digits.
|
||||
// A blind needs the point. A seat count would only be a way to type a wrong one.
|
||||
const modeOf = name => attr(inputs.find(t => named(t) === name), "inputmode");
|
||||
["smallBlindInput", "bigBlindInput", "defaultStackInput", "rakeCapInput",
|
||||
"basePotInput", "stackInput", "chipValueInput"].forEach(name => {
|
||||
assert.equal(modeOf(name), "decimal", `${name} cannot be given cents`);
|
||||
});
|
||||
["seatCountInput", "chipCountInput"].forEach(name => {
|
||||
assert.equal(modeOf(name), "numeric", `${name} counts things, not money`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and none of them invites the browser to fill it in", () => {
|
||||
// A saved address offering itself as a small blind is the browser being helpful
|
||||
// about the one field on the page it cannot know anything about.
|
||||
["smallBlindInput", "bigBlindInput", "defaultStackInput", "seatCountInput",
|
||||
"rakePercentInput", "rakeCapInput", "basePotInput"].forEach(name => {
|
||||
const tag = inputs.find(t => named(t) === name);
|
||||
assert.equal(attr(tag, "autocomplete"), "off", `${name} may be autofilled`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("what still counts as a number box", () => {
|
||||
// Money.selectsOnFocus decides whether tapping a pre-filled amount box selects what
|
||||
// is in it. It used to recognise those boxes by type="number"; there are none left.
|
||||
test("a text box with a decimal keypad is one", () => {
|
||||
assert.equal(Money.selectsOnFocus(
|
||||
{ tagName: "INPUT", type: "text", inputMode: "decimal" }), true);
|
||||
});
|
||||
|
||||
test("and so is one with a numeric keypad", () => {
|
||||
// The seat count and the chip count. Both open pre-filled, both were caught by
|
||||
// the old type check, and reading only "decimal" would have dropped them.
|
||||
assert.equal(Money.selectsOnFocus(
|
||||
{ tagName: "INPUT", type: "text", inputMode: "numeric" }), true);
|
||||
});
|
||||
|
||||
test("a plain text box is not", () => {
|
||||
assert.equal(Money.selectsOnFocus(
|
||||
{ tagName: "INPUT", type: "text", inputMode: "" }), false,
|
||||
"a player's name would be wiped by the first keystroke after a tap");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a wheel has nothing left to spin", () => {
|
||||
test("no field in the tool is a spinbutton any more", () => {
|
||||
// wheelWouldEdit answers false for every field now, which is the point rather
|
||||
// than a regression: a text box is not a spinbutton, so a wheel over it cannot
|
||||
// change it. The guard stays because it is what makes that safe to rely on.
|
||||
assert.equal(Money.wheelWouldEdit(
|
||||
{ tagName: "INPUT", type: "text", inputMode: "decimal" },
|
||||
{ tagName: "INPUT", type: "text", inputMode: "decimal" }), false);
|
||||
});
|
||||
|
||||
test("but it would still catch one if somebody added it back", () => {
|
||||
const spinner = { tagName: "INPUT", type: "number" };
|
||||
assert.equal(Money.wheelWouldEdit(spinner, spinner), true);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// Forty-four pixels, meaning forty-four pixels.
|
||||
//
|
||||
// Every input and select a dealer touches has a rule asking for `min-height: 44px`, and
|
||||
// not one of them was 44px tall. Form controls are content-box by default, so 44 was
|
||||
// being read as the height of the text inside and 8px of padding top and bottom plus a
|
||||
// 1px border each side went on the outside of it. Measured on an iPhone: all fifteen
|
||||
// settings fields in the sidebar stood at 62px, and the raise pad's amount box at 66px
|
||||
// with the text turned up.
|
||||
//
|
||||
// Nothing looked wrong, which is why it lasted eleven rounds. 62px clears a 44px floor,
|
||||
// so the sweep — which asks whether a target is big enough, never whether it is the size
|
||||
// it was asked to be — had nothing to say about it. The cost is only visible in the
|
||||
// aggregate: fifteen fields at 18px over is 180px of sidebar, and the raise field's 10px
|
||||
// is 10px of felt, because the action bar's height is published into --feltRoomPx and
|
||||
// the table is laid out from what is left.
|
||||
//
|
||||
// This is the third time this stylesheet has been caught by content-box — the sidebar's
|
||||
// panel headers and the rulings sheet's summaries are the other two, and both carry a
|
||||
// note saying so. The note is in the stylesheet; the list of what it covers is here.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const CSS = fs.readFileSync(path.join(
|
||||
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
// The rule, the selectors it is written against, and the note above it saying why.
|
||||
const boxSizing = () => {
|
||||
const note = CSS.indexOf("/* A field is as tall as it says it is.");
|
||||
assert.ok(note >= 0, "nothing in the stylesheet asks a field to be the height it states");
|
||||
const at = CSS.indexOf("box-sizing: border-box;", note);
|
||||
assert.ok(at >= 0);
|
||||
return CSS.slice(note, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
// Every selector anywhere in the stylesheet that gives a typing field a 44px floor,
|
||||
// wherever the media query that holds it happens to live. Checkboxes and colour swatches
|
||||
// are left out: they are sized by an explicit width and height rather than by a floor
|
||||
// their padding can push past, which is the whole of the bug this guards.
|
||||
const touchSized = () => {
|
||||
const found = [];
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const hit = CSS.indexOf("min-height: 44px", from);
|
||||
if (hit === -1) break;
|
||||
from = hit + 1;
|
||||
const opens = CSS.lastIndexOf("{", hit);
|
||||
const prior = Math.max(CSS.lastIndexOf("}", opens), CSS.lastIndexOf("{", opens - 1),
|
||||
CSS.lastIndexOf("*/", opens));
|
||||
CSS.slice(prior + 1, opens).split(",")
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(selector => found.push(selector));
|
||||
}
|
||||
return found.filter(selector =>
|
||||
/\b(input|select|textarea)\b|\.raiseInput/.test(selector) &&
|
||||
!/\[type="(checkbox|color)"\]/.test(selector));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a field is the height it says it is", () => {
|
||||
const rule = boxSizing();
|
||||
|
||||
// Every field, not a list of the surfaces fields sit on. The list version of this
|
||||
// rule was written first and had already missed one — see the note.
|
||||
test("said about the elements, so nothing can sit outside the list", () => {
|
||||
assert.match(rule, /(^|\n)input,\s*\n\s*select,\s*\n\s*textarea \{/);
|
||||
});
|
||||
|
||||
// The one field whose height is not only its own: it sets the action bar's height,
|
||||
// the bar's height is published into --feltRoomPx, and the felt is laid out from
|
||||
// what is left. Taking 10px off the bar gave them to the table.
|
||||
test("the felt's stake in it is written down", () => {
|
||||
assert.match(rule, /--feltRoomPx/);
|
||||
assert.match(rule, /248px to[\s*]*238/);
|
||||
});
|
||||
|
||||
test("and so is the measurement that found it", () => {
|
||||
assert.match(rule, /62px/);
|
||||
assert.match(rule, /fifteen settings fields/);
|
||||
});
|
||||
|
||||
// Two other rules in this file already carry a content-box note; this is the third
|
||||
// place it has bitten, and the note is what stops it being a fourth.
|
||||
test("and what it is about, so the next one is recognised", () => {
|
||||
assert.match(rule, /content-box/);
|
||||
assert.match(rule, /padding/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nothing asks for a height it will not get", () => {
|
||||
// A durable guard rather than a snapshot. If a later round gives a field a 44px
|
||||
// floor, that floor is only the height the author meant while this rule reaches the
|
||||
// field — and nothing else in the suite would notice if it stopped.
|
||||
test("every field given a touch height is measured the way that height assumes", () => {
|
||||
const fields = touchSized();
|
||||
assert.ok(fields.length >= 4, `only ${fields.length} field rules found — the scrape has drifted`);
|
||||
// The rule names the element types themselves, so the only way a field escapes
|
||||
// is a later round narrowing it back to a list of places.
|
||||
const rule = boxSizing();
|
||||
fields.forEach(selector => {
|
||||
const kind = (selector.match(/\b(input|select|textarea)\b/) || [])[1]
|
||||
|| (/\.raiseInput/.test(selector) ? "input" : null);
|
||||
assert.ok(kind && new RegExp(`(^|\\n|,\\s*)${kind}\\b`).test(rule),
|
||||
`\`${selector}\` is given a touch height that box-sizing no longer reaches`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
// What Table Setup holds while it is open.
|
||||
//
|
||||
// It is the one panel the tool opens by itself, on a table nobody has set up yet — so
|
||||
// it is most of what a dealer sees the first time they arrive. Measured on a fresh
|
||||
// table, before this: the panel stood 886px on a 667px phone, and the whole page 2076.
|
||||
//
|
||||
// The order was the problem more than the length. The two figures the panel's own
|
||||
// summary glances at — "10 seats · $1/$2" — were the seat count at the top and the
|
||||
// blinds 218px further down, under three switches and three paragraphs explaining
|
||||
// them: a dedicated dealer, a card sheet at every street, and whether the phone is
|
||||
// allowed to sleep. A dealer setting up a $1/$2 game read all of that on the way to
|
||||
// the two boxes they came for.
|
||||
//
|
||||
// The three have gone into "While you deal", with the paragraphs that explain them.
|
||||
// That is not a new idea in this panel — House take and Without live betting were
|
||||
// already nested disclosures — it is the same idea applied to the things that were not
|
||||
// about money. Afterwards: the panel is 529px, the page 1719 on the same phone and
|
||||
// 1275 on a 1280x900 desktop, and the first two rows are the seats and the blinds.
|
||||
//
|
||||
// A switch nobody can see is a switch nobody remembers turning on, so the disclosure
|
||||
// carries a glance of its own.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Render, makeTable } = 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 MAIN = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "main.js"), "utf8");
|
||||
|
||||
// The Table Setup panel, from its opening tag to the panel after it.
|
||||
const setupPanel = () => {
|
||||
const at = PAGE.indexOf('<details class="sidebarPanel" id="tableSetupPanel">');
|
||||
assert.ok(at >= 0, "there is no Table Setup panel");
|
||||
const end = PAGE.indexOf('<details class="sidebarPanel"', at + 1);
|
||||
assert.ok(end > at, "the panel after Table Setup has gone");
|
||||
return PAGE.slice(at, end);
|
||||
};
|
||||
|
||||
// What the panel shows without anything being opened: everything before the first
|
||||
// nested disclosure.
|
||||
const alwaysShowing = () => {
|
||||
const panel = setupPanel();
|
||||
const firstSub = panel.indexOf('<details class="subPanel">');
|
||||
assert.ok(firstSub >= 0, "the panel has no nested disclosures at all");
|
||||
return panel.slice(0, firstSub);
|
||||
};
|
||||
|
||||
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 glance = state => (Render.panelGlances(state).glanceWhileYouDeal || {}).text;
|
||||
|
||||
const table = () => makeTable({ seatCount: 9, seated: [], stack: 0 });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what the open panel leads with", () => {
|
||||
// The two the summary already glances at. They were first and seventh.
|
||||
test("the seats and the blinds, in that order, above everything else", () => {
|
||||
const body = alwaysShowing();
|
||||
const seats = body.indexOf('id="seatCountInput"');
|
||||
const small = body.indexOf('id="smallBlindInput"');
|
||||
const big = body.indexOf('id="bigBlindInput"');
|
||||
const stack = body.indexOf('id="defaultStackInput"');
|
||||
assert.ok(seats >= 0 && small >= 0 && big >= 0 && stack >= 0,
|
||||
"one of the four numbers is no longer in the part of the panel that shows");
|
||||
assert.ok(seats < small, "the seat count is below the blinds");
|
||||
assert.ok(small < big && big < stack, "the blinds and the stack have swapped");
|
||||
});
|
||||
|
||||
test("and nothing in it is a switch about how the tool behaves", () => {
|
||||
const body = alwaysShowing();
|
||||
["dedicatedDealerToggle", "trackBoardToggle", "keepScreenAwakeToggle"].forEach(id => {
|
||||
assert.ok(!body.includes(id), `${id} is still on the panel's first screen`);
|
||||
});
|
||||
});
|
||||
|
||||
// One line, and it is about the boxes directly above it.
|
||||
test("one paragraph survives, the short one", () => {
|
||||
const hints = alwaysShowing().match(/<p class="fieldHint">/g) || [];
|
||||
assert.equal(hints.length, 1, "the panel's first screen is back to being prose");
|
||||
assert.match(alwaysShowing(), /Blinds post themselves each hand/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("where the three switches went", () => {
|
||||
const dealing = () => {
|
||||
const panel = setupPanel();
|
||||
const at = panel.indexOf("<summary>While you deal");
|
||||
assert.ok(at >= 0, 'there is no "While you deal" disclosure');
|
||||
return panel.slice(panel.lastIndexOf("<details", at), panel.indexOf("</details>", at));
|
||||
};
|
||||
|
||||
test("all three of them, with the paragraphs that explain them", () => {
|
||||
const body = dealing();
|
||||
["dedicatedDealerToggle", "trackBoardToggle", "keepScreenAwakeToggle"].forEach(id => {
|
||||
assert.ok(body.includes(id), `${id} did not come with the rest`);
|
||||
});
|
||||
assert.match(body, /no seat shows the dealer button/);
|
||||
assert.match(body, /a sheet asks which cards came off the deck/);
|
||||
assert.match(body, /Stops the phone dimming and locking/);
|
||||
});
|
||||
|
||||
// The same shape House take and Without live betting already had. A disclosure that
|
||||
// ships open is a disclosure that has saved nobody anything.
|
||||
test("it is shut, like the two money ones beside it", () => {
|
||||
const panel = setupPanel();
|
||||
assert.ok(!/<details class="subPanel"[^>]*\bopen\b/.test(panel),
|
||||
"a nested disclosure in this panel ships open");
|
||||
});
|
||||
|
||||
test("and it sits above them, because it is not about money", () => {
|
||||
const panel = setupPanel();
|
||||
assert.ok(panel.indexOf("While you deal") < panel.indexOf("<summary>House take"));
|
||||
assert.ok(panel.indexOf("<summary>House take") < panel.indexOf("<summary>Without live betting"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the glance on a shut disclosure", () => {
|
||||
test("silence when all three are where they ship", () => {
|
||||
const state = table();
|
||||
assert.equal(glance(state), "");
|
||||
});
|
||||
|
||||
test("and a name when one of them is not", () => {
|
||||
const state = table();
|
||||
state.trackBoard = true;
|
||||
assert.equal(glance(state), "Asking for cards");
|
||||
|
||||
const other = table();
|
||||
other.dedicatedDealer = true;
|
||||
assert.equal(glance(other), "House dealer");
|
||||
});
|
||||
|
||||
// The only one that ships on, so it is its absence that is worth saying.
|
||||
test("the screen setting speaks by going quiet, not by being on", () => {
|
||||
const on = table();
|
||||
on.keepScreenAwake = true;
|
||||
assert.equal(glance(on), "");
|
||||
|
||||
const off = table();
|
||||
off.keepScreenAwake = false;
|
||||
assert.equal(glance(off), "Screen may sleep");
|
||||
});
|
||||
|
||||
// Joined, the three run to 49 characters, and this row is 44px of a 320px column
|
||||
// with a label already on it. "House dealer · Asking for c…" has told nobody
|
||||
// anything; the one most likely to be the surprise has.
|
||||
test("one of them, never a list of three", () => {
|
||||
const state = table();
|
||||
state.trackBoard = true;
|
||||
state.dedicatedDealer = true;
|
||||
state.keepScreenAwake = false;
|
||||
assert.equal(glance(state), "Asking for cards");
|
||||
assert.ok(glance(state).length <= 24);
|
||||
});
|
||||
|
||||
test("it is a row this sidebar knows how to write", () => {
|
||||
assert.ok(Render.PANEL_GLANCES.includes("glanceWhileYouDeal"),
|
||||
"the glance is computed and never written, so the row keeps its old text all night");
|
||||
const panel = setupPanel();
|
||||
const at = panel.indexOf('id="glanceWhileYouDeal"');
|
||||
assert.ok(at >= 0, "there is nowhere to write it");
|
||||
assert.match(panel.slice(panel.lastIndexOf("<span", at), at + 40), /class="panelGlance subPanelGlance"/);
|
||||
assert.match(panel.slice(at, panel.indexOf(">", at)), /hidden/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what had to be true for it to hang there", () => {
|
||||
// Without this the span anchors to whichever ancestor is positioned, which is the
|
||||
// sidebar — the glance would appear at the top right of the whole column.
|
||||
test("the summary it hangs off is a positioned box", () => {
|
||||
assert.match(ruleFor(".subPanel > summary"), /position:\s*relative/);
|
||||
});
|
||||
|
||||
// A footnote on a line that is itself a footnote. At the weight of "Off by $40" the
|
||||
// eye stops on a row saying nothing has gone wrong.
|
||||
test("and it is drawn a step quieter than the panel headings' own", () => {
|
||||
const sub = ruleFor(".subPanelGlance");
|
||||
assert.match(sub, /font-size:\s*var\(--fs-xs\)/);
|
||||
assert.match(sub, /color:\s*var\(--text-dim\)/);
|
||||
assert.match(ruleFor(".panelGlance"), /font-size:\s*var\(--fs-sm\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the panel still opens itself only when it should", () => {
|
||||
test("on a table nobody has touched, and not on the fourth hand", () => {
|
||||
const at = MAIN.indexOf('document.getElementById("tableSetupPanel")');
|
||||
assert.ok(at >= 0, "nothing decides whether the panel starts open");
|
||||
const block = MAIN.slice(at, at + 400);
|
||||
assert.match(block, /Number\(state\.handNumber\) \|\| 1\) === 1/);
|
||||
assert.match(block, /!state\.seats\.some\(seat => HoldemState\.seatInPlay\(seat\)\)/);
|
||||
assert.match(block, /panel\.open = fresh/);
|
||||
});
|
||||
|
||||
test("what was measured is written beside the decision", () => {
|
||||
const at = MAIN.indexOf("Table Setup used to ship open");
|
||||
assert.ok(at >= 0);
|
||||
const note = MAIN.slice(at, at + 1200);
|
||||
assert.match(note, /886px/);
|
||||
assert.match(note, /529px/);
|
||||
});
|
||||
});
|
||||
@@ -97,9 +97,9 @@ describe("the header is one row on a phone", () => {
|
||||
});
|
||||
|
||||
test("and the buttons grow into that width without ever shrinking below their labels", () => {
|
||||
// `flex: 1 1 0` would divide the row into equal thirds and squeeze "Next Hand ⟳"
|
||||
// — 105px of text — into 99. These are `white-space: nowrap`, so what gives is
|
||||
// the text.
|
||||
// `flex: 1 1 0` would divide the row into equal shares: at 360px the row is 269
|
||||
// wide, so mid-hand that is 87px each and "Next Hand ⟳" needs 89 — 73 of text and
|
||||
// 16 of padding. These are `white-space: nowrap`, so what gives is the text.
|
||||
const rule = ruleIn(PHONE(), "#holdemHeaderActions .holdemBtn") || "";
|
||||
assert.match(rule, /flex:\s*1\s+1\s+auto/, "an auto basis makes the label the floor");
|
||||
assert.match(rule, /white-space:\s*nowrap/);
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
// Where a sheet puts the dealer, and whether they can get anywhere from there.
|
||||
//
|
||||
// Modal exists because six overlays were each missing the same three things, and it
|
||||
// fixed all six at once by answering one question the same way everywhere: focus the
|
||||
// first control, and hold Tab between the first and the last. Measured this round, that
|
||||
// single answer had gone wrong in three separate directions.
|
||||
//
|
||||
// It could not see a <summary>. The rulings sheet is twelve of them and one Done
|
||||
// button, so the trap decided Done was both ends of the ring and bounced every press
|
||||
// back to it: eighty Tab presses, one element ever reached, twelve rulings that could
|
||||
// not be opened without a mouse.
|
||||
//
|
||||
// "The first control" was the card picker's Clear. The sheet a dealer opens to write a
|
||||
// card down opened on the button that rubs one out — the one thing the APG says to keep
|
||||
// a dialog off. And "the first control" is not an answer six dialogs share: Roselli's
|
||||
// rule is that a dialog long enough to scroll should land on its heading so the reader
|
||||
// can orient, and a short question with one irreversible answer should land on the
|
||||
// answer that leaves them no worse off.
|
||||
//
|
||||
// And a sheet that redraws itself was deleting the control that held focus. Choosing a
|
||||
// buy-in, picking a card, switching slots, confirming a side pot: all four left focus on
|
||||
// the document body, which inside a modal is nowhere at all.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Modal, 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 = name => fs.readFileSync(
|
||||
path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
|
||||
|
||||
// The markup of one overlay, from its opening div to the next one's.
|
||||
const OVERLAY_ORDER = ["addChipsOverlay", "rulingsOverlay", "seatMenuOverlay",
|
||||
"cardPickerOverlay", "confirmOverlay", "potWizardOverlay"];
|
||||
const overlay = id => {
|
||||
const at = PAGE.indexOf(`<div id="${id}"`);
|
||||
assert.ok(at >= 0, `there is no ${id}`);
|
||||
const ends = OVERLAY_ORDER
|
||||
.map(other => PAGE.indexOf(`<div id="${other}"`, at + 1))
|
||||
.filter(i => i > at);
|
||||
return PAGE.slice(at, ends.length ? Math.min(...ends) : PAGE.length);
|
||||
};
|
||||
|
||||
// A document small enough to answer the handful of questions these functions ask of
|
||||
// one, and honest about the two that matter: a detached node knows it is detached, and
|
||||
// a disabled button refuses focus.
|
||||
function fakeSheet(specs) {
|
||||
const doc = { activeElement: null };
|
||||
const nodes = specs.map(spec => ({
|
||||
dataset: spec.key === undefined ? {} : { focusKey: spec.key },
|
||||
disabled: !!spec.disabled,
|
||||
isConnected: true,
|
||||
tabIndex: 0,
|
||||
label: spec.key || spec.label,
|
||||
focus() {
|
||||
if (this.disabled) return;
|
||||
doc.activeElement = this;
|
||||
},
|
||||
}));
|
||||
const root = {
|
||||
nodes,
|
||||
contains: el => nodes.includes(el),
|
||||
querySelectorAll: () => nodes.filter(n => n.dataset.focusKey !== undefined),
|
||||
};
|
||||
return { doc, root, nodes };
|
||||
}
|
||||
|
||||
// preservingFocus and the picker's cursor both reach for the global document. They are
|
||||
// given one for the length of a test and it is taken away again afterwards.
|
||||
function withDocument(doc, body) {
|
||||
const had = Object.prototype.hasOwnProperty.call(globalThis, "document");
|
||||
const before = globalThis.document;
|
||||
globalThis.document = doc;
|
||||
try {
|
||||
return body();
|
||||
} finally {
|
||||
if (had) globalThis.document = before;
|
||||
else delete globalThis.document;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what counts as somewhere you can Tab to", () => {
|
||||
const selectors = Modal.FOCUSABLE.split(",");
|
||||
|
||||
// The whole of the rulings sheet hung on this one omission.
|
||||
test("a summary is a control, because the browser already treats it as one", () => {
|
||||
assert.ok(selectors.some(s => /details\s*>\s*summary/.test(s)),
|
||||
"a <details> summary is focusable without a tabindex, and this list can't see it");
|
||||
});
|
||||
|
||||
// Left off, a card taken out of the Tab order still matches `button`, and the grid
|
||||
// goes back to being fifty-two stops with a roving tabindex painted on top.
|
||||
test("every line excludes what has been taken out of the Tab order, not just the last", () => {
|
||||
selectors.forEach(selector => {
|
||||
assert.ok(/:not\(\[tabindex="-1"\]\)/.test(selector) || /^\s*\[tabindex\]/.test(selector),
|
||||
`\`${selector.trim()}\` still matches controls that are not Tab stops`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and the sheet itself is still off the list", () => {
|
||||
assert.ok(/\[tabindex\]:not\(\[tabindex="-1"\]\)/.test(Modal.FOCUSABLE));
|
||||
});
|
||||
});
|
||||
|
||||
describe("where each sheet lands the dealer", () => {
|
||||
const LANDS_ON_ITS_HEADING = [
|
||||
["cardPickerOverlay", "cardPickerTitle"],
|
||||
["addChipsOverlay", "addChipsTitle"],
|
||||
["rulingsOverlay", "rulingsTitle"],
|
||||
["potWizardOverlay", "potWizardTitle"],
|
||||
["seatMenuOverlay", "seatMenuTitle"],
|
||||
];
|
||||
|
||||
LANDS_ON_ITS_HEADING.forEach(([id, heading]) => {
|
||||
test(`${id} opens on the line that says which`, () => {
|
||||
const markup = overlay(id);
|
||||
const tag = markup.match(new RegExp(`<h2 id="${heading}"[^>]*>`));
|
||||
assert.ok(tag, `${heading} is not an h2 in ${id}`);
|
||||
assert.match(tag[0], /data-modal-focus/);
|
||||
assert.match(tag[0], /tabindex="-1"/,
|
||||
"a heading that is a Tab stop is a Tab stop the dealer has to press past");
|
||||
});
|
||||
});
|
||||
|
||||
// The APG's own example of when to land on a control rather than a heading, and the
|
||||
// reason: one of the two answers cannot be taken back.
|
||||
test("the confirm dialog is the exception, and it lands on Cancel", () => {
|
||||
const markup = overlay("confirmOverlay");
|
||||
const cancel = markup.match(/<button id="confirmCancelBtn"[^>]*>/);
|
||||
assert.ok(cancel);
|
||||
assert.match(cancel[0], /data-modal-focus/);
|
||||
assert.ok(!/<button id="confirmAcceptBtn"[^>]*data-modal-focus/.test(markup),
|
||||
"the dialog lands on the answer that cannot be taken back");
|
||||
});
|
||||
|
||||
test("no sheet names two landing spots, which would be a coin toss", () => {
|
||||
OVERLAY_ORDER.forEach(id => {
|
||||
const found = (overlay(id).match(/data-modal-focus/g) || []).length;
|
||||
assert.equal(found, 1, `${id} names ${found} landing spots`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the card picker no longer opens on Clear", () => {
|
||||
const markup = overlay("cardPickerOverlay");
|
||||
assert.ok(markup.indexOf('id="cardPickerTitle"') < markup.indexOf('id="cardPickerClearBtn"'),
|
||||
"the heading has moved below the button it was there to keep focus off");
|
||||
assert.ok(!/id="cardPickerClearBtn"[^>]*data-modal-focus/.test(markup));
|
||||
});
|
||||
|
||||
// The landing spot is deliberately outside the ring, and a wrap that only knows
|
||||
// "is this the first one" walks straight out of the dialog on the first Shift+Tab.
|
||||
test("the trap wraps from outside the ring as well as from its ends", () => {
|
||||
const modal = src("modal");
|
||||
const at = modal.indexOf("const at = focusable.indexOf(");
|
||||
assert.ok(at >= 0, "the Tab trap no longer asks where the dealer is in the ring");
|
||||
const body = modal.slice(at, modal.indexOf("\n },", at));
|
||||
assert.match(body, /shiftKey && at <= 0/);
|
||||
assert.match(body, /at === -1/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a redraw that does not drop the dealer", () => {
|
||||
test("focus comes back to the control with the same name", () => {
|
||||
const { doc, root, nodes } = fakeSheet([
|
||||
{ key: "quick-500" }, { key: "quick-1000" }, { key: "quick-2000" }]);
|
||||
doc.activeElement = nodes[1];
|
||||
|
||||
withDocument(doc, () => Modal.preservingFocus(root, () => {
|
||||
// What a rebuild does: the old buttons go, identical new ones arrive.
|
||||
nodes.forEach(n => { n.isConnected = false; });
|
||||
root.nodes = root.querySelectorAll = null;
|
||||
const rebuilt = fakeSheet([{ key: "quick-500" }, { key: "quick-1000" }, { key: "quick-2000" }]);
|
||||
rebuilt.nodes.forEach(n => { n.focus = () => { doc.activeElement = n; }; });
|
||||
root.querySelectorAll = () => rebuilt.nodes;
|
||||
root.contains = el => rebuilt.nodes.includes(el);
|
||||
}));
|
||||
|
||||
assert.equal(doc.activeElement.dataset.focusKey, "quick-1000");
|
||||
});
|
||||
|
||||
// The card just picked is a dealt card now, and a dealt card is a disabled button.
|
||||
// There is no coming back to it, so the caller is asked where to go instead.
|
||||
test("a name that comes back disabled hands the decision to the caller", () => {
|
||||
const { doc, root, nodes } = fakeSheet([{ key: "AS" }, { key: "KH" }]);
|
||||
doc.activeElement = nodes[0];
|
||||
let asked = false;
|
||||
|
||||
withDocument(doc, () => Modal.preservingFocus(root, () => {
|
||||
nodes[0].isConnected = false;
|
||||
nodes[0].disabled = true;
|
||||
}, () => { asked = true; }));
|
||||
|
||||
assert.ok(asked, "focus was left on the body of a modal");
|
||||
});
|
||||
|
||||
test("and a name that is simply gone asks too", () => {
|
||||
const { doc, root, nodes } = fakeSheet([{ key: "confirm" }]);
|
||||
doc.activeElement = nodes[0];
|
||||
let asked = false;
|
||||
|
||||
withDocument(doc, () => Modal.preservingFocus(root, () => {
|
||||
nodes[0].isConnected = false;
|
||||
root.querySelectorAll = () => [];
|
||||
}, () => { asked = true; }));
|
||||
|
||||
assert.ok(asked);
|
||||
});
|
||||
|
||||
// Clear is in the header. Nothing rebuilds it, so nothing should move it — a redraw
|
||||
// the dealer asked for is not a reason to take them somewhere else.
|
||||
test("a control that survived the redraw is left exactly where it was", () => {
|
||||
const { doc, root, nodes } = fakeSheet([{ label: "Clear" }, { key: "AS" }]);
|
||||
doc.activeElement = nodes[0];
|
||||
let asked = false;
|
||||
|
||||
withDocument(doc, () => Modal.preservingFocus(root, () => {
|
||||
nodes[1].isConnected = false;
|
||||
}, () => { asked = true; }));
|
||||
|
||||
assert.equal(doc.activeElement, nodes[0]);
|
||||
assert.ok(!asked);
|
||||
});
|
||||
|
||||
test("and a dealer who was never in this sheet is not dragged into it", () => {
|
||||
const { doc, root } = fakeSheet([{ key: "AS" }]);
|
||||
const elsewhere = { dataset: {}, isConnected: true };
|
||||
doc.activeElement = elsewhere;
|
||||
let asked = false;
|
||||
|
||||
withDocument(doc, () => Modal.preservingFocus(root, () => {}, () => { asked = true; }));
|
||||
|
||||
assert.equal(doc.activeElement, elsewhere);
|
||||
assert.ok(!asked);
|
||||
});
|
||||
|
||||
// Four sheets redraw themselves on a tap. All four go through this now, and each
|
||||
// one hands over the piece of itself it is about to throw away.
|
||||
[["addChips", "render()", "addChipsSheet"],
|
||||
["cardPicker", "render()", "cardPickerSheet"],
|
||||
["seatMenu", "render()", "seatMenuOverlay"],
|
||||
["potWizard", "renderStepInto()", "potWizardModal"]].forEach(([module, method, root]) => {
|
||||
test(`${module}'s ${method} redraws through it`, () => {
|
||||
const text = src(module);
|
||||
const at = text.indexOf(`\n ${method} {`);
|
||||
assert.ok(at >= 0, `${module} has no ${method}`);
|
||||
const body = text.slice(at, text.indexOf("\n },", at));
|
||||
assert.match(body, /Modal\.preservingFocus\(/,
|
||||
`${module} rebuilds its controls without saying what to do about focus`);
|
||||
assert.ok(body.includes(`"${root}"`),
|
||||
`${module} hands preservingFocus something other than ${root}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
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");
|
||||
});
|
||||
|
||||
// A dealt card is `aria-disabled` rather than `disabled`, so it holds focus like any
|
||||
// other cell — which is what lets the arrows tell the truth about the grid, and what
|
||||
// lets pressing one be answered instead of ignored. See theUnansweredPress.
|
||||
const grid = keys => keys.map(key => ({
|
||||
dataset: { focusKey: key.replace("!", "") },
|
||||
getAttribute: name => (name === "aria-disabled" && key.includes("!") ? "true" : null),
|
||||
tabIndex: 0,
|
||||
focus() {},
|
||||
}));
|
||||
|
||||
const withGrid = (cards, body) => {
|
||||
const nodes = grid(cards);
|
||||
return withDocument({ querySelectorAll: () => nodes }, () => body(nodes));
|
||||
};
|
||||
|
||||
test("a step is one card, whether or not that card is already out", () => {
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
// 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 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", "KS!", "QS", "JS", "TS", "9S", "8S", "7S"], () => {
|
||||
const afterRight = CardPicker.nextCursor(0, 1);
|
||||
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 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", "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", "KS", "QS", "JS"], nodes => {
|
||||
CardPicker.aimCursor(2);
|
||||
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, 0, -1]);
|
||||
});
|
||||
});
|
||||
|
||||
// Which happens after every pick: the cursor is sitting on a card that has just
|
||||
// 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!", "KS", "QS", "JS"], nodes => {
|
||||
CardPicker.aimCursor(0);
|
||||
assert.equal(CardPicker.cursor, 0);
|
||||
assert.deepEqual(nodes.map(n => n.tabIndex), [0, -1, -1, -1]);
|
||||
});
|
||||
});
|
||||
|
||||
test("and a grid with nothing left in it still has somewhere to put the cursor", () => {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
// An index from outside the grid is clamped rather than wrapped, which is what
|
||||
// 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", "KS", "QS", "JS"], () => {
|
||||
assert.equal(CardPicker.nextCursor(-1, 1), 0);
|
||||
assert.equal(CardPicker.nextCursor(4, -1), 3);
|
||||
});
|
||||
});
|
||||
|
||||
// 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\.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) {"]
|
||||
.forEach(signature => {
|
||||
const at = picker.indexOf(signature);
|
||||
assert.ok(at >= 0, `there is no \`${signature}\``);
|
||||
assert.match(picker.slice(at, picker.indexOf("this.render();", at)), /this\.cursor = 0;/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("seeing where you are", () => {
|
||||
// Until a summary could be Tabbed to at all, there was no way to notice that
|
||||
// twenty of them had no ring.
|
||||
test("a summary gets the tool's own focus ring, not the browser's", () => {
|
||||
const at = CSS.indexOf("---------- Keyboard focus ----------");
|
||||
assert.ok(at >= 0);
|
||||
const block = CSS.slice(at, CSS.indexOf("outline: 2px solid #7fc4ff", at));
|
||||
assert.match(block, /(^|\n)summary:focus-visible,/);
|
||||
});
|
||||
|
||||
// Only script ever focuses these, so `:focus` would ring the title of every sheet
|
||||
// on a phone that has no keyboard to ring it for.
|
||||
test("the landing heading rings for a keyboard and stays quiet for a thumb", () => {
|
||||
const at = CSS.indexOf('h2[data-modal-focus]:focus-visible {');
|
||||
assert.ok(at >= 0, "the heading a sheet lands on shows nothing when it takes focus");
|
||||
const rule = CSS.slice(at, CSS.indexOf("}", at));
|
||||
assert.match(rule, /outline: 2px solid #7fc4ff/);
|
||||
// These headings run to the edge of their sheet; an outward ring is clipped.
|
||||
assert.match(rule, /outline-offset: -2px/);
|
||||
assert.ok(!/\[data-modal-focus\]:focus\b(?!-)/.test(CSS),
|
||||
"a plain :focus rule would ring the title on every tap");
|
||||
});
|
||||
});
|
||||
+533
-41
@@ -15,6 +15,8 @@ const { ActionBar, Render } = require("./load");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
const CSS_JS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"), "utf8");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -115,53 +117,506 @@ describe("the bar lines up with the table", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the button seat's name", () => {
|
||||
// The dealer button's chip sits in the puck's top-right corner. Keeping the name out
|
||||
// from under it with `padding-right` takes the chip's width off *every* line of the
|
||||
// name, and the chip is on one of them: measured at 1280x900, the name box is 78px,
|
||||
// "Christopher" is 66 and fits, and on the seat holding the button it was cut to 56
|
||||
// and broke as "Christop / her". The button moves every hand, so that is a player
|
||||
// whose name reads properly for four hands and comes apart on the fifth.
|
||||
const roomy = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
|
||||
const float = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName::before \{([^}]*)\}/);
|
||||
const base = CSS.match(/\n\.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
|
||||
// The button seat's name is the same name in the same box as every other seat's.
|
||||
//
|
||||
// Three attempts said otherwise, in this order: a float, a float with a first-line
|
||||
// indent, and `padding-right: 25px` — the dealer chip's width plus its inset, taken
|
||||
// off the name because the chip sat inside the puck's top-right corner. All three
|
||||
// were answers to a chip that had no business being in the writing, and the price
|
||||
// was a quarter of the name's line on whichever seat currently held the button.
|
||||
// Measured with the sweep's twelve names: 7 of 12 fitted on the button seat at
|
||||
// 9-handed on an iPhone against 11 anywhere else, and 1 of 12 against 7 at
|
||||
// 12-handed on a Galaxy S8.
|
||||
const name = CSS.match(/\n\.seatPuckName \{([^}]*)\}/);
|
||||
|
||||
test("a ring with room for two lines indents only the line beside the chip", () => {
|
||||
assert.ok(roomy && float, "the roomy regime should use a float");
|
||||
assert.match(float[1], /float:\s*right/);
|
||||
assert.match(roomy[1], /padding-right:\s*0/);
|
||||
test("nothing reserves a gutter inside the name any more", () => {
|
||||
assert.equal(/\.seat\.seatHasButton \.seatPuckName/.test(CSS), false,
|
||||
"the button seat's name should be the same box as every other seat's");
|
||||
});
|
||||
|
||||
test("the float is exactly one line tall", () => {
|
||||
// Tall enough to cover the chip's reach into the name, and no taller: a float
|
||||
// that spills into the second line indents that one too, which puts the whole
|
||||
// problem back.
|
||||
assert.match(float[1], /height:\s*1\.2em/);
|
||||
test("the float that used to do this is gone, not merely unused", () => {
|
||||
// Left in place with a one-line name it would clip the name to nothing: the word
|
||||
// cannot fit beside the float, so it moves to a second line, and there is no
|
||||
// second line. Measured — "Christopher" drew as an empty label.
|
||||
assert.equal(/\.seatPuckName::before/.test(CSS), false,
|
||||
"a float and one line of name cannot both be true");
|
||||
});
|
||||
|
||||
test("break-word stands down where the float is doing the work", () => {
|
||||
// It is what stops the float working — it breaks a word to fit the short line
|
||||
// rather than letting it move past the float to the long one.
|
||||
assert.match(roomy[1], /overflow-wrap:\s*normal/);
|
||||
test("and no rule anywhere gives a puck's name a second line", () => {
|
||||
// This is what every puck being the same height rests on. A name is a label, and
|
||||
// the ring is sized against the tallest puck on it, so one name wrapping is one
|
||||
// seat setting how far apart all of them have to sit.
|
||||
assert.ok(name, "the name should have a base rule");
|
||||
assert.match(name[1], /white-space:\s*nowrap/);
|
||||
});
|
||||
|
||||
test("a crowded ring keeps the padding, because it has only one line", () => {
|
||||
// `.seatsTight .seatPuckName` is `line-clamp: 1` on purpose, and one line has
|
||||
// nowhere to move to — the only way to keep it off the chip is to shorten it.
|
||||
// Trying the float on both cost a whole sweep to find out: the name dropped onto
|
||||
// a second line that the chip's lower half was still sitting on, and the audit
|
||||
// called it at every viewport in the list.
|
||||
assert.ok(base, "the base rule should still reserve the chip's width");
|
||||
assert.match(base[1], /padding-right:\s*22px/);
|
||||
assert.match(CSS, /\.seatsTight \.seatPuckName \{[^}]*line-clamp:\s*1/);
|
||||
test("and it is one line by not wrapping, not by hiding what wrapped", () => {
|
||||
// A `-webkit-box` clamped to one line hides the lines past the first; it does not
|
||||
// hide the part of the second one that the row's own min-height leaves uncovered.
|
||||
// The row is 15px and .puckNameTight's line box is 12.24, so 2.76px of the next
|
||||
// line showed — "Christ…" with the ascenders of "opher" underneath it.
|
||||
assert.equal(/(?<![\w-])line-clamp:/.test(CSS), false,
|
||||
"a clamp and a fixed row height only agree while the type never moves");
|
||||
assert.match(name[1], /text-overflow:\s*ellipsis/);
|
||||
});
|
||||
|
||||
test("the float is as wide as the chip it stands in for", () => {
|
||||
const chip = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])width:\s*(\d+)px/);
|
||||
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(\d+)px/);
|
||||
const floatW = float[1].match(/width:\s*(\d+)px/);
|
||||
assert.ok(chip && inset && floatW);
|
||||
assert.equal(Number(floatW[1]), Number(chip[1]) + Number(inset[1]),
|
||||
"the float should cover the chip and the gap it is inset by");
|
||||
test("the box a name is measured in is the box it is drawn in", () => {
|
||||
// What the clamp cost besides the stray line: a name that wrapped reported
|
||||
// scrollWidth == clientWidth, so fitSeatNames() could only ever see the overflow
|
||||
// as height. `nowrap` puts the whole name on one line whether it fits or not,
|
||||
// which is what makes the width worth reading.
|
||||
assert.match(name[1], /overflow:\s*hidden/);
|
||||
assert.equal(/overflow-wrap:\s*break-word/.test(name[1]), false,
|
||||
"nothing to break a word onto");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the table is sized against the bar, not against a pad over it", () => {
|
||||
// The raise pad is 57px taller than the row it replaces, and the felt was sized
|
||||
// against it. Measured with five players nine-handed, pressing Raise:
|
||||
//
|
||||
// 1280x900 felt 738x532 -> 669x481, pucks 120 -> 109, worst seat moved 73px
|
||||
// 360x640 felt 283x329 -> 283x279, pucks 44 -> 48, worst seat moved 50px
|
||||
//
|
||||
// The whole table jumped, re-ran the ten-candidate width search, and undid all of it
|
||||
// when the dealer pressed Raise to. Every raise, all night, at the one moment they
|
||||
// are reading the felt to decide what to type.
|
||||
//
|
||||
// A phone held sideways already scored 0px, because the rail is a column down the
|
||||
// side and its height never was the table's business. This is the upright layout
|
||||
// being held to what the sideways one already does.
|
||||
test.afterEach(() => { ActionBar.raiseOpen = false; delete ActionBar.barHeightAtRest; });
|
||||
|
||||
test("a closed bar is measured, and remembered", () => {
|
||||
ActionBar.raiseOpen = false;
|
||||
assert.equal(ActionBar.restingBarHeight(151), 151);
|
||||
assert.equal(ActionBar.barHeightAtRest, 151);
|
||||
});
|
||||
|
||||
test("and an open pad gets the height the bar had before it opened", () => {
|
||||
ActionBar.raiseOpen = false;
|
||||
ActionBar.restingBarHeight(151);
|
||||
ActionBar.raiseOpen = true;
|
||||
assert.equal(ActionBar.restingBarHeight(208), 151,
|
||||
"the felt would resize under the dealer's thumb mid-raise");
|
||||
});
|
||||
|
||||
test("and gets it back the moment the pad closes", () => {
|
||||
ActionBar.raiseOpen = false;
|
||||
ActionBar.restingBarHeight(151);
|
||||
ActionBar.raiseOpen = true;
|
||||
ActionBar.restingBarHeight(208);
|
||||
ActionBar.raiseOpen = false;
|
||||
assert.equal(ActionBar.restingBarHeight(172), 172,
|
||||
"a bar that really did grow — a side pot to show — still takes its room");
|
||||
});
|
||||
|
||||
test("a pad open before anything was ever measured takes what it can see", () => {
|
||||
// Nothing has been remembered yet, so the measurement is the only answer there
|
||||
// is. Better a felt sized for the pad than one sized for undefined.
|
||||
ActionBar.raiseOpen = true;
|
||||
delete ActionBar.barHeightAtRest;
|
||||
assert.equal(ActionBar.restingBarHeight(208), 208);
|
||||
});
|
||||
|
||||
test("the bar that is actually covering the window is still reported honestly", () => {
|
||||
// Only the felt's question is answered from the resting height. The toast has to
|
||||
// clear whatever is really there, and so does the page's scroll padding, so
|
||||
// --actionBarHeight stays a measurement.
|
||||
const publish = CSS_JS.slice(CSS_JS.indexOf("const publish = () => {"));
|
||||
const body = publish.slice(0, publish.indexOf("};"));
|
||||
assert.match(body, /--actionBarHeight[^\n]*Math\.round\(height\)/,
|
||||
"--actionBarHeight should still be the measured height");
|
||||
assert.match(body, /publishFeltRoom\(.*restingBarHeight\(height\)/,
|
||||
"the felt should be the one asking for the resting height");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the status block is the same number of lines whoever is acting", () => {
|
||||
// The bar is fixed to the bottom, so a line added to this block comes straight off
|
||||
// the felt. The block wraps, and flex settles its line breaks before it decides what
|
||||
// to shrink — so a name too long for the space left beside the clock and the undo
|
||||
// pill did not give way, it pushed them onto a third line.
|
||||
//
|
||||
// Measured at 360x640 mid-street: "Dee · $600 behind" left the table 283x329 and
|
||||
// "Christopher · $1,200 behind" made it 283x310, with every seat moving 24px between
|
||||
// one player and the next. Whose turn it was decided how big the table was.
|
||||
const acting = CSS.match(/\.actionActing \{([^}]*)\}/);
|
||||
const who = CSS.match(/\n\.actionWho \{([^}]*)\}/);
|
||||
const name = CSS.match(/\.actionWhoName \{([^}]*)\}/);
|
||||
const stack = CSS.match(/\.actionWhoStack \{([^}]*)\}/);
|
||||
|
||||
test("the acting row takes a line of its own, once there is somebody on it", () => {
|
||||
assert.ok(acting, ".actionActing rule not found");
|
||||
assert.match(acting[1], /flex:\s*0 1 auto/,
|
||||
"the pill on its own should not claim a line");
|
||||
const withActor = CSS.match(/\.actionActing:has\(\.actionWho\) \{([^}]*)\}/);
|
||||
assert.ok(withActor, "nothing gives the acting row its own line");
|
||||
assert.match(withActor[1], /flex:\s*1 1 100%/,
|
||||
"without a full basis it shares whatever line the wrap left it");
|
||||
});
|
||||
|
||||
test("and only then — a run-out has nobody to act and cannot spare the line", () => {
|
||||
// While a board is running out there is nobody to act, so this row is the undo
|
||||
// pill alone. Claiming a line for it is 22px off the felt on the twelve-handed
|
||||
// 360x640 table where the pucks are already at their 44px floor, and the sweep
|
||||
// caught that as a seat's "ALL IN" going back under the blind badge next door.
|
||||
assert.doesNotMatch(acting[1], /flex:\s*1 1 100%/);
|
||||
});
|
||||
|
||||
test("and it shrinks rather than wrapping", () => {
|
||||
// Left wrapping, this row reproduced the fault one level down: the undo pill is
|
||||
// a fixed width, so a long name pushed it onto a line of its own and the block
|
||||
// was 84px for some players and 66 for others.
|
||||
assert.match(acting[1], /flex-wrap:\s*nowrap/);
|
||||
});
|
||||
|
||||
test("the name is what gives way", () => {
|
||||
// `min-width` on a flex item defaults to `auto` — "never smaller than your own
|
||||
// content" — which is why the ellipsis this was written with never once appeared.
|
||||
assert.ok(who && name, "the actor should be a name and an amount, not one string");
|
||||
assert.match(who[1], /min-width:\s*0/);
|
||||
assert.match(name[1], /min-width:\s*0/);
|
||||
assert.match(name[1], /text-overflow:\s*ellipsis/);
|
||||
});
|
||||
|
||||
test("and the stack is what does not", () => {
|
||||
// The same rule the seat pucks keep: a number read wrong is a worse failure than
|
||||
// a name read short, so the stack keeps every digit.
|
||||
assert.ok(stack, ".actionWhoStack rule not found");
|
||||
assert.match(stack[1], /flex:\s*none/);
|
||||
});
|
||||
|
||||
test("except in the rail, which is a column and has no line to shrink into", () => {
|
||||
// 190px wide: every one of these readings is on its own line there regardless,
|
||||
// and squeezing a name into a fifth of the column would be shrinking to fit a
|
||||
// row that isn't there.
|
||||
const rules = [...CSS.matchAll(/\.actionActing \{([^}]*)\}/g)].map(m => m[1]);
|
||||
assert.equal(rules.length, 2,
|
||||
"one rule for the bar and one for the rail — no more, no fewer");
|
||||
assert.ok(rules.some(body => /flex-wrap:\s*wrap\s*;/.test(body)),
|
||||
"the rail should say what it wants instead");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("and the name is the half that is still there at the end of it", () => {
|
||||
// Shrinking the name had no floor under it. Flex takes the only item that can give
|
||||
// way and takes it all the way, so with the clock button on the line there was
|
||||
// nothing left to give: measured with five players nine-handed, mid-street, forcing
|
||||
// the clock button on —
|
||||
//
|
||||
// 360x640 "Christopher" wanted 62px and was drawn 0. "Alexandra" 54 -> 0.
|
||||
// 390x844 "Christopher" wanted 62px and was drawn 0. "Alexandra" 54 -> 0.
|
||||
// 430x932 "Christopher" 62 -> 31.
|
||||
//
|
||||
// What the bar read at the first two was "· $1,200 behind" — the separator, the
|
||||
// figure, and no player. The ellipsis the rule was written around never appeared,
|
||||
// because there were no characters left to put one after.
|
||||
//
|
||||
// The rule itself is kept: a number read wrong is worse than a name read short, so
|
||||
// the stack is never truncated. It is taken off the line whole instead. Measured
|
||||
// again afterwards, with the same clock button on the same line:
|
||||
//
|
||||
// 360x640 "Christopher" 62 -> 44 ("Christo…"), "Alexandra" 54 -> 44
|
||||
// 390x844 both whole
|
||||
// 430x932 both whole
|
||||
//
|
||||
// and 0px of seat movement across the thirty-second mark at every one of them.
|
||||
const withLine = (name, stack, run) => {
|
||||
const el = (extra) => Object.assign({
|
||||
hidden: false, scrollWidth: 0, clientWidth: 0,
|
||||
}, extra);
|
||||
const nameEl = el(name);
|
||||
const stackEl = el(stack);
|
||||
global.document = {
|
||||
querySelector: sel => (sel.endsWith(".actionWhoName") ? nameEl
|
||||
: sel.endsWith(".actionWhoStack") ? stackEl : null),
|
||||
};
|
||||
try { run(nameEl, stackEl); } finally { delete global.document; }
|
||||
};
|
||||
|
||||
test("a name that fits keeps the stack beside it", () => {
|
||||
withLine({ scrollWidth: 54, clientWidth: 54 }, {}, (nameEl, stackEl) => {
|
||||
ActionBar.fitActingLine();
|
||||
assert.equal(stackEl.hidden, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("a name being cut takes the stack off the line first", () => {
|
||||
withLine({ scrollWidth: 62, clientWidth: 0 }, {}, (nameEl, stackEl) => {
|
||||
ActionBar.fitActingLine();
|
||||
assert.equal(stackEl.hidden, true,
|
||||
"the stack is on that player's own seat puck; the name is only here");
|
||||
});
|
||||
});
|
||||
|
||||
test("and a stack already off it is put back before anything is measured", () => {
|
||||
// Otherwise the answer is a property of whatever the last render left behind
|
||||
// rather than of the line: a name that fits only *because* the stack is missing
|
||||
// would keep it missing for the rest of the night.
|
||||
withLine({ scrollWidth: 54, clientWidth: 54 }, { hidden: true }, (nameEl, stackEl) => {
|
||||
ActionBar.fitActingLine();
|
||||
assert.equal(stackEl.hidden, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("a hair of rounding is not a cut", () => {
|
||||
withLine({ scrollWidth: 55, clientWidth: 54 }, {}, (nameEl, stackEl) => {
|
||||
ActionBar.fitActingLine();
|
||||
assert.equal(stackEl.hidden, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("and a bar with nobody acting on it is left alone", () => {
|
||||
global.document = { querySelector: () => null };
|
||||
try { assert.doesNotThrow(() => ActionBar.fitActingLine()); }
|
||||
finally { delete global.document; }
|
||||
});
|
||||
|
||||
test("the stylesheet can actually take it off the line", () => {
|
||||
// `hidden` is a UA-stylesheet default, and .actionWhoStack sits inside a flex
|
||||
// container. Said out loud here so a later `display:` on the class can't quietly
|
||||
// put the figure back.
|
||||
assert.match(CSS, /\.actionWhoStack\[hidden\]\s*\{[^}]*display:\s*none/);
|
||||
assert.match(CSS, /\.actionTank\[hidden\]\s*\{[^}]*display:\s*none/);
|
||||
});
|
||||
|
||||
test("the space before the separator is in the stylesheet, not in the string", () => {
|
||||
// These are two flex items, and a flex item's own leading whitespace is collapsed
|
||||
// at its edge — so ` · $1,200 behind` drew as `Alexandra· $1,500 behind` from the
|
||||
// day the sentence was split in two.
|
||||
const who = CSS.match(/\n\.actionWho \{([^}]*)\}/);
|
||||
assert.match(who[1], /gap:\s*var\(--sp-1\)/);
|
||||
assert.match(CSS_JS, /whoStack\.textContent = `· \$\{/,
|
||||
"a leading space here is invisible and would double the gap if it weren't");
|
||||
});
|
||||
|
||||
test("and the fit is taken after the timer has decided whether it is on the line", () => {
|
||||
const render = CSS_JS.slice(CSS_JS.indexOf(" render() {"));
|
||||
const body = render.slice(0, render.indexOf("\n },"));
|
||||
const tank = body.indexOf("this.syncTankTimer()");
|
||||
const fit = body.indexOf("this.fitActingLine()");
|
||||
assert.ok(tank >= 0 && fit >= 0, "render() should do both");
|
||||
assert.ok(fit > tank,
|
||||
"measured before the stopwatch is hidden, the name is measured against 50px it has");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what the undo pill says it would take back", () => {
|
||||
// The pill is a fixed width — it names the last action, so its text changes on every
|
||||
// tap, and a control that resizes under a thumb reads as the interface twitching —
|
||||
// and on a phone that width leaves the label 73px. Measured across one hand, four of
|
||||
// the five labels a hand produces wanted more:
|
||||
//
|
||||
// ↶ dealing the hand 104 ↶ Christopher's fold 109
|
||||
// ↶ Mike from work's fold 128 ↶ Alexandra's fold 100
|
||||
// ↶ Dee's fold 69 (the only one that fitted)
|
||||
//
|
||||
// So the safety net spent most of a night reading "↶ dealing …", which does not say
|
||||
// what it is a net for. Cut as one string it says the wrong half: "↶ Mike from wor…"
|
||||
// names everybody except the action.
|
||||
//
|
||||
// Two halves, and the actor is the one that goes — whole, not shortened. Measured
|
||||
// again afterwards, nothing is cut at any viewport in the sweep: a phone reads
|
||||
// "↶ the hand", "↶ Dee's fold", "↶ raise to $20", "↶ call"; a desktop and the
|
||||
// landscape rail keep the actor wherever it fits.
|
||||
|
||||
test("a label handed over in halves knows which is which", () => {
|
||||
const step = ActionBar.undoParts({ lead: "Christopher's", act: "fold" });
|
||||
assert.equal(step.lead, "Christopher's");
|
||||
assert.equal(step.act, "fold");
|
||||
assert.equal(step.long, "Christopher's fold", "the sentence the toast and the tooltip use");
|
||||
});
|
||||
|
||||
test("one with no actor is all act", () => {
|
||||
// "the straddle" belongs to the table rather than to a player.
|
||||
const step = ActionBar.undoParts({ lead: "", act: "the straddle" });
|
||||
assert.equal(step.lead, "");
|
||||
assert.equal(step.long, "the straddle", "no stray space in front of it");
|
||||
});
|
||||
|
||||
test("and a plain string still works, with nothing to drop", () => {
|
||||
// Every caller passes halves today. This is what keeps a caller that doesn't
|
||||
// from drawing "[object Object]" on the one control a dealer reaches for when
|
||||
// they have made a mistake.
|
||||
const step = ActionBar.undoParts("something nobody split");
|
||||
assert.equal(step.lead, "");
|
||||
assert.equal(step.act, "something nobody split");
|
||||
assert.equal(step.long, "something nobody split");
|
||||
});
|
||||
|
||||
test("the sentence is what goes into the history, and the halves ride with it", () => {
|
||||
const seen = [];
|
||||
const before = ActionBar.hooks;
|
||||
ActionBar.hooks = {
|
||||
beginAction: (label, parts) => seen.push({ label, parts }),
|
||||
save: () => {}, rerender: () => {},
|
||||
};
|
||||
try {
|
||||
ActionBar.commit({ lead: "Dee's", act: "call" }, () => {});
|
||||
} finally { ActionBar.hooks = before; }
|
||||
assert.equal(seen.length, 1);
|
||||
assert.equal(seen[0].label, "Dee's call", "the toast says the whole thing");
|
||||
assert.deepEqual(
|
||||
{ lead: seen[0].parts.lead, act: seen[0].parts.act },
|
||||
{ lead: "Dee's", act: "call" });
|
||||
});
|
||||
|
||||
test("and the halves are read back from the history, not remembered here", () => {
|
||||
// Undo pops the history. A copy kept on this object would name the step that was
|
||||
// just taken back rather than the one now on top of the pile.
|
||||
const before = ActionBar.hooks;
|
||||
ActionBar.hooks = {
|
||||
lastActionLabel: () => "Dee's call",
|
||||
lastActionParts: () => ({ lead: "Dee's", act: "call" }),
|
||||
};
|
||||
try {
|
||||
assert.deepEqual(ActionBar.undoStep(), { lead: "Dee's", act: "call" });
|
||||
} finally { ActionBar.hooks = before; }
|
||||
});
|
||||
|
||||
test("a caller that offers no halves gets the label whole", () => {
|
||||
const before = ActionBar.hooks;
|
||||
ActionBar.hooks = { lastActionLabel: () => "dealing the hand" };
|
||||
try {
|
||||
assert.deepEqual(ActionBar.undoStep(), { lead: "", act: "dealing the hand" });
|
||||
} finally { ActionBar.hooks = before; }
|
||||
});
|
||||
|
||||
const withPill = (act, run) => {
|
||||
const lead = { hidden: false, className: "actionUndoLead" };
|
||||
const label = {
|
||||
className: "actionUndoLabel",
|
||||
querySelector: sel => (sel === ".actionUndoLead" ? lead
|
||||
: sel === ".actionUndoAct" ? act : null),
|
||||
};
|
||||
global.document = {
|
||||
querySelector: sel => (sel.endsWith(".actionUndoLabel") ? label : null),
|
||||
};
|
||||
try { run(lead); } finally { delete global.document; }
|
||||
};
|
||||
|
||||
test("an act that fits keeps the actor beside it", () => {
|
||||
withPill({ scrollWidth: 19, clientWidth: 40 }, lead => {
|
||||
ActionBar.fitUndoLabel();
|
||||
assert.equal(lead.hidden, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("an act being cut drops the actor whole", () => {
|
||||
withPill({ scrollWidth: 63, clientWidth: 40 }, lead => {
|
||||
ActionBar.fitUndoLabel();
|
||||
assert.equal(lead.hidden, true,
|
||||
"\"Christo…'s fold\" is a worse sentence than \"fold\"");
|
||||
});
|
||||
});
|
||||
|
||||
test("and it is the act that is asked, never the label", () => {
|
||||
// The label cannot overflow: the act inside it is the item with `overflow:
|
||||
// hidden`, so it swallows the excess and the row measures as though everything
|
||||
// fitted. Asking the label was the first way this was written, and it drew
|
||||
// "↶ dealing the hand" with "the hand" quietly cut off inside it.
|
||||
const fit = CSS_JS.slice(CSS_JS.indexOf(" fitUndoLabel() {"));
|
||||
const body = fit.slice(0, fit.indexOf("\n },"));
|
||||
assert.match(body, /act\.scrollWidth > act\.clientWidth/);
|
||||
assert.doesNotMatch(body, /label\.scrollWidth/);
|
||||
});
|
||||
|
||||
test("an actor already dropped is put back before anything is measured", () => {
|
||||
withPill({ scrollWidth: 19, clientWidth: 40 }, lead => {
|
||||
lead.hidden = true;
|
||||
ActionBar.fitUndoLabel();
|
||||
assert.equal(lead.hidden, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("and a bar with nothing to take back is left alone", () => {
|
||||
global.document = { querySelector: () => null };
|
||||
try { assert.doesNotThrow(() => ActionBar.fitUndoLabel()); }
|
||||
finally { delete global.document; }
|
||||
});
|
||||
|
||||
test("the keycap goes where there is no keyboard to press it", () => {
|
||||
// Every other control on the bar had dropped its cap on a phone since the rule
|
||||
// was written; this one is a different class and had been left out of it, so the
|
||||
// narrowest label on the bar was the only one still spending 22px on a shortcut
|
||||
// nothing could press.
|
||||
const rule = CSS.match(/@media \(hover: none\) \{\s*\.actionKey,\s*\.actionUndoKey \{([^}]*)\}/);
|
||||
assert.ok(rule, ".actionUndoKey should be hidden alongside .actionKey on touch");
|
||||
assert.match(rule[1], /display:\s*none/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a stopwatch that is only there when it is saying something", () => {
|
||||
// 50px of a 349px line, from the first second of every decision, reading "0:07" in
|
||||
// grey. It is a prompt rather than a readout — nothing happens when it grows — and a
|
||||
// prompt that is always on screen is not a prompt. It arrives when the button that
|
||||
// acts on it arrives, which is the same threshold, and the room goes to the name.
|
||||
const withClock = ({ seat = 0, started = null, unknown = null, clockAt = null }, run) => {
|
||||
const saved = {
|
||||
state: ActionBar.state, tankSeatIndex: ActionBar.tankSeatIndex,
|
||||
tankStartedAt: ActionBar.tankStartedAt, tankUnknownFor: ActionBar.tankUnknownFor,
|
||||
clockSeatIndex: ActionBar.clockSeatIndex, clockStartedAt: ActionBar.clockStartedAt,
|
||||
};
|
||||
ActionBar.state = { actingSeatIndex: seat };
|
||||
ActionBar.tankSeatIndex = seat;
|
||||
ActionBar.tankStartedAt = started;
|
||||
ActionBar.tankUnknownFor = unknown;
|
||||
ActionBar.clockSeatIndex = clockAt === null ? null : seat;
|
||||
ActionBar.clockStartedAt = clockAt;
|
||||
try { run(); } finally { Object.assign(ActionBar, saved); }
|
||||
};
|
||||
|
||||
test("a decision that has only just landed on somebody shows neither", () => {
|
||||
withClock({ started: Date.now() - 3000 }, () =>
|
||||
assert.equal(ActionBar.tankWorthShowing(), false));
|
||||
});
|
||||
|
||||
test("and both arrive together, at the threshold the stopwatch already marked", () => {
|
||||
withClock({ started: Date.now() - (ActionBar.NUDGE_AFTER_S * 1000) }, () =>
|
||||
assert.equal(ActionBar.tankWorthShowing(), true));
|
||||
});
|
||||
|
||||
test("a clock already running is always worth watching", () => {
|
||||
// The readout is a countdown by then, and the button beside it says "Stop the
|
||||
// clock" — there is no second at which either of those is not the point.
|
||||
withClock({ started: Date.now(), clockAt: Date.now() }, () =>
|
||||
assert.equal(ActionBar.tankWorthShowing(), true));
|
||||
});
|
||||
|
||||
test("so is the seat whose wait cannot be known", () => {
|
||||
// The action was already on them when the page loaded. The dash and the sentence
|
||||
// behind it are the answer to "why is there no time on this one", so the answer
|
||||
// has to be visible for the question to get asked.
|
||||
withClock({ started: Date.now(), unknown: 0 }, () =>
|
||||
assert.equal(ActionBar.tankWorthShowing(), true));
|
||||
});
|
||||
|
||||
test("and with nothing to time from at all, nothing is shown", () => {
|
||||
withClock({ started: null }, () =>
|
||||
assert.equal(ActionBar.tankWorthShowing(), false));
|
||||
});
|
||||
|
||||
test("it is built hidden, so a fresh decision never flashes one up", () => {
|
||||
assert.match(CSS_JS, /tank\.hidden = true;/);
|
||||
});
|
||||
|
||||
test("and the tick re-fits the line only when one of them actually moved", () => {
|
||||
// The line is measured by forcing a layout, and this runs once a second all
|
||||
// night. Nothing has changed on the seconds in between.
|
||||
const paint = CSS_JS.slice(CSS_JS.indexOf(" paintTank() {"));
|
||||
const body = paint.slice(0, paint.indexOf("\n },"));
|
||||
assert.match(body, /if \(el\.hidden !== wasTank \|\| \(nudge && nudge\.hidden !== wasNudge\)\) this\.fitActingLine\(\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,9 +689,17 @@ describe("a name that will not fit", () => {
|
||||
// ladder — which words go, in what order, and when it stops — and the browser's part
|
||||
// is one boolean per pass.
|
||||
const label = (text, fits) => {
|
||||
const classes = new Set();
|
||||
const el = {
|
||||
textContent: text,
|
||||
title: "",
|
||||
// The step of type a name takes when it has no word left to drop, held as
|
||||
// the same set of strings the real element's classList is.
|
||||
classList: {
|
||||
add: name => classes.add(name),
|
||||
remove: name => classes.delete(name),
|
||||
contains: name => classes.has(name),
|
||||
},
|
||||
// The browser's answer, standing in for a real one: this name fits if
|
||||
// `fits` says the version currently written to it does.
|
||||
get scrollWidth() { return fits(this.textContent) ? 10 : 100; },
|
||||
@@ -309,13 +772,16 @@ describe("a name that will not fit", () => {
|
||||
assert.equal(Render.dropAWord(["Mike"], 1), 1, "one word has nothing to drop");
|
||||
});
|
||||
|
||||
test("a single word is left to the ellipsis it always had", () => {
|
||||
// There is no honest way to shorten "Christopher" that a dealer glancing across
|
||||
// a table would read faster than "Christo…".
|
||||
test("a single word steps its type down rather than being cut", () => {
|
||||
// There is no honest way to shorten "Christopher" at a word, and "Christo…" is a
|
||||
// name only until a Christine sits down. The amounts on the same puck have never
|
||||
// taken that trade — fitAmount shrinks a tournament stack rather than rounding it
|
||||
// — and a misread name is the same kind of failure as a misread stack.
|
||||
const el = label("Christopher", () => false);
|
||||
seatsNamed("Christopher");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Christopher", "never cut mid-word here");
|
||||
assert.ok(el.classList.contains("puckNameTight"), "and never left overflowing either");
|
||||
});
|
||||
|
||||
test("a name that fits is not touched", () => {
|
||||
@@ -323,6 +789,32 @@ describe("a name that will not fit", () => {
|
||||
seatsNamed("Mike from work");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Mike from work");
|
||||
assert.ok(!el.classList.contains("puckNameTight"), "nothing to shrink for");
|
||||
});
|
||||
|
||||
test("a name shortened until it fits is not also shrunk", () => {
|
||||
// The step of type is the last resort, not a second helping. "Mike" fits, so it
|
||||
// is read at the size everything else on the felt is.
|
||||
const el = label("Mike from work", text => text === "Mike");
|
||||
seatsNamed("Mike from work");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Mike");
|
||||
assert.ok(!el.classList.contains("puckNameTight"));
|
||||
});
|
||||
|
||||
test("the step comes off again when the puck it was for is gone", () => {
|
||||
// Otherwise it is a ratchet: a name that had to shrink on a nine-handed phone
|
||||
// would still be shrunk on the same table six-handed, or on a desktop, because
|
||||
// nothing ever measured it at the size it was supposed to be read at.
|
||||
const el = label("Christopher", () => false);
|
||||
seatsNamed("Christopher");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.ok(el.classList.contains("puckNameTight"));
|
||||
|
||||
const roomy = label("Christopher", () => true);
|
||||
roomy.classList.add("puckNameTight");
|
||||
Render.fitSeatNames(layer([roomy]));
|
||||
assert.ok(!roomy.classList.contains("puckNameTight"));
|
||||
});
|
||||
|
||||
test("the whole name is on the tooltip whether it was shortened or not", () => {
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// The sheet a dealer opens mid-argument.
|
||||
//
|
||||
// It held twelve rulings with all their prose showing — 2,480px of it on a 390px
|
||||
// phone, of which 1,754 was below the fold — and a row of twelve chips across the top
|
||||
// to jump between them. The chips were an index of the twelve headings printed directly
|
||||
// above the same twelve headings: the same list twice, 172px of navigation before the
|
||||
// first word, and the dealer who used one landed in the middle of a scroll with eleven
|
||||
// rulings they hadn't asked for still wrapped around them.
|
||||
//
|
||||
// The headings are the index now. NN/g's rule for this shape — a longer list of
|
||||
// independent topics somebody opens one of — is an accordion, shut by default, and
|
||||
// half of these headings answer the question without being opened at all: "A short
|
||||
// all-in doesn't reopen the betting" is the ruling, not the topic. Measured after:
|
||||
// 29px below the fold on an iPhone with all twelve rows on screen at once, 51px on a
|
||||
// desktop. Opening the longest of them leaves 244px.
|
||||
//
|
||||
// These are layout and markup decisions, so they are checked as text — except the one
|
||||
// about what the sheet does when it is opened again, which is behaviour and lives in
|
||||
// main.js.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
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 MAIN = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "main.js"), "utf8");
|
||||
|
||||
const sheet = () => {
|
||||
const at = PAGE.indexOf('<div id="rulingsOverlay"');
|
||||
assert.ok(at >= 0, "there is no rulings overlay");
|
||||
return PAGE.slice(at, PAGE.indexOf('<div id="seatMenuOverlay"', at));
|
||||
};
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
// Every @media block written at exactly this condition, concatenated — the same helper
|
||||
// theHeader.test.js uses, and for the same reason: this condition is opened more than
|
||||
// once in the file, and a helper that stops at the first finds a block with none of
|
||||
// these rules in it and reports them missing.
|
||||
const mediaBlock = condition => {
|
||||
const found = [];
|
||||
const needle = "@media ";
|
||||
for (let at = CSS.indexOf(needle); at >= 0; at = CSS.indexOf(needle, at + 1)) {
|
||||
const open = CSS.indexOf("{", at);
|
||||
if (CSS.slice(at + needle.length, open).trim() !== condition) continue;
|
||||
let depth = 0;
|
||||
for (let i = open; i < CSS.length; i++) {
|
||||
if (CSS[i] === "{") depth++;
|
||||
else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(open + 1, i)); break; }
|
||||
}
|
||||
}
|
||||
assert.ok(found.length > 0, `no @media block written as "${condition}"`);
|
||||
return found.join("\n");
|
||||
};
|
||||
|
||||
const ruleIn = (block, selector) => {
|
||||
const re = new RegExp(`(^|[},])\\s*${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, "m");
|
||||
const found = block.match(re);
|
||||
assert.ok(found, `there is no \`${selector}\` rule in that block`);
|
||||
return found[2];
|
||||
};
|
||||
|
||||
const SIDEWAYS = () => mediaBlock("(orientation: landscape) and (max-height: 560px)");
|
||||
|
||||
// Each ruling as its own slice of markup.
|
||||
const rulings = () => {
|
||||
const parts = sheet().split('<details class="ruling"');
|
||||
return parts.slice(1).map(p => p.slice(0, p.indexOf("</details>")));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("twelve rulings, shut", () => {
|
||||
test("every one of them is a disclosure, and there are still twelve", () => {
|
||||
assert.equal(rulings().length, 12);
|
||||
});
|
||||
|
||||
// NN/g is explicit that opening the first one by default costs the reader the
|
||||
// glance at the whole list, which on this sheet is the entire point.
|
||||
test("none of them is open in the markup", () => {
|
||||
assert.ok(!/<details class="ruling"[^>]*\bopen\b/.test(sheet()),
|
||||
"a ruling starts open, so the sheet no longer opens on its own contents");
|
||||
});
|
||||
|
||||
// Native exclusive accordions close the open one when another is opened, which
|
||||
// shifts everything below it under the finger that is reaching. That is the
|
||||
// disorientation the accordion is here to remove, not add.
|
||||
test("opening one does not close another", () => {
|
||||
assert.ok(!/<details class="ruling"[^>]*\bname=/.test(sheet()),
|
||||
"the rulings are an exclusive accordion, so content moves when one opens");
|
||||
});
|
||||
|
||||
test("the name of a ruling is the control that opens it", () => {
|
||||
rulings().forEach((r, i) => {
|
||||
assert.match(r, /<summary><h3>.+<\/h3><\/summary>/,
|
||||
`ruling ${i + 1} does not carry its heading in its summary`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the index that was above them is gone, not merely hidden", () => {
|
||||
test("nothing builds a chip row any more", () => {
|
||||
assert.ok(!/rulingsIndex/.test(PAGE), "the index nav is still in the markup");
|
||||
assert.ok(!/buildRulingsIndex|rulingsIndexBtn/.test(MAIN), "main.js still builds the index");
|
||||
assert.ok(!/rulingsIndexBtn/.test(CSS), "the index chips still have styles");
|
||||
});
|
||||
|
||||
// The short labels existed only to fit three chips on a line. With the full
|
||||
// headings doing the work there is nothing left to read them.
|
||||
test("and the short labels it read are gone with it", () => {
|
||||
assert.ok(!/data-short=/.test(PAGE));
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a summary has to be to work", () => {
|
||||
// A <summary> is announced as a button and not as a heading, so without this the
|
||||
// twelve names — the whole of this sheet's navigation — are invisible to anybody
|
||||
// moving through it by heading. Same fix as the sidebar's panel names.
|
||||
test("the heading survives being a control", () => {
|
||||
assert.match(ruleFor(".ruling > summary > h3"), /display:\s*inline/);
|
||||
assert.match(ruleFor(".ruling > summary > h3"), /font:\s*inherit/);
|
||||
});
|
||||
|
||||
// A <summary> is content-box, so a min-height is *added* to its padding — the trap
|
||||
// the sidebar's headers were in, one stylesheet section over.
|
||||
test("a thumb gets its 44px, counted the way the box actually counts", () => {
|
||||
const rule = ruleFor(".ruling > summary");
|
||||
assert.match(rule, /box-sizing:\s*border-box/);
|
||||
assert.match(rule, /min-height:\s*44px/);
|
||||
});
|
||||
|
||||
test("the accent belongs to the ruling being read, not to all twelve", () => {
|
||||
assert.match(ruleFor(".ruling > summary"), /color:\s*var\(--text\)/);
|
||||
assert.match(ruleFor(".ruling[open] > summary"), /color:\s*var\(--gold\)/);
|
||||
});
|
||||
|
||||
test("and so does the gold bar down the side", () => {
|
||||
assert.match(ruleFor(".ruling[open]"), /border-left:\s*2px solid/);
|
||||
assert.ok(!/border-left/.test(ruleFor(".ruling")),
|
||||
"every ruling wears the open one's marker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading one of them", () => {
|
||||
// Five of the twelve are two paragraphs and they ran into each other: the sections
|
||||
// were spaced from one another, but nothing separated the halves of one ruling.
|
||||
test("two paragraphs of a ruling are two paragraphs", () => {
|
||||
assert.match(ruleFor(".ruling p + p"), /margin-top:\s*var\(--sp-\d\)/);
|
||||
});
|
||||
|
||||
// <details> keeps its state, so without this a dealer who read the odd-chip rule an
|
||||
// hour ago finds it still hanging open under the one they came back for.
|
||||
test("the sheet opens shut, however it was left", () => {
|
||||
const at = MAIN.indexOf("const openRulings");
|
||||
assert.ok(at >= 0, "nothing owns what the sheet looks like when it opens");
|
||||
const body = MAIN.slice(at, MAIN.indexOf("};", at));
|
||||
assert.match(body, /\.ruling\[open\]/);
|
||||
assert.match(body, /open = false/);
|
||||
});
|
||||
|
||||
test("and that is what the button calls", () => {
|
||||
assert.match(MAIN, /getElementById\("openRulingsBtn"\)\.addEventListener\("click", openRulings\)/);
|
||||
});
|
||||
});
|
||||
|
||||
// The dealer who has read the answer has to be able to leave without scrolling back
|
||||
// past it. Measured before this: on a 360x640 phone the twelve shut rulings are 791px of
|
||||
// a 549px sheet, so at the bottom of that scroll Done is 221px above the sheet's top
|
||||
// edge; on a 390x844 phone with one ruling opened — the only reason anybody is in here —
|
||||
// it is 262px. The seat menu's sheet had already been given the fix and this one, the
|
||||
// longer of the two, had not.
|
||||
describe("the way out does not scroll away", () => {
|
||||
test("the header is stuck to the top of the sheet's own scrollport", () => {
|
||||
const rule = ruleFor(".rulingsHeader");
|
||||
assert.match(rule, /position:\s*sticky/);
|
||||
assert.match(rule, /top:\s*0/);
|
||||
// Out to the sheet's edges and back, or the list shows through either side of it.
|
||||
assert.match(rule, /margin:\s*0 calc\(var\(--sp-4\) \* -1\)/);
|
||||
assert.match(rule, /background:\s*var\(--surface-2\)/);
|
||||
});
|
||||
|
||||
test("and nothing is left above it for the list to scroll out through", () => {
|
||||
assert.match(ruleFor("#rulingsSheet"), /padding:\s*0 var\(--sp-4\)/,
|
||||
"the sheet keeps a top padding, which is a gap above a sticky header");
|
||||
// The grip was the one thing up there. It belongs inside the header now.
|
||||
const header = sheet().match(/<div class="rulingsHeader">[\s\S]*?<\/div>\s*<\/div>/);
|
||||
assert.ok(header, "the header is not in the markup where it was");
|
||||
assert.match(header[0], /seatSheetGrip/,
|
||||
"the grip is still outside the header, so it scrolls away above it");
|
||||
assert.ok(ruleFor(".rulingsHeader .seatSheetGrip"),
|
||||
"the grip has no rule placing it inside a two-column header");
|
||||
});
|
||||
|
||||
test("the scroll inside the sheet stays inside it", () => {
|
||||
assert.match(ruleFor("#rulingsSheet"), /overscroll-behavior:\s*contain/);
|
||||
});
|
||||
});
|
||||
|
||||
// A phone held sideways is 844x390: no height and swimming in width. Measured before
|
||||
// this, the bottom sheet was 460px wide in the middle of that window — 384px of it
|
||||
// unused either side — holding 754px of rulings in a 334px scrollport, 111px of which
|
||||
// went on a title and a caveat that stand still. Four of the twelve names on screen.
|
||||
//
|
||||
// Material's rule for a bottom sheet in landscape is to stop being one, and this tool
|
||||
// already does exactly that under this query for the action bar. So the sheet takes the
|
||||
// window and the width pays for the height: two columns of six, all twelve names on
|
||||
// screen at 844x390 with nothing to scroll at all.
|
||||
describe("a phone on its side gets the whole window", () => {
|
||||
test("the twelve are six and six in the markup", () => {
|
||||
const columns = sheet().split('<div class="rulingsColumn">').slice(1);
|
||||
assert.equal(columns.length, 2, "there are not exactly two columns to stand side by side");
|
||||
columns.forEach((c, i) => {
|
||||
const count = (c.slice(0, c.indexOf("\n </div>")).match(/<details class="ruling"/g) || []).length;
|
||||
assert.equal(count, 6, `column ${i + 1} holds ${count} rulings rather than six`);
|
||||
});
|
||||
});
|
||||
|
||||
test("but everywhere else they are still one column", () => {
|
||||
// `display: contents` hands the rulings straight to the list, so every layout
|
||||
// that isn't landscape sees exactly the markup it saw before the wrappers.
|
||||
assert.match(ruleFor(".rulingsColumn"), /display:\s*contents/);
|
||||
assert.match(ruleIn(SIDEWAYS(), ".rulingsColumn"), /display:\s*block/);
|
||||
});
|
||||
|
||||
test("sideways the sheet stops being a sheet and becomes the screen", () => {
|
||||
const block = SIDEWAYS();
|
||||
assert.match(ruleIn(block, "#rulingsOverlay"), /align-items:\s*stretch/,
|
||||
"the sheet is still anchored to the bottom edge, so it is still short");
|
||||
const sheetRule = ruleIn(block, "#rulingsSheet");
|
||||
assert.match(sheetRule, /height:\s*100%/);
|
||||
assert.match(sheetRule, /max-height:\s*none/);
|
||||
// Capped rather than unbounded: this query also matches a short desktop window,
|
||||
// and two columns of a 1400px one would be 680px lines of body text.
|
||||
assert.match(sheetRule, /max-width:\s*62rem/);
|
||||
assert.match(sheetRule, /overflow:\s*hidden/,
|
||||
"the sheet scrolls as well as the list, which is two scrollbars for one list");
|
||||
});
|
||||
|
||||
test("the title, the caveat and the way out share one line", () => {
|
||||
// That line is the whole of the 111px this screen was spending on them, and the
|
||||
// sticky positioning goes with the box being dropped: nothing above the list
|
||||
// scrolls here, so nothing has to stick.
|
||||
const block = SIDEWAYS();
|
||||
assert.match(ruleIn(block, ".rulingsHeader"), /display:\s*contents/);
|
||||
assert.match(ruleIn(block, ".rulingsHeader .seatSheetGrip"), /display:\s*none/,
|
||||
"a grab handle on a sheet that is not a sheet any more");
|
||||
assert.match(ruleIn(block, "#rulingsSheet"), /grid-template-areas:\s*\n?\s*"title intro done"/);
|
||||
});
|
||||
|
||||
test("the two columns are two flows, not one flow wrapped", () => {
|
||||
// A grid with twelve children of its own pairs them into six shared rows, and
|
||||
// opening the third ruling on the left pushes the ninth down the right-hand side
|
||||
// to keep the row square. Two wrappers means a column only ever moves itself.
|
||||
const rule = ruleIn(SIDEWAYS(), ".rulingsList");
|
||||
assert.match(rule, /grid-template-columns:\s*1fr 1fr/);
|
||||
assert.match(rule, /overflow-y:\s*auto/);
|
||||
assert.match(rule, /min-height:\s*0/,
|
||||
"a grid item's floor is its content, so the list would push the sheet open");
|
||||
});
|
||||
|
||||
test("and the gutter is reserved before it is needed", () => {
|
||||
// It arrives the moment a ruling is opened. Without the reservation it takes 8px
|
||||
// off the tracks and slides the right-hand column sideways under the eye that is
|
||||
// reading it.
|
||||
assert.match(ruleIn(SIDEWAYS(), ".rulingsList"), /scrollbar-gutter:\s*stable/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
// What a seat is doing, asked once.
|
||||
//
|
||||
// The seat editor had two checkboxes, "In hand" and "Fold". A seat is really in one of
|
||||
// three states — sitting out, in the hand, folded — and "folded" means nothing while
|
||||
// the seat is out, so two boxes offered a fourth corner that has no meaning. The code
|
||||
// that stopped a dealer reaching it `disabled` the Fold box whenever In hand was clear.
|
||||
//
|
||||
// Measured: that left the label at opacity 1 and colour rgb(170,170,170) — identical to
|
||||
// a live one — with a `title` of "Folded" on it. So pressing Fold on a seat that was
|
||||
// sitting out gave no movement and no reason, which is the exact failure round 16 went
|
||||
// through the whole tool to remove. It hid from the sweep rule that finds those because
|
||||
// the title sat on the <label> rather than on the control the rule inspects.
|
||||
//
|
||||
// Three named states cannot contradict each other and none of them is ever unavailable,
|
||||
// so there is nothing left to dim.
|
||||
//
|
||||
// The second half of this round is where that form appears at all. The phone block's
|
||||
// own comment has always said why the editor becomes a bottom sheet — "the seat editor
|
||||
// lives far enough down the page that tapping a seat looks like it did nothing" — but
|
||||
// the sheet was scoped to 700px while #holdemLayout goes to one column at 900. The two
|
||||
// tiers in between had precisely the fault the sheet was written to fix. Measured,
|
||||
// tapping a seat:
|
||||
//
|
||||
// 844x390 the editor drew in the flow at y=369 in a 390px window: 21px of 401 on
|
||||
// screen, 5% of it, at scrollY 0 with nothing scrolling it into view.
|
||||
// 768x1024 31% visible, the rest below the fold, and again no scroll.
|
||||
//
|
||||
// Afterwards every viewport from 360x640 to 900x500 shows 97-99% of the sheet, and
|
||||
// above 900 the editor is a static block at the top of a sidebar that is beside the
|
||||
// table rather than below it — 100% visible there too.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Render, makeTable } = 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 seatForm = () => {
|
||||
const at = PAGE.indexOf('<template id="seatEditorTemplate">');
|
||||
assert.ok(at >= 0, "there is no seat editor template");
|
||||
return PAGE.slice(at, PAGE.indexOf("</template>", at));
|
||||
};
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
// Everything inside every block with this query, braces balanced and joined. Every one
|
||||
// of them: this stylesheet opens `@media (hover: none)` twice, and a helper that stops
|
||||
// at the first has twice caught a test asserting about the wrong half of the file.
|
||||
function blockAt(query) {
|
||||
const found = [];
|
||||
for (let at = CSS.indexOf(query); at >= 0; at = CSS.indexOf(query, at + 1)) {
|
||||
let depth = 0;
|
||||
for (let i = at + query.length - 1; i < CSS.length; i++) {
|
||||
if (CSS[i] === "{") depth++;
|
||||
else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(at, i + 1)); break; }
|
||||
}
|
||||
}
|
||||
assert.ok(found.length, `there is no \`${query}\` block`);
|
||||
return found.join("\n");
|
||||
}
|
||||
|
||||
// The three buttons, and enough of a form to hand them back.
|
||||
function fakeForm() {
|
||||
const options = Render.SEAT_STATES.map(state => ({
|
||||
dataset: { seatState: state.key },
|
||||
attrs: {},
|
||||
tabIndex: 0,
|
||||
handlers: {},
|
||||
setAttribute(name, value) { this.attrs[name] = value; },
|
||||
getAttribute(name) { return this.attrs[name]; },
|
||||
addEventListener(type, fn) { this.handlers[type] = fn; },
|
||||
focus() { options.focused = this.dataset.seatState; },
|
||||
}));
|
||||
return {
|
||||
options,
|
||||
querySelectorAll: selector => {
|
||||
assert.equal(selector, ".seatStateOption");
|
||||
return options;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const checked = form => form.options.filter(o => o.getAttribute("aria-checked") === "true")
|
||||
.map(o => o.dataset.seatState);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("three states, not two booleans", () => {
|
||||
test("the markup asks one question with three answers", () => {
|
||||
const form = seatForm();
|
||||
const at = form.indexOf('class="seatState"');
|
||||
assert.ok(at >= 0, "the seat form has no state control");
|
||||
const group = form.slice(form.lastIndexOf("<div", at), form.indexOf("</div>", at));
|
||||
assert.match(group, /role="radiogroup"/);
|
||||
assert.match(group, /aria-label="This seat"/);
|
||||
["out", "in", "folded"].forEach(key => {
|
||||
assert.match(group, new RegExp(`data-seat-state="${key}"`), `there is no "${key}" answer`);
|
||||
});
|
||||
assert.equal((group.match(/role="radio"/g) || []).length, 3);
|
||||
});
|
||||
|
||||
test("and the two checkboxes are gone from the page and the stylesheet", () => {
|
||||
["inHandCheckbox", "foldCheckbox", "inHandToggle", "foldToggle", "seatCardToggles"]
|
||||
.forEach(name => {
|
||||
assert.ok(!PAGE.includes(name), `${name} is still in the markup`);
|
||||
assert.ok(!CSS.includes(name), `${name} is still in the stylesheet`);
|
||||
});
|
||||
});
|
||||
|
||||
// The pair (out, folded) is the one the old control could be asked for.
|
||||
test("every state names both halves, and none of them is the meaningless pair", () => {
|
||||
assert.deepEqual(Render.SEAT_STATES.map(s => s.key), ["out", "in", "folded"]);
|
||||
Render.SEAT_STATES.forEach(state => {
|
||||
assert.equal(typeof state.inHand, "boolean");
|
||||
assert.equal(typeof state.folded, "boolean");
|
||||
assert.ok(!(state.folded && !state.inHand),
|
||||
`"${state.key}" is folded and not in the hand, which means nothing`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and a seat reads back as the state it was put in", () => {
|
||||
Render.SEAT_STATES.forEach(state => {
|
||||
assert.equal(Render.seatStateOf({ inHand: state.inHand, folded: state.folded }), state.key);
|
||||
});
|
||||
// Including a seat saved before any of this, where folded was left set on a
|
||||
// chair that had sat out.
|
||||
assert.equal(Render.seatStateOf({ inHand: false, folded: true }), "out");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the control draws", () => {
|
||||
test("exactly one answer is marked, and it is the seat's", () => {
|
||||
const form = fakeForm();
|
||||
Render.paintSeatState(form, { inHand: true, folded: true });
|
||||
assert.deepEqual(checked(form), ["folded"]);
|
||||
Render.paintSeatState(form, { inHand: false, folded: false });
|
||||
assert.deepEqual(checked(form), ["out"]);
|
||||
Render.paintSeatState(form, { inHand: true, folded: false });
|
||||
assert.deepEqual(checked(form), ["in"]);
|
||||
});
|
||||
|
||||
// One stop for the group, arrows inside it — the APG's radio group, and the same
|
||||
// shape the card picker's suits use.
|
||||
test("and exactly one of the three is a Tab stop", () => {
|
||||
const form = fakeForm();
|
||||
Render.paintSeatState(form, { inHand: true, folded: false });
|
||||
assert.deepEqual(form.options.map(o => o.tabIndex), [-1, 0, -1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what pressing one does", () => {
|
||||
const wired = player => {
|
||||
const form = fakeForm();
|
||||
const commits = [];
|
||||
const realCommit = Render.commitSeatChange;
|
||||
Render.commitSeatChange = () => commits.push(true);
|
||||
try {
|
||||
Render.wireSeatState(form, player, 0);
|
||||
} finally {
|
||||
Render.commitSeatChange = realCommit;
|
||||
}
|
||||
return { form, commits, press: key => {
|
||||
const btn = form.options.find(o => o.dataset.seatState === key);
|
||||
const again = Render.commitSeatChange;
|
||||
Render.commitSeatChange = () => commits.push(true);
|
||||
try { btn.handlers.click(); } finally { Render.commitSeatChange = again; }
|
||||
} };
|
||||
};
|
||||
|
||||
test("it writes both halves of the seat, not one", () => {
|
||||
const player = { inHand: true, folded: false };
|
||||
const { press } = wired(player);
|
||||
press("folded");
|
||||
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: true, folded: true });
|
||||
press("out");
|
||||
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: false, folded: false });
|
||||
press("in");
|
||||
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: true, folded: false });
|
||||
});
|
||||
|
||||
test("and says so, so the table redraws", () => {
|
||||
const player = { inHand: true, folded: false };
|
||||
const { commits, press } = wired(player);
|
||||
const before = commits.length;
|
||||
press("out");
|
||||
assert.ok(commits.length > before, "nothing was committed, so the felt still shows the old state");
|
||||
});
|
||||
|
||||
// Typing a name into an empty chair seats somebody. The control has to say so —
|
||||
// it is the only thing on the form that shows it.
|
||||
test("naming an empty chair repaints the control rather than leaving it stale", () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(ROOT, "wwwroot", "js", "casino", "holdem", "render.js"), "utf8");
|
||||
const at = src.indexOf("const wasEmpty = !player.name.trim()");
|
||||
assert.ok(at >= 0);
|
||||
assert.match(src.slice(at, at + 400), /this\.paintSeatState\(form, player\);/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("how it is drawn", () => {
|
||||
// The answer moves between the three as the hand goes on, and a segment that
|
||||
// resizes under the thumb reads as the control flinching.
|
||||
test("three equal columns, whatever the words in them", () => {
|
||||
assert.match(ruleFor(".seatState"), /grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\)/);
|
||||
});
|
||||
|
||||
test("the chosen one is the one that looks pressed", () => {
|
||||
assert.match(ruleFor('.seatStateOption[aria-checked="true"]'), /background:\s*var\(--surface-3\)/);
|
||||
assert.match(ruleFor('.seatStateOption[aria-checked="true"]'), /color:\s*var\(--text-bright\)/);
|
||||
});
|
||||
|
||||
// The same red the seat menu's own Fold wears, and only when it is the answer: a red
|
||||
// segment sitting unpressed beside two grey ones reads as a warning about the seat.
|
||||
test("folded is red, and only once it is chosen", () => {
|
||||
const rule = ruleFor('.seatStateOption[data-seat-state="folded"][aria-checked="true"]');
|
||||
assert.match(rule, /#3d1f1f/);
|
||||
assert.ok(!/\n\.seatStateOption\[data-seat-state="folded"\] \{/.test(CSS),
|
||||
"an unchosen Folded segment is painted red");
|
||||
});
|
||||
|
||||
test("and a thumb gets its 44px wherever there is no pointer", () => {
|
||||
assert.match(blockAt("@media (hover: none) {"), /\.seatStateOption \{\s*min-height: 44px;/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("where the form appears at all", () => {
|
||||
// The sheet exists because a stacked sidebar puts the editor far below the felt.
|
||||
// That is true at every width where the layout is one column.
|
||||
test("the sheet is scoped to the breakpoint that stacks the layout", () => {
|
||||
const oneColumn = blockAt("@media (max-width: 900px) {");
|
||||
assert.match(oneColumn, /#holdemLayout \{\s*grid-template-columns: 1fr;/,
|
||||
"900px is no longer where the sidebar stops being a column beside the table");
|
||||
const sheet = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"));
|
||||
const query = sheet.slice(sheet.indexOf("@media"), sheet.indexOf("{", sheet.indexOf("@media")) + 1);
|
||||
assert.equal(query.trim(), "@media (max-width: 900px) {");
|
||||
});
|
||||
|
||||
test("and it is a sheet, fixed to the bottom, not a block in the flow", () => {
|
||||
const sheet = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"));
|
||||
const at = sheet.indexOf(" #seatEditorPanel {");
|
||||
assert.ok(at >= 0, "the sheet block no longer positions the panel");
|
||||
const rule = sheet.slice(at, sheet.indexOf("}", at));
|
||||
assert.match(rule, /position:\s*fixed/);
|
||||
assert.match(rule, /bottom:\s*0/);
|
||||
assert.match(rule, /max-height:\s*74vh/);
|
||||
});
|
||||
|
||||
test("what was measured is written beside the query", () => {
|
||||
const note = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"),
|
||||
CSS.indexOf("@media (max-width: 900px) {", CSS.indexOf("---- Seat editor as a bottom sheet ----")));
|
||||
assert.match(note, /844x390/);
|
||||
assert.match(note, /768x1024/);
|
||||
assert.match(note, /5% of it/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
// The seat menu on a phone held sideways.
|
||||
//
|
||||
// The tool learned two rounds ago that a landscape phone is short of height and
|
||||
// swimming in width: the action bar stops being a bar across the bottom and becomes a
|
||||
// rail down the right. The sheets were never told. Measured on the fullest menu the
|
||||
// tool can build — twelve seated, somebody on the waiting list, blinds still the only
|
||||
// money in:
|
||||
//
|
||||
// 640x360 545px of content in a 306px sheet. Cash out…, Move to…, Edit seat…,
|
||||
// Small blind, Big blind and Cancel all below the fold, Cancel by 224px.
|
||||
// 667x375 545 in 319, Cancel 211px under.
|
||||
// 844x390 529 in 332, Cancel 183px under.
|
||||
//
|
||||
// And on all three the sheet was 420px wide inside a window 640 to 844 wide. Two
|
||||
// hundred and forty-seven pixels of width held empty on the one screen shape whose
|
||||
// whole problem is height, while the dealer scrolled a menu to find the way out of it.
|
||||
//
|
||||
// The two tiers this menu has been sorted into since round 7 — what you press with
|
||||
// cards in the air, and housekeeping between hands — stop being the top and bottom of
|
||||
// a column and become the right and left of a row. Fold and All-in land under the same
|
||||
// thumb the rail puts Fold, Call and Raise under, with Cancel beneath them. Measured
|
||||
// after: 297px in a 298px sheet at 640x360 and at 667x375, 281 in 282 at 844x390.
|
||||
// Nothing below the fold on any of them, and the move picker's twelve chairs and its
|
||||
// Back went from four rows and a scroll to three rows and none.
|
||||
//
|
||||
// Upright nothing moved at all: 545px before, 545px after, the same two columns.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Betting, HoldemState, SeatMenu, makeTable } = require("./load");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "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));
|
||||
};
|
||||
|
||||
// Everything inside one @media block, braces balanced, so a test asks what that block
|
||||
// says rather than whether a string appears anywhere in six thousand lines.
|
||||
const SHORT = "@media (max-height: 560px) and (min-width: 600px) {";
|
||||
function shortViewportBlock() {
|
||||
const at = CSS.indexOf(SHORT);
|
||||
assert.ok(at >= 0, "there is no short-viewport block for the sheets");
|
||||
let depth = 0;
|
||||
for (let i = at + SHORT.length - 1; i < CSS.length; i++) {
|
||||
if (CSS[i] === "{") depth++;
|
||||
else if (CSS[i] === "}" && --depth === 0) return CSS.slice(at, i + 1);
|
||||
}
|
||||
assert.fail("the short-viewport block is never closed");
|
||||
}
|
||||
|
||||
// A document small enough to answer what paint() asks of one. It builds elements,
|
||||
// appends them and reads their labels back; nothing here lays anything out, which is
|
||||
// the stylesheet's half of the job and is checked as text below.
|
||||
function fakeDom() {
|
||||
const element = tag => ({
|
||||
tag,
|
||||
className: "",
|
||||
type: "",
|
||||
textContent: "",
|
||||
children: [],
|
||||
appendChild(child) { this.children.push(child); return child; },
|
||||
addEventListener() {},
|
||||
setAttribute() {},
|
||||
focus() {},
|
||||
get childElementCount() { return this.children.length; },
|
||||
set innerHTML(value) { assert.equal(value, "", "only clearing is modelled"); this.children = []; },
|
||||
get innerHTML() { return ""; },
|
||||
});
|
||||
const byId = {};
|
||||
["seatMenuTitle", "seatMenuSubtitle", "seatMenuActions", "seatMenuCancelBtn",
|
||||
"seatMenuOverlay"].forEach(id => { byId[id] = element("div"); });
|
||||
return {
|
||||
byId,
|
||||
doc: {
|
||||
createElement: element,
|
||||
getElementById: id => byId[id] || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withDocument(doc, body) {
|
||||
const had = Object.prototype.hasOwnProperty.call(globalThis, "document");
|
||||
const before = globalThis.document;
|
||||
globalThis.document = doc;
|
||||
try { return body(); } finally {
|
||||
if (had) globalThis.document = before; else delete globalThis.document;
|
||||
}
|
||||
}
|
||||
|
||||
// The fullest menu the tool can build, aimed at a live seat that isn't the dealer with
|
||||
// the blinds still the only money in and an override already set.
|
||||
function painted(seatIndex = 3, { move = false, seated = [0, 1, 2, 3, 4], waiting = true } = {}) {
|
||||
const state = makeTable({ seatCount: 9, seated, stack: 500, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
state.blindOverride = { sbIndex: 1, bbIndex: 2 };
|
||||
if (waiting) state.waitingList = [{ id: "w1", name: "Nina" }];
|
||||
const { doc, byId } = fakeDom();
|
||||
return withDocument(doc, () => {
|
||||
SeatMenu.state = state;
|
||||
SeatMenu.hooks = {};
|
||||
SeatMenu.seatIndex = seatIndex;
|
||||
SeatMenu.mode = move ? "move" : null;
|
||||
SeatMenu.paint();
|
||||
return { list: byId.seatMenuActions, cancel: byId.seatMenuCancelBtn, state };
|
||||
});
|
||||
}
|
||||
|
||||
const classes = el => el.className.split(/\s+/).filter(Boolean);
|
||||
const labels = el => el.children.map(child => child.textContent);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the two tiers are boxes, not a run of siblings", () => {
|
||||
// Five loose children can only ever stack. Two boxes can be placed.
|
||||
test("the sheet's actions are exactly the hand and the housekeeping", () => {
|
||||
const { list } = painted();
|
||||
assert.deepEqual(list.children.map(child => child.className),
|
||||
["seatMenuHand", "seatMenuKeeping"]);
|
||||
});
|
||||
|
||||
test("the hand tier holds the bars a dealer presses with cards in the air", () => {
|
||||
const { list } = painted();
|
||||
const hand = list.children[0];
|
||||
assert.deepEqual(labels(hand), ["Fold", "All-in"]);
|
||||
hand.children.forEach(btn => {
|
||||
assert.ok(classes(btn).includes("seatMenuBtn"));
|
||||
assert.ok(!classes(btn).includes("menuTile"),
|
||||
"a bar is not a tile — the whole point of the tier is that it is a bar");
|
||||
});
|
||||
});
|
||||
|
||||
test("and the housekeeping tier holds its captions and grids in order", () => {
|
||||
const { list } = painted();
|
||||
const keeping = list.children[1];
|
||||
assert.deepEqual(keeping.children.map(child => child.className),
|
||||
["seatMenuCaption", "seatMenuGrid", "seatMenuCaption", "seatMenuGrid"]);
|
||||
assert.deepEqual(keeping.children.map(child => child.tag), ["p", "div", "p", "div"]);
|
||||
});
|
||||
|
||||
// The sideways layout reads the tier's absence to know it has one column rather
|
||||
// than two, so an empty tier has to be absent rather than empty.
|
||||
test("a tier with nothing in it is left out rather than drawn empty", () => {
|
||||
// A chair nobody is sitting in, with nobody waiting to take it: there is
|
||||
// nothing at all to do to it while the cards are in the air.
|
||||
const { list } = painted(7, { seated: [0, 1, 2], waiting: false });
|
||||
assert.deepEqual(list.children.map(child => child.className), ["seatMenuKeeping"],
|
||||
"an empty chair still drew a hand tier with nothing in it");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the move picker", () => {
|
||||
test("its chairs go in the housekeeping tier too, so the sheet has one column", () => {
|
||||
const { list } = painted(3, { move: true });
|
||||
assert.deepEqual(list.children.map(child => child.className), ["seatMenuKeeping"]);
|
||||
});
|
||||
|
||||
// "Seat 3 — Alexandra" sets 116px against "Sit out"'s 50, so this grid asks for a
|
||||
// different column from the one the tiles use.
|
||||
test("and they ask for a column of their own width", () => {
|
||||
const { list } = painted(3, { move: true });
|
||||
const grid = list.children[0].children[0];
|
||||
assert.deepEqual(classes(grid), ["seatMenuGrid", "seatMenuGridNames"]);
|
||||
});
|
||||
|
||||
test("the way back out is still the sheet's own foot button", () => {
|
||||
const { cancel } = painted(3, { move: true });
|
||||
assert.equal(cancel.textContent, "Back");
|
||||
assert.equal(painted(3).cancel.textContent, "Cancel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("upright, nothing moved", () => {
|
||||
// The wrappers gap exactly as the loose children used to, so the sheet a dealer
|
||||
// holding a phone the usual way sees is the one it has been since round 7.
|
||||
test("both tiers are the same column the sheet already was", () => {
|
||||
const rule = ruleFor(".seatMenuHand,\n.seatMenuKeeping");
|
||||
assert.match(rule, /flex-direction:\s*column/);
|
||||
assert.match(rule, /gap:\s*var\(--sp-2\)/);
|
||||
assert.match(rule, /min-width:\s*0/);
|
||||
});
|
||||
|
||||
test("and the grid is still two columns wherever the screen is not short", () => {
|
||||
assert.match(ruleFor(".seatMenuGrid"), /grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the short screen does instead", () => {
|
||||
const block = shortViewportBlock();
|
||||
|
||||
// Height and width, not orientation: a short desktop window has the same shortage
|
||||
// and the same answer, and a narrow one has neither the width to spend nor a second
|
||||
// column to spend it on.
|
||||
test("it is scoped on the shortage rather than on which way up the phone is", () => {
|
||||
assert.ok(!/orientation/.test(SHORT), "orientation says nothing about how much height there is");
|
||||
});
|
||||
|
||||
test("the sheet takes the width it has been leaving empty", () => {
|
||||
assert.match(block, /max-width:\s*min\(100% - var\(--sp-5\), 46rem\)/);
|
||||
});
|
||||
|
||||
test("the tiers become the two columns, with the way out under the thumb", () => {
|
||||
assert.match(block, /"header\s+header"/);
|
||||
assert.match(block, /"keeping hand"/);
|
||||
assert.match(block, /"keeping cancel"/);
|
||||
});
|
||||
|
||||
test("which means the box that held them stands aside", () => {
|
||||
const at = block.indexOf("#seatMenuActions {");
|
||||
assert.ok(at >= 0, "#seatMenuActions is not addressed here");
|
||||
assert.match(block.slice(at, block.indexOf("}", at)), /display:\s*contents/);
|
||||
});
|
||||
|
||||
// A column reserved for nothing would narrow the names for no reason.
|
||||
test("a sheet with no hand tier is one column, not two", () => {
|
||||
const at = block.indexOf("#seatMenuSheet:not(:has(.seatMenuHand))");
|
||||
assert.ok(at >= 0, "the move picker still reserves a column for a tier it hasn't got");
|
||||
const rule = block.slice(at, block.indexOf("}", at));
|
||||
assert.match(rule, /grid-template-columns:\s*minmax\(0, 1fr\)/);
|
||||
assert.match(rule, /"keeping"/);
|
||||
});
|
||||
|
||||
test("the tiles spread into as many columns as the width holds", () => {
|
||||
assert.match(block, /repeat\(auto-fill, minmax\(var\(--tileMin\), 1fr\)\)/);
|
||||
});
|
||||
|
||||
// auto-fit throws away the tracks nothing sits in, so a group of two stretched to
|
||||
// half the sheet each while the group above it was a third — two grids in a column
|
||||
// lining up with nothing.
|
||||
test("and they line up with each other, group to group", () => {
|
||||
// The comment above the rule says the word it is here to rule out, so the code
|
||||
// is read with the comments stripped out of it.
|
||||
const code = block.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
assert.ok(!/auto-fit/.test(code),
|
||||
"auto-fit collapses the empty tracks and the grids stop sharing column edges");
|
||||
});
|
||||
|
||||
// The left column is the taller of the two, and stretching left Cancel a 74px
|
||||
// button on one phone and a 48px one on the next.
|
||||
test("nothing grows into the slack the taller column leaves", () => {
|
||||
assert.match(block, /align-items:\s*start/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the numbers the columns are chosen from", () => {
|
||||
// The floor is what the longest of these labels needs, not a round figure that
|
||||
// happened to work on the phone it was measured on.
|
||||
test("a tile's narrowest is a token the grid of names can raise", () => {
|
||||
assert.match(ruleFor(".seatMenuGrid"), /--tileMin:\s*7\.5rem/);
|
||||
const block = shortViewportBlock();
|
||||
const at = block.indexOf(".seatMenuGrid.seatMenuGridNames");
|
||||
assert.ok(at >= 0, "the chairs never ask for more room than a verb");
|
||||
assert.match(block.slice(at, block.indexOf("}", at)), /--tileMin:\s*8\.25rem/);
|
||||
});
|
||||
|
||||
// Two words. Every pixel over that comes out of the left column, where it is a
|
||||
// whole extra column of tiles.
|
||||
test("the hand column is sized for Fold and All-in and no wider", () => {
|
||||
const block = shortViewportBlock();
|
||||
assert.match(block, /grid-template-columns:\s*minmax\(0, 1fr\) minmax\(8rem, 10rem\)/);
|
||||
});
|
||||
|
||||
test("what was measured is written beside the rules", () => {
|
||||
const block = shortViewportBlock();
|
||||
const note = CSS.slice(CSS.indexOf("A phone on its side: the seat menu goes beside itself"),
|
||||
CSS.indexOf(SHORT));
|
||||
assert.match(note, /640x360/);
|
||||
assert.match(note, /545px/);
|
||||
assert.match(note, /224px/);
|
||||
assert.match(block, /10rem it\s+gets 397 and fits three/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
// A control that can't act, pressed on the device this tool is for.
|
||||
//
|
||||
// Measured on an iPhone, three hands dealt, the card picker open on an empty seat:
|
||||
// seven of the fifty-two cards were greyed out, none of them could take focus, and
|
||||
// tapping one changed nothing whatsoever — no toast, no text, not a byte of DOM. The
|
||||
// tool did have an answer to "who has the ace of spades?"; it was a `title`, and a
|
||||
// `title` needs a mouse. The same held in the header, where a new dealer's very first
|
||||
// screen is two dimmed buttons, and on Export CSV, which was greyed out with no
|
||||
// explanation written anywhere at all.
|
||||
//
|
||||
// `disabled` is the wrong attribute whenever there is something to say. It takes the
|
||||
// control out of the tab order, so a screen reader never lands on it and never hears
|
||||
// the description; and it eats the press on a touch screen, where the hover that would
|
||||
// have shown the `title` does not exist. NN/g's rule for the case is explicit — on a
|
||||
// touch interface, a press on a disabled control should raise the explanation — and
|
||||
// WCAG 3.3.1 wants the instruction reachable at all. `aria-disabled` announces the same
|
||||
// state and leaves the control reachable, so the press arrives and is answered.
|
||||
//
|
||||
// It also fixed something nobody had noticed. The picker's arrow keys hunted past dealt
|
||||
// cards, because a `disabled` button cannot hold focus — and hunting did the exact thing
|
||||
// the grid's no-wrap rule was written to prevent: Right from the ace of spades over a
|
||||
// dealt ace of hearts landed on the ace of diamonds, so the column silently shifted and
|
||||
// the next press of Down walked a different suit. A cell that can hold focus needs no
|
||||
// hunting, so that code is gone and the grid tells the truth about its own shape.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Unavailable } = require("./load");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
const MARKUP = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const src = name => fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.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));
|
||||
};
|
||||
|
||||
// Enough of an element for a module that only ever reads three attributes off one.
|
||||
const element = (attrs = {}) => ({
|
||||
dataset: {},
|
||||
attrs: { ...attrs },
|
||||
setAttribute(name, value) { this.attrs[name] = String(value); },
|
||||
getAttribute(name) { return name in this.attrs ? this.attrs[name] : null; },
|
||||
removeAttribute(name) { delete this.attrs[name]; },
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("marking a control that cannot act", () => {
|
||||
test("it says so in the tree, and carries the reason with it", () => {
|
||||
const btn = element();
|
||||
Unavailable.set(btn, true, "Nothing to resolve yet — seat a player first.");
|
||||
assert.equal(btn.getAttribute("aria-disabled"), "true");
|
||||
assert.equal(Unavailable.reason(btn), "Nothing to resolve yet — seat a player first.");
|
||||
assert.ok(Unavailable.blocked(btn));
|
||||
});
|
||||
|
||||
// The state and the reason are set and cleared in one call on purpose. A control
|
||||
// left marked after the reason stopped being true is worse than one never marked:
|
||||
// it is a live control that refuses to work and explains why in the past tense.
|
||||
test("and both halves come off together", () => {
|
||||
const btn = element();
|
||||
Unavailable.set(btn, true, "Nothing to resolve yet.");
|
||||
Unavailable.set(btn, false, "Nothing to resolve yet.");
|
||||
assert.equal(btn.getAttribute("aria-disabled"), null);
|
||||
assert.equal(Unavailable.reason(btn), "");
|
||||
assert.ok(!Unavailable.blocked(btn));
|
||||
});
|
||||
|
||||
test("a control nobody marked is not blocked and has nothing to say", () => {
|
||||
assert.ok(!Unavailable.blocked(element()));
|
||||
assert.equal(Unavailable.reason(element()), "");
|
||||
assert.equal(Unavailable.reason(null), "");
|
||||
assert.ok(!Unavailable.blocked(null));
|
||||
});
|
||||
|
||||
// `aria-disabled="false"` is a real thing to write and it means enabled. Reading the
|
||||
// attribute's presence rather than its value would have got that backwards.
|
||||
test("and one marked false is enabled, which is what the attribute means", () => {
|
||||
const btn = element({ "aria-disabled": "false" });
|
||||
assert.ok(!Unavailable.blocked(btn));
|
||||
});
|
||||
});
|
||||
|
||||
describe("what happens when it is pressed", () => {
|
||||
// The press can land on a span inside the button rather than the button, which is
|
||||
// most of what the picker's cards are made of.
|
||||
test("the answer is looked for upwards from whatever was hit", () => {
|
||||
const btn = element();
|
||||
Unavailable.set(btn, true, "A♠ — dealt to Maya");
|
||||
const rank = { closest: selector => (selector === '[aria-disabled="true"]' ? btn : null) };
|
||||
assert.equal(Unavailable.reason(Unavailable.from(rank)), "A♠ — dealt to Maya");
|
||||
});
|
||||
|
||||
test("a press on nothing in particular finds nothing", () => {
|
||||
assert.equal(Unavailable.from(null), null);
|
||||
assert.equal(Unavailable.from({}), null);
|
||||
});
|
||||
|
||||
// Nothing about `aria-disabled` stops a click reaching its own handler — that is the
|
||||
// trade for staying reachable. One listener does the stopping, in the capture phase,
|
||||
// so a control's own handler never runs and never has to remember to check.
|
||||
test("the press is stopped before the control's own handler sees it", () => {
|
||||
const listener = src("feedback").slice(src("feedback").indexOf("listen(root) {"));
|
||||
assert.match(listener, /addEventListener\("click"[\s\S]*?\}, true\)/,
|
||||
"the listener is not in the capture phase, so it cannot stop anything");
|
||||
assert.match(listener, /event\.preventDefault\(\)/);
|
||||
assert.match(listener, /event\.stopPropagation\(\)/);
|
||||
});
|
||||
|
||||
test("and it is wired once, for the whole table", () => {
|
||||
const main = src("main");
|
||||
assert.match(main, /Unavailable\.listen\(document\)/);
|
||||
assert.equal((main.match(/Unavailable\.listen\(/g) || []).length, 1);
|
||||
});
|
||||
|
||||
// A control with nothing to say is not a control this is about — the seat sheet's
|
||||
// own greyed-out fields, say. Swallowing its press would be a new dead end.
|
||||
test("a marked control with no reason is left entirely alone", () => {
|
||||
const btn = element();
|
||||
Unavailable.set(btn, true, "");
|
||||
assert.equal(Unavailable.reason(btn), "");
|
||||
});
|
||||
});
|
||||
|
||||
describe("where the answer is written", () => {
|
||||
// The toast is fixed to the bottom of the window at z-index 80; the card picker's
|
||||
// scrim is 95 and its sheet stands 88vh tall. A toast raised for a control inside
|
||||
// that sheet would be behind it, or over the cards if it were not.
|
||||
test("a sheet that covers the toast says it itself", () => {
|
||||
assert.match(MARKUP, /id="cardPickerSheet"[^>]*data-why-note="cardPickerNote"/);
|
||||
assert.match(MARKUP, /id="cardPickerNote"[^>]*role="status"/);
|
||||
});
|
||||
|
||||
test("and everything else falls through to the toast", () => {
|
||||
const say = src("feedback").slice(src("feedback").indexOf("say(el, why) {"));
|
||||
assert.match(say, /closest\("\[data-why-note\]"\)/);
|
||||
assert.match(say, /Toast\.show\(why\)/);
|
||||
});
|
||||
|
||||
// Cleared on the next paint rather than on a timer: a paint is exactly the set of
|
||||
// things that make the last answer stale, and nothing else in the sheet redraws.
|
||||
test("the line is cleared by the next thing that changes the sheet", () => {
|
||||
const picker = src("cardPicker");
|
||||
const at = picker.indexOf("paint() {");
|
||||
assert.ok(at >= 0);
|
||||
const opening = picker.slice(at, picker.indexOf("cardPickerTitle", at));
|
||||
assert.match(opening, /getElementById\("cardPickerNote"\)/);
|
||||
assert.match(opening, /note\.textContent = ""/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the line holds its place", () => {
|
||||
// Filling it must not move the grid. It sits between the slots and the cards, so a
|
||||
// line that appeared from nothing would shove fifty-two targets down the screen
|
||||
// under a thumb already travelling towards one of them.
|
||||
test("it is one line tall whether or not it is saying anything", () => {
|
||||
const rule = ruleFor(".pickerNote");
|
||||
assert.match(rule, /min-height:\s*1\.35em/);
|
||||
assert.match(rule, /line-height:\s*1\.35/);
|
||||
});
|
||||
|
||||
// Measured: the note's box is 318px on a Galaxy S8 at 20px of root text, and a name
|
||||
// is whatever somebody typed. Twenty-four capital Ws draw 434px in it. No wording
|
||||
// is safe, so the box is what holds.
|
||||
test("and it cannot become two, whatever the name is", () => {
|
||||
const rule = ruleFor(".pickerNote");
|
||||
assert.match(rule, /white-space:\s*nowrap/);
|
||||
assert.match(rule, /overflow:\s*hidden/);
|
||||
assert.match(rule, /text-overflow:\s*ellipsis/);
|
||||
});
|
||||
|
||||
test("what it measured is written beside the rule", () => {
|
||||
const at = CSS.indexOf("/* Which card, and who has it.");
|
||||
assert.ok(at >= 0, "the rule carries no note saying why");
|
||||
const note = CSS.slice(at, CSS.indexOf("*/", at));
|
||||
assert.match(note, /318px/);
|
||||
assert.match(note, /434px/);
|
||||
});
|
||||
|
||||
// Which is the half that survives the clipping, and the half that does not.
|
||||
test("the card and where it went come before the name", () => {
|
||||
const picker = src("cardPicker");
|
||||
assert.match(picker, /\$\{label\} — face up on the board/);
|
||||
assert.match(picker, /\$\{label\} — dealt to \$\{takenBy\}/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dimmed looks dimmed, however it was said", () => {
|
||||
test("a button marked either way is painted the same", () => {
|
||||
const at = CSS.indexOf("\n.holdemBtn:disabled,");
|
||||
assert.ok(at >= 0, "the two states are painted by separate rules, so they can drift apart");
|
||||
assert.match(CSS.slice(at, CSS.indexOf("}", at)), /\.holdemBtn\[aria-disabled="true"\]/);
|
||||
});
|
||||
|
||||
test("and so is a card", () => {
|
||||
const at = CSS.indexOf("\n.pickerCard:disabled,");
|
||||
assert.ok(at >= 0);
|
||||
assert.match(CSS.slice(at, CSS.indexOf("}", at)), /\.pickerCard\[aria-disabled="true"\]/);
|
||||
});
|
||||
|
||||
// A press animation on a control that will not act is a lie told in 40ms.
|
||||
test("neither one presses down under a finger that can't use it", () => {
|
||||
[".holdemBtn:active", ".pickerCard:active", ".pickerCard:hover"].forEach(base => {
|
||||
const found = CSS.includes(`${base}:not(:disabled):not([aria-disabled="true"])`);
|
||||
assert.ok(found, `\`${base}\` still animates for a control marked aria-disabled`);
|
||||
});
|
||||
});
|
||||
|
||||
// `disabled` is a state the OS palette knows and draws in GrayText by itself.
|
||||
// `aria-disabled` is a word in the accessibility tree and nothing else, so in
|
||||
// forced-colors mode a dealt card came out looking exactly like a live one.
|
||||
test("and the OS is told about it too, since it only knows the other one", () => {
|
||||
// Whichever of the file's forced-colors blocks it landed in — there is more than
|
||||
// one of them, and the stylesheet is free to answer a query wherever it likes.
|
||||
const open = "@media (forced-colors: active) {";
|
||||
const blocks = [];
|
||||
for (let at = CSS.indexOf(open); at >= 0; at = CSS.indexOf(open, at + 1)) {
|
||||
blocks.push(CSS.slice(at, CSS.indexOf("\n}", at)));
|
||||
}
|
||||
assert.ok(blocks.length, "nothing in the file answers forced-colors at all");
|
||||
const answering = blocks.filter(b => /\[aria-disabled="true"\]/.test(b));
|
||||
assert.equal(answering.length, 1, "the dimmed state has no forced-colors answer");
|
||||
assert.match(answering[0], /\.pickerCard\[aria-disabled="true"\]/);
|
||||
assert.match(answering[0], /\.holdemBtn\[aria-disabled="true"\]/);
|
||||
assert.match(answering[0], /color:\s*GrayText/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the three controls that were dead ends", () => {
|
||||
test("the header's two say why, and are reachable enough to say it", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("renderHeaderActions() {");
|
||||
const body = render.slice(at, render.indexOf("\n },", at));
|
||||
assert.match(body, /Unavailable\.set\(button, nothingToRunYet, why\)/);
|
||||
assert.ok(!/button\.disabled = /.test(body), "one of them is still natively disabled");
|
||||
assert.match(body, /Nothing to resolve yet/);
|
||||
assert.match(body, /Nothing to move on from/);
|
||||
});
|
||||
|
||||
test("Export CSV has a reason at all, which it never did", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf('getElementById("exportHistoryBtn")');
|
||||
assert.ok(at >= 0);
|
||||
const around = render.slice(at - 200, at + 260);
|
||||
assert.match(around, /Unavailable\.set\(/);
|
||||
assert.match(around, /No hands to export yet/);
|
||||
});
|
||||
|
||||
// Found the same afternoon, in the same sheet, by running the whole audit over an
|
||||
// open overlay for the first time: the two slot buttons above the grid are drawn as
|
||||
// a card or an em dash, and the em dash was the entire name a screen reader was
|
||||
// given. "Dash, button", twice, on the sheet where a dealer writes a card down.
|
||||
test("and the slots above it are called something", () => {
|
||||
const picker = src("cardPicker");
|
||||
const at = picker.indexOf("btn.className = \"pickerSlot\"");
|
||||
const body = picker.slice(at, picker.indexOf("slots.appendChild(btn)", at));
|
||||
assert.match(body, /this\.boardSlotLabel\(slot\) : `Card \$\{slot \+ 1\}`/,
|
||||
"a seat's slots still have no name of their own");
|
||||
assert.match(body, /setAttribute\("aria-label", value \? .* : .*empty.*\)/,
|
||||
"an empty slot does not say that it is empty");
|
||||
});
|
||||
|
||||
test("and a dealt card names itself rather than saying \"this one\"", () => {
|
||||
const picker = src("cardPicker");
|
||||
const at = picker.indexOf("const takenBy = used.get(card)");
|
||||
const body = picker.slice(at, picker.indexOf("grid.appendChild(btn)", at));
|
||||
assert.match(body, /Unavailable\.set\(btn, !!takenBy/);
|
||||
assert.ok(!/btn\.disabled = /.test(body), "the cards are still natively disabled");
|
||||
// The `title` is the same sentence, and now reaches a screen reader because the
|
||||
// element it hangs off can be landed on.
|
||||
assert.match(body, /btn\.title = Unavailable\.reason\(btn\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
// Undo, and whether a dealer can actually get to it.
|
||||
//
|
||||
// This tool prefers undo to confirmation — NN/g's own preference, and `runUndoable`
|
||||
// says so in as many words: "it now just happens, and the toast offers the way back for
|
||||
// the few seconds anyone would notice a misclick." That makes the toast's Undo the
|
||||
// safety net under the biggest things the tool does. Three ways it wasn't one:
|
||||
//
|
||||
// * Every whole-step undo clears the per-action history on its way past, so the bar
|
||||
// has no pill left and this button is the only way back. Measured on a desktop it
|
||||
// is the 67th of the page's 68 tab stops, and it is gone in eight seconds. `U` —
|
||||
// the key the sidebar legend advertises as taking back the last action — found
|
||||
// nothing at all and did nothing.
|
||||
// * The eight seconds ran whatever the dealer was doing. A cursor resting on the
|
||||
// toast did not stop it; nor did tabbing onto the button itself. Measured: focus
|
||||
// the Undo, wait 8.6 seconds, and the toast is gone and focus has been handed to
|
||||
// <body> — the offer withdrawn and the dealer's place in the page with it.
|
||||
// * Each toast carried its own role="status" inside #toastHost, which is already a
|
||||
// live region in the markup. A region nested in a region, announced twice.
|
||||
//
|
||||
// The timing rules here are the usual ones: three to four seconds for a plain
|
||||
// confirmation, six to ten for anything carrying a button, pause on hover and on
|
||||
// keyboard focus, and always a manual way out — which is what WCAG 2.2.1 asks of
|
||||
// content that disappears on a clock.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Toast } = require("./load");
|
||||
|
||||
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
|
||||
const MAIN = fs.readFileSync(path.join(SRC, "main.js"), "utf8");
|
||||
const FEEDBACK = fs.readFileSync(path.join(SRC, "feedback.js"), "utf8");
|
||||
const SHORTCUTS = fs.readFileSync(path.join(SRC, "shortcuts.js"), "utf8");
|
||||
const PAGE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enough of a document for a toast: a host it can be put into, elements that can hold
|
||||
// listeners and focus, and a `:hover` that a test can turn on.
|
||||
|
||||
let active = null;
|
||||
|
||||
const node = (tag, className) => {
|
||||
const self = {
|
||||
tagName: String(tag).toUpperCase(),
|
||||
className: className || "",
|
||||
textContent: "",
|
||||
dataset: {},
|
||||
children: [],
|
||||
parent: null,
|
||||
isConnected: true,
|
||||
hovered: false,
|
||||
listeners: {},
|
||||
attrs: {},
|
||||
classList: { add() {} },
|
||||
appendChild(child) { child.parent = self; self.children.push(child); return child; },
|
||||
addEventListener(type, fn) { (self.listeners[type] = self.listeners[type] || []).push(fn); },
|
||||
setAttribute(name, value) { self.attrs[name] = String(value); },
|
||||
getAttribute(name) { return name in self.attrs ? self.attrs[name] : null; },
|
||||
contains(other) {
|
||||
for (let n = other; n; n = n.parent) if (n === self) return true;
|
||||
return false;
|
||||
},
|
||||
matches(selector) { return selector === ":hover" ? self.hovered : false; },
|
||||
focus() { active = self; },
|
||||
getBoundingClientRect: () => ({ height: 44 }),
|
||||
// Fire one of the listeners the toast hung on itself.
|
||||
fire(type, event) { (self.listeners[type] || []).forEach(fn => fn(event || {})); },
|
||||
find(className) {
|
||||
if (self.className === className) return self;
|
||||
for (const child of self.children) {
|
||||
const hit = child.find(className);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(self, "firstElementChild", { get: () => self.children[0] || null });
|
||||
Object.defineProperty(self, "innerHTML", {
|
||||
get: () => "",
|
||||
set(value) {
|
||||
if (value !== "") return;
|
||||
self.children.forEach(child => { child.parent = null; child.isConnected = false; });
|
||||
self.children.length = 0;
|
||||
},
|
||||
});
|
||||
return self;
|
||||
};
|
||||
|
||||
let host;
|
||||
const openDocument = () => {
|
||||
host = node("div", "toastHost");
|
||||
host.id = "toastHost";
|
||||
active = null;
|
||||
global.document = {
|
||||
getElementById: id => (id === "toastHost" ? host : null),
|
||||
createElement: tag => node(tag),
|
||||
documentElement: { style: { setProperty() {} } },
|
||||
};
|
||||
Object.defineProperty(global.document, "activeElement", { get: () => active });
|
||||
global.requestAnimationFrame = fn => fn();
|
||||
};
|
||||
|
||||
const toastOnScreen = () => host.firstElementChild;
|
||||
const undoButton = () => toastOnScreen() && toastOnScreen().find("toastAction");
|
||||
|
||||
// The toast's own timers are the thing under test, so they are driven rather than
|
||||
// waited on. `resume` defers to the next tick on purpose — see feedback.js — so every
|
||||
// release is followed by a tick of 1 to let that land.
|
||||
const settle = t => t.mock.timers.tick(1);
|
||||
|
||||
const show = (options = {}) => Toast.show(options.message || "Button moved on.", options);
|
||||
|
||||
test.beforeEach(t => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
|
||||
openDocument();
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
Toast.dismiss();
|
||||
delete global.document;
|
||||
delete global.requestAnimationFrame;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a toast nobody touches", () => {
|
||||
test("stays for the time it was given, and then goes", t => {
|
||||
show({ duration: 5000 });
|
||||
t.mock.timers.tick(4900);
|
||||
assert.ok(toastOnScreen(), "it left early");
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(toastOnScreen(), null, "it outstayed its duration");
|
||||
});
|
||||
|
||||
test("and the default is the one a message with a button in it gets", () => {
|
||||
// Three to four seconds for a plain confirmation, six to ten for anything
|
||||
// carrying a button. Every plain one here names its own shorter duration; this
|
||||
// default is what the undoable steps and the wake lock fall back to.
|
||||
assert.ok(Toast.DEFAULT_DURATION_MS >= 6000 && Toast.DEFAULT_DURATION_MS <= 10000,
|
||||
`${Toast.DEFAULT_DURATION_MS}ms is outside the range for a toast with an action in it`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a toast the dealer has reached for", () => {
|
||||
test("a finger on it stops the clock", t => {
|
||||
show({ duration: 8000 });
|
||||
t.mock.timers.tick(1000);
|
||||
const toast = toastOnScreen();
|
||||
toast.hovered = true;
|
||||
toast.fire("pointerenter");
|
||||
|
||||
t.mock.timers.tick(30000);
|
||||
assert.ok(toastOnScreen(), "eight seconds ran out under a finger that was holding it");
|
||||
});
|
||||
|
||||
test("and so does tabbing onto the button", t => {
|
||||
show({ duration: 8000, actionLabel: "Undo", onAction: () => {} });
|
||||
const toast = toastOnScreen();
|
||||
undoButton().focus();
|
||||
toast.fire("focusin", { relatedTarget: null });
|
||||
|
||||
t.mock.timers.tick(30000);
|
||||
assert.ok(toastOnScreen(), "the offer was withdrawn while the keyboard was standing on it");
|
||||
assert.equal(active, undoButton(), "and focus went with it");
|
||||
});
|
||||
|
||||
test("letting go starts it again", t => {
|
||||
show({ duration: 8000 });
|
||||
const toast = toastOnScreen();
|
||||
toast.hovered = true;
|
||||
toast.fire("pointerenter");
|
||||
t.mock.timers.tick(30000);
|
||||
|
||||
toast.hovered = false;
|
||||
toast.fire("pointerleave");
|
||||
settle(t);
|
||||
t.mock.timers.tick(7000);
|
||||
assert.ok(toastOnScreen(), "it left before the time it still had");
|
||||
t.mock.timers.tick(1200);
|
||||
assert.equal(toastOnScreen(), null, "letting go did not restart the clock at all");
|
||||
});
|
||||
|
||||
test("and letting go late still leaves time to act", t => {
|
||||
// The half of this that pausing alone doesn't fix: touch it at 7.9 seconds of
|
||||
// 8, let go, and what is left of the clock to *notice* the message is 100ms —
|
||||
// gone while the thumb is still travelling to Undo.
|
||||
show({ duration: 8000 });
|
||||
const toast = toastOnScreen();
|
||||
t.mock.timers.tick(7900);
|
||||
toast.hovered = true;
|
||||
toast.fire("pointerenter");
|
||||
t.mock.timers.tick(3000);
|
||||
toast.hovered = false;
|
||||
toast.fire("pointerleave");
|
||||
settle(t);
|
||||
|
||||
t.mock.timers.tick(Toast.RESUME_MS - 500);
|
||||
assert.ok(toastOnScreen(), "it went in the 100ms that was left rather than the time to act");
|
||||
t.mock.timers.tick(700);
|
||||
assert.equal(toastOnScreen(), null, "and then it never went at all");
|
||||
});
|
||||
|
||||
test("a cursor leaving while the keyboard is still inside does not restart it", t => {
|
||||
// Two ways to hold it open, one timer. The pointer leaving is not an answer to
|
||||
// "is anybody still using this?"
|
||||
show({ duration: 8000, actionLabel: "Undo", onAction: () => {} });
|
||||
const toast = toastOnScreen();
|
||||
toast.hovered = true;
|
||||
toast.fire("pointerenter");
|
||||
undoButton().focus();
|
||||
toast.fire("focusin", { relatedTarget: null });
|
||||
|
||||
toast.hovered = false;
|
||||
toast.fire("pointerleave");
|
||||
settle(t);
|
||||
t.mock.timers.tick(30000);
|
||||
assert.ok(toastOnScreen(), "the clock restarted under a focused button");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what happens to focus when it goes", () => {
|
||||
test("the dealer is put back where they had got to", t => {
|
||||
const elsewhere = node("button");
|
||||
show({ actionLabel: "Undo", onAction: () => {} });
|
||||
const toast = toastOnScreen();
|
||||
|
||||
elsewhere.focus();
|
||||
undoButton().focus();
|
||||
toast.fire("focusin", { relatedTarget: elsewhere });
|
||||
Toast.dismiss();
|
||||
settle(t);
|
||||
|
||||
assert.equal(active, elsewhere,
|
||||
"focus was left on a button that no longer exists, which hands it to <body>");
|
||||
});
|
||||
|
||||
test("and not somewhere they left eight seconds ago", t => {
|
||||
// Captured when focus crosses into the toast, not when the toast appeared: a
|
||||
// dealer who carries on working and then tabs back should land where they got
|
||||
// to, not where they were when the message arrived.
|
||||
const whenItArrived = node("button");
|
||||
const whereTheyGotTo = node("button");
|
||||
whenItArrived.focus();
|
||||
show({ actionLabel: "Undo", onAction: () => {} });
|
||||
const toast = toastOnScreen();
|
||||
|
||||
whereTheyGotTo.focus();
|
||||
undoButton().focus();
|
||||
toast.fire("focusin", { relatedTarget: whereTheyGotTo });
|
||||
Toast.dismiss();
|
||||
settle(t);
|
||||
|
||||
assert.equal(active, whereTheyGotTo);
|
||||
});
|
||||
|
||||
test("focus is left alone when it was never in the toast", () => {
|
||||
const elsewhere = node("button");
|
||||
show({ actionLabel: "Undo", onAction: () => {} });
|
||||
elsewhere.focus();
|
||||
|
||||
Toast.dismiss();
|
||||
assert.equal(active, elsewhere, "dismissing a toast nobody was in moved the dealer");
|
||||
});
|
||||
|
||||
test("and a control that has gone since is not reached for", () => {
|
||||
const gone = node("button");
|
||||
show({ actionLabel: "Undo", onAction: () => {} });
|
||||
const toast = toastOnScreen();
|
||||
gone.focus();
|
||||
undoButton().focus();
|
||||
toast.fire("focusin", { relatedTarget: gone });
|
||||
// A re-render between the toast going up and coming down. This happens on every
|
||||
// undo: restoreState rebuilds the table.
|
||||
gone.isConnected = false;
|
||||
|
||||
assert.doesNotThrow(() => Toast.dismiss());
|
||||
});
|
||||
});
|
||||
|
||||
describe("the key that reaches it", () => {
|
||||
test("a toast says which shortcut presses its button", () => {
|
||||
show({ actionLabel: "Undo", shortcut: "undo", onAction: () => {} });
|
||||
assert.equal(undoButton().dataset.shortcut, "undo");
|
||||
});
|
||||
|
||||
test("and one that named none is not reachable by a stray keypress", () => {
|
||||
// "Turn off" on the wake lock and "Reload" on the other-tab warning both live
|
||||
// in this same strip. Neither is a thing U should be able to do.
|
||||
show({ actionLabel: "Turn off", onAction: () => {} });
|
||||
assert.equal(undoButton().dataset.shortcut, undefined);
|
||||
});
|
||||
|
||||
test("both of the undoable paths opt in", () => {
|
||||
// runUndoable, for whole steps, and seatAction, for a gesture on a seat.
|
||||
const offers = MAIN.split('actionLabel: "Undo"').length - 1;
|
||||
const named = MAIN.split('shortcut: "undo"').length - 1;
|
||||
assert.equal(offers, 2, "the number of Undo toasts has changed");
|
||||
assert.equal(named, offers, "an Undo the keyboard cannot reach");
|
||||
});
|
||||
|
||||
test("the key presses the button rather than running its own copy of it", () => {
|
||||
// The house rule for every other shortcut in this file: press what is on the
|
||||
// screen, so there is no second code path to drift out of step with it.
|
||||
assert.match(MAIN, /#toastHost \.toastAction\[data-shortcut=/);
|
||||
assert.match(MAIN, /undo: \(\) => pressToastAction\("undo"\) \|\| pressInBar\("\.actionUndo"\)/,
|
||||
"the toast is the more recent of the two and has to be asked first");
|
||||
});
|
||||
|
||||
test("and the legend covers both undos, because a dealer has no reason to know there are two", () => {
|
||||
const line = SHORTCUTS.match(/keys: \["U"\][^}]*what: "([^"]+)"/);
|
||||
assert.ok(line, "the U shortcut has no legend line");
|
||||
assert.doesNotMatch(line[1], /last action$/,
|
||||
"the legend still promises only the bar's pill");
|
||||
});
|
||||
});
|
||||
|
||||
describe("one live region, and it is in the markup", () => {
|
||||
test("the host is the region", () => {
|
||||
assert.match(PAGE, /id="toastHost"[^>]*aria-live="polite"/);
|
||||
});
|
||||
|
||||
test("and a toast does not bring a second one with it", () => {
|
||||
// A region that arrives already carrying its text has not been watched by
|
||||
// anything yet, which is the documented reason such a message is never spoken.
|
||||
// The host is in the markup precisely so that it has been.
|
||||
assert.ok(!/toast\.setAttribute\("role"/.test(FEEDBACK),
|
||||
"each toast is its own live region again, nested inside the one that works");
|
||||
show({});
|
||||
assert.equal(toastOnScreen().getAttribute("role"), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
// One number, one control, and a ceiling that matches the table.
|
||||
//
|
||||
// "Split between" on Quick Split was two controls for one value sitting on top of each
|
||||
// other: a row of chips 2 3 4 5, and under it a bar reading "2 ways" with a − and a +.
|
||||
// Measured on an iPhone: 134px with its label, against 74px for "Smallest chip in play"
|
||||
// right below it, which offers five choices in one row. And the number was written down
|
||||
// three times at once — chip "2" lit gold, "2 ways" in the bar, and "Each of 2 players
|
||||
// gets" in the result panel above. Two of those three were controls.
|
||||
//
|
||||
// The bar was never meant to be a bar. `.waysStepper` asked for `min-width: 150px` in a
|
||||
// wrapping flex row, so on any phone it wrapped to a line of its own, and `flex: 1 1
|
||||
// auto` then gave it all 358px of that line: 252px of empty readout with the − and the
|
||||
// + pushed 304px apart. One-handed, on a screen 390px wide, they cannot both be reached.
|
||||
//
|
||||
// So the chips get a fifth chip. It reads "6+" whatever is behind it, because behind it
|
||||
// is where the count is written, and the stepper only exists once you are through the
|
||||
// door. Under six ways — every chop a home game will ever run — the field is one row.
|
||||
//
|
||||
// Two more things went with it. Neither stepper on this screen ever disabled an end, so
|
||||
// − at the floor and + at the ceiling looked live and swallowed the tap; NN/G's stepper
|
||||
// guidelines ask for exactly the opposite. And the per-pot chop capped its + with
|
||||
// `Math.max(contenderCount, MAX_WAYS)`, which is always 12: a side pot that only P1 and
|
||||
// P2 were eligible for would happily be chopped ten ways at $12 each.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { QuickSplit } = require("./load");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "quicksplit.css"), "utf8");
|
||||
const SRC = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "quickSplit.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));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("where the chips stop and the stepper starts", () => {
|
||||
test("the door is the first count the chips don't cover", () => {
|
||||
assert.deepEqual(QuickSplit.WAY_CHOICES, [2, 3, 4, 5]);
|
||||
assert.equal(QuickSplit.overflowFrom(), 6);
|
||||
});
|
||||
|
||||
// Derived rather than written down twice: adding a chip has to move the door.
|
||||
test("and it moves if a chip is added or taken away", () => {
|
||||
const was = QuickSplit.WAY_CHOICES;
|
||||
try {
|
||||
QuickSplit.WAY_CHOICES = [2, 3];
|
||||
assert.equal(QuickSplit.overflowFrom(), 4);
|
||||
} finally {
|
||||
QuickSplit.WAY_CHOICES = was;
|
||||
}
|
||||
});
|
||||
|
||||
test("the stepper's own range is what the door promised", () => {
|
||||
assert.equal(QuickSplit.clampWays(QuickSplit.overflowFrom() - 1), 5);
|
||||
assert.equal(QuickSplit.clampWays(QuickSplit.MAX_WAYS + 4), QuickSplit.MAX_WAYS);
|
||||
assert.equal(QuickSplit.clampWays(0), QuickSplit.WAY_CHOICES[0]);
|
||||
assert.equal(QuickSplit.clampWays(-3), 2);
|
||||
});
|
||||
|
||||
test("the door is drawn once, from the same number", () => {
|
||||
const at = SRC.indexOf("renderWayChoices() {");
|
||||
const body = SRC.slice(at, SRC.indexOf("\n },", at));
|
||||
assert.match(body, /`\$\{this\.overflowFrom\(\)\}\+`/,
|
||||
"the chip's label is typed in rather than taken from where the chips stop");
|
||||
assert.match(body, /aria-label/, "\"6+\" is not a name a screen reader can read out");
|
||||
});
|
||||
|
||||
// The chips and the stepper are one group: when the count is past the chips, no
|
||||
// chip is lit, and the door is.
|
||||
test("nothing is lit in two places at once", () => {
|
||||
const at = SRC.indexOf("renderWayChoices() {");
|
||||
const body = SRC.slice(at, SRC.indexOf("\n },", at));
|
||||
assert.match(body, /const beyond = this\.ways >= this\.overflowFrom\(\)/);
|
||||
assert.match(body, /!beyond && this\.ways === count/,
|
||||
"a preset chip can still light up while the stepper holds a different number");
|
||||
assert.match(body, /if \(beyond\) row\.appendChild\(this\.buildWaysStepper\(\)\)/,
|
||||
"the stepper is drawn even when the chips are the control");
|
||||
});
|
||||
|
||||
test("and the count is written in one place, without a unit the label already gave", () => {
|
||||
const at = SRC.indexOf("buildWaysStepper() {");
|
||||
assert.ok(at >= 0);
|
||||
const body = SRC.slice(at, SRC.indexOf("\n },", at));
|
||||
assert.match(body, /readout\.textContent = String\(this\.ways\)/);
|
||||
assert.ok(!/\$\{this\.ways\} ways/.test(body), "the stepper is spelling the count out again");
|
||||
});
|
||||
});
|
||||
|
||||
describe("both ends of both steppers", () => {
|
||||
test("a step that cannot happen says so", () => {
|
||||
const at = SRC.indexOf("makeStepButton(label, onPick, options) {");
|
||||
assert.ok(at >= 0, "the step buttons still take no disabled state");
|
||||
assert.match(SRC.slice(at, SRC.indexOf("\n },", at)), /btn\.disabled = /);
|
||||
});
|
||||
|
||||
test("the ways stepper disables its floor and its ceiling", () => {
|
||||
const at = SRC.indexOf("buildWaysStepper() {");
|
||||
const body = SRC.slice(at, SRC.indexOf("\n },", at));
|
||||
assert.match(body, /disabled: this\.ways <= this\.overflowFrom\(\)/);
|
||||
assert.match(body, /disabled: this\.ways >= this\.MAX_WAYS/);
|
||||
});
|
||||
|
||||
test("so does the per-pot chop", () => {
|
||||
const at = SRC.indexOf("addChopControl(row, amount, contenderCount) {");
|
||||
const body = SRC.slice(at, SRC.indexOf("\n updateUneven()", at));
|
||||
assert.match(body, /disabled: ways <= 2/);
|
||||
assert.match(body, /disabled: ways >= ceiling/);
|
||||
});
|
||||
|
||||
// 40x44 until the sweep first opened the even mode and read back `hittable 39x43`
|
||||
// on every touch viewport there is.
|
||||
test("and both ends are a fingertip wide, not just a fingertip tall", () => {
|
||||
const rule = ruleFor(".stepBtn");
|
||||
const px = axis => Number((rule.match(new RegExp(`${axis}:\\s*(\\d+)px`)) || [])[1]);
|
||||
assert.ok(px("width") >= 44, `a step button is ${px("width")}px wide`);
|
||||
assert.ok(px("height") >= 44, `a step button is ${px("height")}px tall`);
|
||||
});
|
||||
|
||||
// Disabled has to look disabled, or it is the same silent tap with extra steps.
|
||||
test("and a disabled end is painted as one", () => {
|
||||
assert.match(ruleFor(".stepBtn:disabled"), /opacity:\s*0\.\d+/);
|
||||
assert.match(CSS, /\.stepBtn:not\(:disabled\):hover/,
|
||||
"a dead button still lights up under the mouse");
|
||||
});
|
||||
});
|
||||
|
||||
describe("how many ways a pot can go", () => {
|
||||
// The whole of the bug: `Math.max` where `Math.min` was meant.
|
||||
test("never more ways than there are players who can win it", () => {
|
||||
assert.equal(QuickSplit.chopCeiling(2), 2);
|
||||
assert.equal(QuickSplit.chopCeiling(3), 3);
|
||||
assert.equal(QuickSplit.chopCeiling(9), 9);
|
||||
});
|
||||
|
||||
test("and never past the screen's own ceiling", () => {
|
||||
assert.equal(QuickSplit.chopCeiling(20), QuickSplit.MAX_WAYS);
|
||||
});
|
||||
|
||||
// A pot is only offered a chop at two contenders or more, but the floor is stated
|
||||
// here too so that a caller that gets it wrong can't produce a one-way split.
|
||||
test("a chop is at least two ways", () => {
|
||||
assert.equal(QuickSplit.chopCeiling(1), 2);
|
||||
assert.equal(QuickSplit.chopCeiling(0), 2);
|
||||
});
|
||||
|
||||
test("the chop's + is held to it", () => {
|
||||
const at = SRC.indexOf("addChopControl(row, amount, contenderCount) {");
|
||||
const body = SRC.slice(at, SRC.indexOf("\n updateUneven()", at));
|
||||
assert.match(body, /const ceiling = this\.chopCeiling\(contenderCount\)/);
|
||||
assert.match(body, /if \(ways < ceiling\)/);
|
||||
assert.ok(!/Math\.max\(contenderCount/.test(body), "the old ceiling is still there");
|
||||
});
|
||||
|
||||
// Two eligible players is two ways and nothing else. A stepper with both ends dead
|
||||
// is furniture.
|
||||
test("a heads-up pot is offered no stepper at all", () => {
|
||||
const at = SRC.indexOf("addChopControl(row, amount, contenderCount) {");
|
||||
const body = SRC.slice(at, SRC.indexOf("\n updateUneven()", at));
|
||||
assert.match(body, /if \(ceiling > 2\) \{/);
|
||||
assert.ok(body.indexOf("if (ceiling > 2) {") < body.indexOf('className = "unevenChopWays"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bar that was never meant to be a bar", () => {
|
||||
test("the stepper is as wide as what is in it", () => {
|
||||
const rule = ruleFor(".waysStepper");
|
||||
assert.match(rule, /flex:\s*0 0 auto/);
|
||||
assert.ok(!/min-width/.test(rule),
|
||||
"a min-width in a wrapping row is what handed it the whole second line");
|
||||
});
|
||||
|
||||
test("and the readout no longer pushes the two buttons apart", () => {
|
||||
const rule = ruleFor(".waysReadout");
|
||||
assert.ok(!/flex:\s*1(\s|;)/.test(rule));
|
||||
assert.match(rule, /min-width:\s*[\d.]+ch/);
|
||||
assert.match(rule, /tabular-nums/);
|
||||
});
|
||||
|
||||
// It is standing in for a selected chip, so it is painted like one.
|
||||
test("the count is painted like the chip it replaces", () => {
|
||||
const chip = ruleFor(".choiceChip.selected");
|
||||
// Not `border-color`, which comes first in the rule and is a different gold.
|
||||
const colour = (chip.match(/(?:^|[^-\w])color:\s*(#[0-9a-f]{3,6})/i) || [])[1];
|
||||
assert.ok(colour, "the selected chip has no colour to match");
|
||||
assert.match(ruleFor(".waysReadout"), new RegExp(`color:\\s*${colour}`, "i"));
|
||||
});
|
||||
|
||||
// The measurement that found it, kept where the next round will read it.
|
||||
test("what it measured is written beside the rule", () => {
|
||||
const at = CSS.indexOf("/* As wide as the three things in it");
|
||||
assert.ok(at >= 0, "the rule carries no note saying why");
|
||||
const note = CSS.slice(at, CSS.indexOf("*/", at));
|
||||
assert.match(note, /304px/);
|
||||
assert.match(note, /252px/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the sweep looks at", () => {
|
||||
const DRIVER = fs.readFileSync(path.join(__dirname, "runUiAudit.js"), "utf8");
|
||||
|
||||
// It went straight to the all-in mode, so the mode the screen opens in was audited
|
||||
// zero times in fourteen rounds.
|
||||
test("both modes of the screen, not just the second one", () => {
|
||||
const at = DRIVER.indexOf("const modes = [");
|
||||
assert.ok(at >= 0, "the quick split pass still audits one mode");
|
||||
const body = DRIVER.slice(at, DRIVER.indexOf("];", at));
|
||||
assert.match(body, /"even"/);
|
||||
assert.match(body, /"all-in"/);
|
||||
assert.match(DRIVER, /quick split · \$\{mode\}/, "a failure won't say which mode it was in");
|
||||
});
|
||||
|
||||
test("with the stepper open, since it isn't there otherwise", () => {
|
||||
const at = DRIVER.indexOf('["even", () => {');
|
||||
assert.ok(at >= 0);
|
||||
assert.match(DRIVER.slice(at, DRIVER.indexOf("}]", at)), /#waysChoices \.choiceChip/);
|
||||
});
|
||||
});
|
||||
+195
-33
@@ -143,6 +143,15 @@ const LayoutAudit = {
|
||||
// and this file has no automation of its own: scroll-padding-bottom on the scroll
|
||||
// container has to reserve at least as much room as the furniture standing there.
|
||||
focusHasRoom() {
|
||||
// Not a question that means anything while a sheet is open. The page behind is
|
||||
// inert, and the sheet's own scrim is `position: fixed; inset: 0` — bottom-anchored
|
||||
// and full height — so this read it as furniture standing 100% of the window tall
|
||||
// and demanded scroll padding for all of it. Every overlay failed, every viewport.
|
||||
if (this.modalScope()) {
|
||||
return { name: "focus has room under the fixed bar", offenders: [],
|
||||
skipped: "a sheet is open — the page behind it is not being tabbed" };
|
||||
}
|
||||
|
||||
const furniture = this.bottomFurniture();
|
||||
if (furniture.length === 0) {
|
||||
return { name: "focus has room under the fixed bar", offenders: [],
|
||||
@@ -182,6 +191,13 @@ const LayoutAudit = {
|
||||
// stops being read.
|
||||
furnitureBudget() {
|
||||
const name = "the fixed bar leaves the table its room";
|
||||
// Same reason as focusHasRoom above: a scrim is not furniture, it is the whole
|
||||
// point of the thing on top of it, and "the felt has less room than the furniture"
|
||||
// is not a complaint anyone can act on while a sheet is deliberately covering it.
|
||||
if (this.modalScope()) {
|
||||
return { name, offenders: [], skipped: "a sheet is open — it is meant to cover the table" };
|
||||
}
|
||||
|
||||
const furniture = this.bottomFurniture();
|
||||
if (furniture.length === 0) {
|
||||
return { name, offenders: [], skipped: "nothing is fixed to the bottom here" };
|
||||
@@ -213,6 +229,26 @@ const LayoutAudit = {
|
||||
// accessibility tree, and because a fixed element does not move when the page does,
|
||||
// no amount of scrolling could bring it back. visibility:hidden puts it away; a
|
||||
// transform only moves it.
|
||||
// A fixed panel that scrolls is a different thing from a fixed panel that doesn't,
|
||||
// and this check could not tell them apart. The landscape rail is `overflow-y: auto`
|
||||
// on purpose — the raise pad outgrows a 360px-tall screen the moment the text is
|
||||
// turned up, and the stylesheet chose overflow over drawing controls on top of one
|
||||
// another — so a button below its fold is one flick away, not lost. Reported as
|
||||
// unreachable it is simply untrue, and a sweep that cries wolf is a sweep nobody
|
||||
// reads.
|
||||
//
|
||||
// The ancestor has to be on screen itself for this to hold, which the rail is: it
|
||||
// is `top: 0; bottom: 0`. If a scrolling panel is ever parked off the side, the
|
||||
// thing to report is the panel, not the button inside it.
|
||||
scrollableAncestor(el) {
|
||||
for (let node = el.parentElement; node; node = node.parentElement) {
|
||||
const overflow = getComputedStyle(node).overflowY;
|
||||
if ((overflow === "auto" || overflow === "scroll") &&
|
||||
node.scrollHeight > node.clientHeight + 1) return node;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
focusablesReachable() {
|
||||
const offenders = this.visibleControls()
|
||||
.filter(el => {
|
||||
@@ -225,6 +261,7 @@ const LayoutAudit = {
|
||||
const b = el.getBoundingClientRect();
|
||||
return b.top >= innerHeight || b.bottom <= 0 || b.left >= innerWidth || b.right <= 0;
|
||||
})
|
||||
.filter(el => !this.scrollableAncestor(el))
|
||||
.map(el => `${this.describe(el)} :: fixed and off-screen, so nothing can scroll to it`);
|
||||
|
||||
return { name: "no control is parked where scrolling can't reach it", offenders };
|
||||
@@ -252,6 +289,25 @@ const LayoutAudit = {
|
||||
return { name: "tap-highlight", offenders, checked: this.visibleControls().length };
|
||||
},
|
||||
|
||||
// A control that is on the screen and cannot act has to say why, on the device it is
|
||||
// being used on. `disabled` cannot: it drops the control out of the tab order, so a
|
||||
// screen reader never lands on it to hear the `title`, and it eats the tap on a touch
|
||||
// screen, where there is no hover to show a `title` anyway. That left the card picker
|
||||
// answering "who has the ace of spades?" only to a mouse, and Export CSV answering
|
||||
// nobody at all.
|
||||
//
|
||||
// So a control that is dimmed is either marked `aria-disabled` with a reason on it —
|
||||
// which the one listener in main.js turns into a sentence when it is pressed — or it
|
||||
// is `disabled` and there is genuinely nothing to say, which is the seat sheet's
|
||||
// fields and a stepper sitting on its own end. Having a `title` and being `disabled`
|
||||
// is the combination that cannot work, and it is the one this looks for.
|
||||
dimmedControlsExplain() {
|
||||
const offenders = this.visibleControls()
|
||||
.filter(el => el.disabled && (el.getAttribute("title") || "").trim())
|
||||
.map(el => `${this.describe(el)} :: disabled, and its only explanation is a title`);
|
||||
return { name: "a control that can't act says why where it can be read", offenders };
|
||||
},
|
||||
|
||||
// Text selection must stay ON for anything a dealer types into.
|
||||
textSelectable() {
|
||||
const offenders = [...document.querySelectorAll("input[type='text'], input[type='number'], textarea")]
|
||||
@@ -282,11 +338,25 @@ const LayoutAudit = {
|
||||
const scrolls = /auto|scroll|overlay/.test(style.overflowY) || node === document.body;
|
||||
if (!scrolls) continue;
|
||||
const box = node === document.body ? { top: 0, bottom: innerHeight } : node.getBoundingClientRect();
|
||||
const room = node === document.body
|
||||
const down = node === document.body
|
||||
? document.documentElement.scrollHeight - innerHeight - scrollY
|
||||
: node.scrollHeight - node.clientHeight - node.scrollTop;
|
||||
if (rect.top >= box.bottom && room > 0) return true;
|
||||
if (rect.bottom <= box.top && (node === document.body ? scrollY > 0 : node.scrollTop > 0)) return true;
|
||||
const up = node === document.body ? scrollY : node.scrollTop;
|
||||
|
||||
// How far past each edge the element sits, and whether there is that much
|
||||
// scrolling left in this direction. Partly past counts.
|
||||
//
|
||||
// This used to ask whether the element had left the box *entirely* —
|
||||
// `rect.top >= box.bottom` — which is true of something well below the fold
|
||||
// and false of something hanging eight pixels over the edge of it. So a card
|
||||
// half-visible at the bottom of a sheet with 254px of scroll still in it came
|
||||
// back "cannot be scrolled to", and the row above it, fully out, came back
|
||||
// fine. Verified in a browser before believing either: the sheet scrolls, the
|
||||
// two of clubs comes fully into view and hit-tests as itself.
|
||||
const below = rect.bottom - box.bottom;
|
||||
const above = box.top - rect.top;
|
||||
if (below > 0.5 && down >= below - 0.5) return true;
|
||||
if (above > 0.5 && up >= above - 0.5) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
@@ -334,7 +404,15 @@ const LayoutAudit = {
|
||||
return null;
|
||||
},
|
||||
|
||||
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
|
||||
// Everything a dealer can press, not three selectors' worth.
|
||||
//
|
||||
// This asked about the action bar, the header and the seats, and nothing else on the
|
||||
// page had ever been checked for being covered — which is how the toast came to sit
|
||||
// on 98% of "Common rulings" and 98% of "Quick Split" on every phone, every hand,
|
||||
// for eight seconds. Both are controls, both sit below the felt, and neither was in
|
||||
// any of the three groups, so the sweep never looked.
|
||||
occlusion(selector = null) {
|
||||
selector = selector || this.CONTROL_SELECTOR;
|
||||
const offenders = [];
|
||||
const warnings = [];
|
||||
// While something is open on top, only what is inside it can be judged. The
|
||||
@@ -346,6 +424,21 @@ const LayoutAudit = {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
|
||||
// Inside a disclosure nobody has opened. A shut `<details>` still hands out
|
||||
// a real rect for everything in it — the rake fields measure 54x28 at a
|
||||
// sensible place on the page while House take is closed — so a size check
|
||||
// alone lets them through and they hit-test to the next summary along.
|
||||
// `checkVisibility` is the browser's own answer to the same question.
|
||||
if (typeof el.checkVisibility === "function" &&
|
||||
!el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true })) return;
|
||||
|
||||
// A control that has opted out of the pointer is not being covered — it is
|
||||
// a marker that happens to be a button, and the hit test lands on whatever
|
||||
// is behind it because that is what it asked for. The dealer button on the
|
||||
// seat that holds it is exactly this: `pointer-events: none`, deliberately,
|
||||
// because giving the button to the seat that has it is not a thing to do.
|
||||
if (getComputedStyle(el).pointerEvents === "none") return;
|
||||
|
||||
const outsideViewport = rect.bottom <= 0 || rect.top >= innerHeight ||
|
||||
rect.right <= 0 || rect.left >= innerWidth;
|
||||
if (outsideViewport) {
|
||||
@@ -383,6 +476,7 @@ const LayoutAudit = {
|
||||
let blocked = 0;
|
||||
let testable = 0;
|
||||
let blockedBy = null;
|
||||
let blocker = null;
|
||||
points.forEach(([x, y]) => {
|
||||
const top = document.elementFromPoint(x, y);
|
||||
// A null result means the point isn't hit-testable — typically it sits
|
||||
@@ -408,7 +502,7 @@ const LayoutAudit = {
|
||||
if (!isControl) return;
|
||||
}
|
||||
blocked++;
|
||||
if (!blockedBy) blockedBy = this.describe(top);
|
||||
if (!blockedBy) { blockedBy = this.describe(top); blocker = top; }
|
||||
});
|
||||
|
||||
if (testable === 0) {
|
||||
@@ -428,8 +522,29 @@ const LayoutAudit = {
|
||||
// was a real bug once, where a seat sat exactly under the Undo button and a
|
||||
// tap meant for a player took back the last action instead.
|
||||
const transient = blockedBy && /toast/i.test(blockedBy);
|
||||
if (transient && blocked < testable) warnings.push(note);
|
||||
else offenders.push(note);
|
||||
if (transient && blocked < testable) { warnings.push(note); return; }
|
||||
|
||||
// Content in the flow, under something fixed, on a page that scrolls.
|
||||
//
|
||||
// The fixed thing stays where it is and the content moves, so this is a
|
||||
// position rather than a state: scroll, and the control comes out from
|
||||
// under. Worth saying — a dealer reaching for Common rulings while a toast
|
||||
// is up finds a toast — but it is not the same fault as a control that no
|
||||
// amount of scrolling will free, which is what the rest of this reports.
|
||||
// Widening the check from three selectors to every control put forty of
|
||||
// these in front of a rule that had only ever been shown the felt.
|
||||
const pinned = node => {
|
||||
for (let up = node; up && up !== document.body; up = up.parentElement) {
|
||||
if (getComputedStyle(up).position === "fixed") return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const canScroll = document.documentElement.scrollHeight > innerHeight + 1;
|
||||
if (blockedBy && !pinned(el) && pinned(blocker) && canScroll) {
|
||||
warnings.push(`${note} — scrolls clear of it`);
|
||||
return;
|
||||
}
|
||||
offenders.push(note);
|
||||
});
|
||||
return { name: "nothing important is covered", offenders, warnings };
|
||||
},
|
||||
@@ -812,8 +927,9 @@ const LayoutAudit = {
|
||||
// `exempt` is for controls that are deliberately small *and* have a full-size path
|
||||
// to the same action. The dealer puck is the only one: it's a 22px chip sitting on
|
||||
// the rim of a seat, and growing it to 44px would take that area from the seat
|
||||
// itself — which a dealer taps far more often. "Give the button" is in the seat
|
||||
// menu at full size, so the puck is a shortcut, not the only way through.
|
||||
// itself — which a dealer taps far more often. The seat menu carries the same
|
||||
// action at full size, under "Give this seat", so the puck is a shortcut rather
|
||||
// than the only way through.
|
||||
touchTargets(minimum = 44, { exempt = ".dealerChip", enforceOnPointerDevices = false } = {}) {
|
||||
const offenders = [];
|
||||
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
|
||||
@@ -841,7 +957,14 @@ const LayoutAudit = {
|
||||
// check used to pass it in silence — which is how "Confirm Winner(s)",
|
||||
// the button that pays the pot out, went unmeasured for 36 rounds.
|
||||
// Bring it into view before probing.
|
||||
const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, #rulingsSheet, #seatEditorPanel");
|
||||
// `.rulingsList` before `#rulingsSheet`, and `closest` takes the nearer
|
||||
// of the two: on a phone held sideways the sheet fills the window and
|
||||
// stops scrolling, and the twelve rulings scroll inside the list instead.
|
||||
// Naming only the sheet there finds a box whose scrollHeight equals its
|
||||
// clientHeight, decides nothing is out of view, and puts the check back
|
||||
// in the silence this guard exists to break.
|
||||
const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, " +
|
||||
".rulingsList, #rulingsSheet, #seatEditorPanel");
|
||||
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
|
||||
const box = scroller.getBoundingClientRect();
|
||||
const own = el.getBoundingClientRect();
|
||||
@@ -1236,36 +1359,67 @@ const LayoutAudit = {
|
||||
// right without any of them being modelled. Line boxes rather than one bounding box,
|
||||
// too: a two-line name that clears the chip on the line beside it is not covered
|
||||
// because its *other* line happens to sit under where the chip is.
|
||||
//
|
||||
// Two things a puck wears were missing from this for eleven rounds, and between them
|
||||
// they are why the chair number could sit on the felt covered up without the sweep
|
||||
// ever saying so.
|
||||
//
|
||||
// The first is the chair number itself. This check has always asked whether the
|
||||
// button or a blind badge sits on a name, and the mark that was sitting on names was
|
||||
// neither of those: measured across 348 seats on 36 tables, 51 of them had the number
|
||||
// printed on the first letter of the player's own name.
|
||||
//
|
||||
// The second is that marks were only ever tested against the *writing*, never against
|
||||
// each other — so a badge painted straight over the chair number, which is what all
|
||||
// 66 seats posting a blind were doing, was invisible to a check whose whole subject
|
||||
// is a puck covering itself up.
|
||||
seatLabelsClear() {
|
||||
const offenders = [];
|
||||
// What a puck wears, as opposed to what it writes.
|
||||
const MARKS = ".dealerChip, .blindBadge, .seatChairNumber";
|
||||
const painted = mark => {
|
||||
const box = mark.getBoundingClientRect();
|
||||
if (!box.width || !box.height) return null;
|
||||
if (!(mark.textContent || "").trim()) return null;
|
||||
const style = getComputedStyle(mark);
|
||||
if (style.display === "none" || style.visibility === "hidden" ||
|
||||
parseFloat(style.opacity) < 0.05) return null;
|
||||
return box;
|
||||
};
|
||||
const across = (a, b) => {
|
||||
const wide = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||||
const deep = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||
return wide > 1 && deep > 1 ? wide : 0;
|
||||
};
|
||||
|
||||
document.querySelectorAll(".seat").forEach(seat => {
|
||||
const marks = [...seat.querySelectorAll(MARKS)]
|
||||
.map(mark => ({ mark, box: painted(mark) }))
|
||||
.filter(entry => entry.box);
|
||||
|
||||
const name = seat.querySelector(".seatPuckName");
|
||||
if (!name || !name.textContent.trim()) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(name);
|
||||
const lines = [...range.getClientRects()].filter(line => line.width > 1 && line.height > 1);
|
||||
if (!lines.length) return;
|
||||
|
||||
seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => {
|
||||
const over = mark.getBoundingClientRect();
|
||||
if (!over.width || !over.height) return;
|
||||
const marked = getComputedStyle(mark);
|
||||
if (marked.display === "none" || marked.visibility === "hidden" ||
|
||||
parseFloat(marked.opacity) < 0.05) return;
|
||||
|
||||
let worst = 0;
|
||||
lines.forEach(line => {
|
||||
const across = Math.min(line.right, over.right) - Math.max(line.left, over.left);
|
||||
const down = Math.min(line.bottom, over.bottom) - Math.max(line.top, over.top);
|
||||
if (across > 1 && down > 1) worst = Math.max(worst, across);
|
||||
if (name && name.textContent.trim()) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(name);
|
||||
const lines = [...range.getClientRects()].filter(line => line.width > 1 && line.height > 1);
|
||||
marks.forEach(({ mark, box }) => {
|
||||
const worst = lines.reduce((most, line) => Math.max(most, across(line, box)), 0);
|
||||
if (worst > 1) {
|
||||
offenders.push(`${this.describe(name)} :: ${Math.round(worst)}px under ${this.describe(mark)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// And the marks against each other. The rim holds three of them — the blinds
|
||||
// at one end, the button at the other — and there is not room for a third.
|
||||
marks.forEach((a, i) => marks.slice(i + 1).forEach(b => {
|
||||
const worst = across(a.box, b.box);
|
||||
if (worst > 1) {
|
||||
offenders.push(`${this.describe(name)} :: ${Math.round(worst)}px under ${this.describe(mark)}`);
|
||||
offenders.push(`${this.describe(a.mark)} :: ${Math.round(worst)}px across ${this.describe(b.mark)}`);
|
||||
}
|
||||
});
|
||||
}));
|
||||
});
|
||||
return { name: "seat names aren't painted under their own badges", offenders };
|
||||
return { name: "nothing a seat wears covers its name or another mark", offenders };
|
||||
},
|
||||
|
||||
// A seat's writing, covered by something belonging to a different seat.
|
||||
@@ -1504,10 +1658,17 @@ const LayoutAudit = {
|
||||
const r = button.getBoundingClientRect();
|
||||
const label = this.describe(button);
|
||||
if (r.bottom > innerHeight || r.top < 0) {
|
||||
// Below the fold is only a fault if it is also out of reach. A sheet
|
||||
// that scrolls is allowed to be taller than the window — the card
|
||||
// picker is fifty-two cards and always will be, and this reported the
|
||||
// bottom four rows of it as unreachable buttons on every phone there
|
||||
// is. What the rule is actually for is a dialog whose Confirm has
|
||||
// been pushed off a short screen with no way to get to it.
|
||||
if (this.reachableByScrolling(button)) return;
|
||||
const off = r.bottom > innerHeight
|
||||
? Math.round(r.bottom - innerHeight) + "px below the fold"
|
||||
: Math.round(-r.top) + "px above it";
|
||||
offenders.push(`${label} is ${off}`);
|
||||
offenders.push(`${label} is ${off}, and nothing scrolls to it`);
|
||||
return;
|
||||
}
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
@@ -1530,6 +1691,7 @@ const LayoutAudit = {
|
||||
this.furnitureBudget(),
|
||||
this.touchAction(),
|
||||
this.tapHighlight(),
|
||||
this.dimmedControlsExplain(),
|
||||
this.textSelectable(),
|
||||
this.occlusion(options.occlusionSelector),
|
||||
this.collapsedColumns(),
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// The size the table is drawn at before anything has been measured.
|
||||
//
|
||||
// The felt is not sized by CSS alone. It is sized from `--feltRoomPx`, which actionBar.js
|
||||
// publishes from the bar's real height — because the bar is fixed to the bottom of the
|
||||
// screen and grows with the hand, so nothing else can know how much of the window is
|
||||
// left. That measurement is right. What it was measuring was not.
|
||||
//
|
||||
// `ActionBar.mount()` called `watchHeight()` before `render()`, and the bar ships
|
||||
// `<div id="actionBar" hidden>` in the markup. So the first publish measured a bar that
|
||||
// was not there yet and published `0px` — not "I don't know", but "the bar takes none of
|
||||
// the window", which is the one answer that makes the felt draw itself too big.
|
||||
//
|
||||
// Measured on a cold load, nine-handed:
|
||||
//
|
||||
// 360x640 --feltRoomPx 525px -> felt 336x504, then 336x374 at t=300ms
|
||||
// 1280x900 --feltRoomPx 755px -> felt 857x617, then 839x604
|
||||
//
|
||||
// A hundred and thirty pixels of table, with every seat on it, moving after it was on
|
||||
// screen and readable. The ResizeObserver does come round on its own once the markup's
|
||||
// `hidden` comes off — but a frame later at best, and measured with nine seats to lay
|
||||
// out it was 875ms behind.
|
||||
//
|
||||
// Two rules hold it shut, and they are separate on purpose: one stops the wrong number
|
||||
// being published at all, the other makes sure a right one is published straight away.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ActionBar } = require("./load");
|
||||
|
||||
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
|
||||
const BAR = fs.readFileSync(path.join(SRC, "actionBar.js"), "utf8");
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
// A document with one action bar in it, and a note of everything published.
|
||||
const pageWith = ({ hidden, height, layout }) => {
|
||||
const published = [];
|
||||
const host = {
|
||||
hidden,
|
||||
getBoundingClientRect: () => ({ height, top: 0 }),
|
||||
};
|
||||
global.document = {
|
||||
getElementById: id => (id === "actionBar" ? host : null),
|
||||
documentElement: {
|
||||
style: {
|
||||
setProperty: (name, value) => published.push([name, value]),
|
||||
getPropertyValue: () => "",
|
||||
},
|
||||
},
|
||||
};
|
||||
global.getComputedStyle = () => ({ getPropertyValue: () => layout });
|
||||
global.window = { innerHeight: 844, scrollY: 0, addEventListener: () => {} };
|
||||
return { host, published, barHeight: () => published.filter(p => p[0] === "--actionBarHeight") };
|
||||
};
|
||||
|
||||
// watchHeight wires listeners and publishes once. The felt half of it is stubbed: it
|
||||
// reads a wrap that is not in this document, and what is under test here is the number
|
||||
// the bar decides to hand over.
|
||||
const publishOnce = page => {
|
||||
const realFelt = ActionBar.publishFeltRoom;
|
||||
const roomAsked = [];
|
||||
ActionBar.publishFeltRoom = value => roomAsked.push(value);
|
||||
ActionBar.heightWatcher = null;
|
||||
try {
|
||||
ActionBar.watchHeight();
|
||||
} finally {
|
||||
ActionBar.publishFeltRoom = realFelt;
|
||||
}
|
||||
return roomAsked;
|
||||
};
|
||||
|
||||
test.afterEach(() => {
|
||||
delete global.document;
|
||||
delete global.getComputedStyle;
|
||||
delete global.window;
|
||||
ActionBar.heightWatcher = null;
|
||||
delete ActionBar.publishHeight;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a bar nobody has drawn yet", () => {
|
||||
test("says nothing, rather than saying nothing is there", () => {
|
||||
// The distinction the whole fix rests on. Zero is an answer; "not yet" is not,
|
||||
// and publishing the first as though it were the second is what made the felt
|
||||
// believe it had the whole window.
|
||||
const page = pageWith({ hidden: true, height: 0, layout: "bar" });
|
||||
publishOnce(page);
|
||||
|
||||
assert.deepEqual(page.barHeight(), [],
|
||||
"a hidden bar published a height, and the felt will be drawn against it");
|
||||
});
|
||||
|
||||
test("and the felt is not asked to resize on the strength of it either", () => {
|
||||
const page = pageWith({ hidden: true, height: 0, layout: "bar" });
|
||||
const roomAsked = publishOnce(page);
|
||||
|
||||
assert.deepEqual(roomAsked, [], "the felt was handed a number measured off nothing");
|
||||
});
|
||||
|
||||
test("a bar that has been drawn publishes what it measures", () => {
|
||||
const page = pageWith({ hidden: false, height: 151, layout: "bar" });
|
||||
publishOnce(page);
|
||||
|
||||
assert.deepEqual(page.barHeight(), [["--actionBarHeight", "151px"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a rail is not an undrawn bar", () => {
|
||||
// A phone held sideways puts the bar down the right-hand side, where it genuinely
|
||||
// takes none of the bottom of the window. That zero is a measurement and has to keep
|
||||
// being published — otherwise the felt goes on reserving room for a bar that has
|
||||
// moved out of its way.
|
||||
test("it publishes zero, and it does so even while hidden", () => {
|
||||
const page = pageWith({ hidden: true, height: 0, layout: "rail" });
|
||||
publishOnce(page);
|
||||
|
||||
assert.deepEqual(page.barHeight(), [["--actionBarHeight", "0px"]],
|
||||
"a rail stopped saying it takes no height");
|
||||
});
|
||||
|
||||
test("and a drawn rail publishes zero rather than its own height", () => {
|
||||
const page = pageWith({ hidden: false, height: 390, layout: "rail" });
|
||||
publishOnce(page);
|
||||
|
||||
assert.deepEqual(page.barHeight(), [["--actionBarHeight", "0px"]],
|
||||
"the rail's height went into the number that means 'how much of the bottom is gone'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("and something is published as soon as there is a bar to measure", () => {
|
||||
test("mount publishes after it has rendered, not only before", () => {
|
||||
// watchHeight still goes first, because render() may reach for publishHeight.
|
||||
// The publish that matters is the one after.
|
||||
const mount = BAR.slice(BAR.indexOf("mount(state, hooks) {"), BAR.indexOf("watchHeight() {"));
|
||||
const watch = mount.indexOf("this.watchHeight();");
|
||||
const render = mount.indexOf("this.render();");
|
||||
const publish = mount.indexOf("this.publishHeight();");
|
||||
|
||||
assert.ok(watch >= 0 && render >= 0 && publish >= 0,
|
||||
"mount no longer does all three of these");
|
||||
assert.ok(watch < render, "watchHeight has to be in place before render can call it");
|
||||
assert.ok(render < publish,
|
||||
"mount publishes before the bar has been drawn, which is the whole bug");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the felt falls back on before any of that", () => {
|
||||
// The scripts are at the end of the body and the table is laid out inside main.js's
|
||||
// DOMContentLoaded, so there is a real frame where --feltRoomPx does not exist yet.
|
||||
// `100vh` was the worst guess available for it: it says the whole window is free.
|
||||
test("a phone does not assume the whole window", () => {
|
||||
// The phone's rule is the one that caps the felt's height; the wide layout caps
|
||||
// its width instead, from the same number.
|
||||
const found = CSS.match(/max-height:\s*var\(--feltRoomPx,([^)]*\)?[^;]*);/);
|
||||
assert.ok(found, "nothing caps the felt's height by the measured room any more");
|
||||
const fallback = found[1].trim();
|
||||
assert.notEqual(fallback, "100vh",
|
||||
"the fallback claims the whole window, so the first frame draws a table that has to shrink");
|
||||
assert.match(fallback, /var\(--actionBarHeight, 9rem\)/,
|
||||
"9rem is what the rest of this file assumes a bar costs before it can be asked");
|
||||
});
|
||||
|
||||
test("and 9rem is a number this file already stands behind", () => {
|
||||
// Two other rules had reached the same conclusion before this one did. If they
|
||||
// ever disagree, the felt's first frame and the toast's first frame disagree.
|
||||
const nines = CSS.match(/var\(--actionBarHeight, 9rem\)/g) || [];
|
||||
assert.ok(nines.length >= 3,
|
||||
`only ${nines.length} rules assume 9rem; the felt's guess should be the same guess as the rest`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
// The pill that names what Undo would take back, and whether it manages to.
|
||||
//
|
||||
// The bar's undo is a standing control rather than a toast per action, and it is a fixed
|
||||
// width on purpose: its text changes on every tap, and a control that resizes under a
|
||||
// thumb reads as the interface twitching. Measured on a phone that leaves the label
|
||||
// 78px, which is not enough for any label with an actor on it — so the label is three
|
||||
// elements (arrow, actor, act) and `ActionBar.fitUndoLabel` drops the actor whole rather
|
||||
// than letting an ellipsis land wherever it lands.
|
||||
//
|
||||
// That machinery was right and the labels being fed to it were not. Two rules follow
|
||||
// from "the actor is the half that goes", and six of the fifteen labels broke one:
|
||||
//
|
||||
// The act has to name the action on its own. Four labels put a *verb* in the actor's
|
||||
// slot — "dealing" + "the hand", "dealing" + "everybody in", "calling" + "the straddle
|
||||
// off" — so dropping the lead left the pill reading "↶ the hand", which is what it
|
||||
// said after every deal of the night, and "↶ the straddle off". Neither names an
|
||||
// action.
|
||||
//
|
||||
// The act has to fit, because whatever is left is ellipsised. Measured at 390x844 and
|
||||
// 360x640 with a thumb: "hand killed on the clock" wanted 125px of 78, "check on the
|
||||
// clock" 99, "waiting for the blind" 103, "posting to come in" 97. All four came out
|
||||
// cut mid-phrase — "↶ waiting for th…".
|
||||
//
|
||||
// Both are fixed by writing the labels differently rather than by changing the fitting:
|
||||
// the qualifier moves into `long`, which is the whole sentence, and the verb stays on
|
||||
// the pill. Every short form is a substring of its long form, which is what Label in
|
||||
// Name asks of a control that has shortened its own name — and it is what lets `long`
|
||||
// be the accessible name rather than only a `title` nothing on a phone can reach.
|
||||
//
|
||||
// Measured after, on both phone sizes: nothing ellipsised, and every visible form
|
||||
// contained in the name. The one exception is stated below.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ActionBar } = require("./load");
|
||||
|
||||
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
|
||||
const BAR = fs.readFileSync(path.join(SRC, "actionBar.js"), "utf8");
|
||||
|
||||
// Every act the bar can put on the pill, with what the pill shows once the actor has
|
||||
// been dropped and the whole sentence it belongs to.
|
||||
//
|
||||
// This table is the rule written down. A new action has to appear here before its act is
|
||||
// allowed, which is the point: the two things that go wrong — an act that needs its lead
|
||||
// to mean anything, and an act too long for the box — are both invisible until somebody
|
||||
// reads the pill on a phone.
|
||||
const ACTS = {
|
||||
"fold": { shows: "↶ Dee's fold", long: "Dee's fold" },
|
||||
"check": { shows: "↶ Dee's check", long: "Dee's check" },
|
||||
"call": { shows: "↶ Dee's call", long: "Dee's call" },
|
||||
"all-in": { shows: "↶ Dee's all-in", long: "Dee's all-in" },
|
||||
"hand killed": { shows: "↶ hand killed", long: "Dee's hand killed on the clock" },
|
||||
"waiting": { shows: "↶ Dee waiting", long: "Dee waiting for the blind" },
|
||||
"posting": { shows: "↶ Dee posting", long: "Dee posting to come in" },
|
||||
"the deal": { shows: "↶ the deal", long: "the deal" },
|
||||
"dealt in": { shows: "↶ dealt in", long: "everybody dealt in" },
|
||||
"straddle": { shows: "↶ the straddle", long: "the straddle" },
|
||||
"re-straddle": { shows: "↶ the re-straddle", long: "the re-straddle" },
|
||||
"called off": { shows: "↶ called off", long: "the straddle called off" },
|
||||
};
|
||||
|
||||
// Acts that carry an amount, and so cannot be shortened without losing the number that
|
||||
// is the whole of what they say. "raise to $12,500" is 86px of 78 and does ellipsise —
|
||||
// accepted, because the leading digits survive, the amount is on that player's own seat
|
||||
// puck, and the whole of it is on the button's name. Below five figures they all fit:
|
||||
// "raise to $1,200" is 79.
|
||||
const AMOUNTS = ["raise to ", "bet of "];
|
||||
|
||||
// One street name per act, filled in by the caller.
|
||||
const TEMPLATED = ["the ${street}"];
|
||||
|
||||
// The longest non-amount act that fits, in characters. A proxy for the 78px measured on
|
||||
// the two phones in the sweep — "re-straddle" and "hand killed" are 11 and both fit;
|
||||
// "forced check" is 12 and 68px, the widest that was tried and cleared.
|
||||
const BUDGET = 12;
|
||||
|
||||
// Every `act:` in the bar, pulled out of the source rather than listed by hand — a new
|
||||
// one that nobody adds to ACTS above has to fail this.
|
||||
const actsInSource = () => {
|
||||
const found = new Set();
|
||||
BAR.split("\n").forEach(line => {
|
||||
let at = line.indexOf("act:");
|
||||
while (at >= 0) {
|
||||
const rest = line.slice(at + 4);
|
||||
// Quoted and back-ticked tokens on that property, minus anything that is
|
||||
// the right-hand side of a comparison — `expired.verb === "fold" ? …` picks
|
||||
// the act, it is not one.
|
||||
const tokens = rest.match(/(["`])((?:\\.|(?!\1)[^\\])*)\1/g) || [];
|
||||
tokens.forEach(raw => {
|
||||
const before = rest.slice(0, rest.indexOf(raw));
|
||||
if (/[=!]==\s*$/.test(before)) return;
|
||||
found.add(raw.slice(1, -1));
|
||||
});
|
||||
at = line.indexOf("act:", at + 4);
|
||||
}
|
||||
});
|
||||
return [...found];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what the pill is allowed to say", () => {
|
||||
test("every act in the bar is one this file has looked at", () => {
|
||||
const known = new Set([...Object.keys(ACTS), ...TEMPLATED]);
|
||||
const stray = actsInSource().filter(a =>
|
||||
!known.has(a) && !AMOUNTS.some(prefix => a.startsWith(prefix)));
|
||||
assert.deepEqual(stray, [],
|
||||
"a new act, which has to be checked against a 78px box before it is trusted");
|
||||
});
|
||||
|
||||
test("and every act this file has looked at is still in the bar", () => {
|
||||
const inSource = new Set(actsInSource());
|
||||
const gone = Object.keys(ACTS).filter(a => !inSource.has(a));
|
||||
assert.deepEqual(gone, [], "this table describes labels the bar no longer produces");
|
||||
});
|
||||
|
||||
test("none of them needs its actor to mean anything", () => {
|
||||
// The failure this is here for: "the hand", which is what the pill read after
|
||||
// every deal, and "the straddle off". An act is what survives, so an act that
|
||||
// only parses as the tail of a phrase survives as nothing.
|
||||
Object.entries(ACTS).forEach(([act, { shows }]) => {
|
||||
assert.ok(shows.includes(act), `"${shows}" does not contain "${act}"`);
|
||||
assert.doesNotMatch(act, /^(the hand|everybody in|the straddle off)$/,
|
||||
`"${act}" is one of the acts that read as nothing on their own`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and none of them is too long for the box", () => {
|
||||
Object.keys(ACTS).forEach(act => {
|
||||
assert.ok(act.length <= BUDGET,
|
||||
`"${act}" is ${act.length} characters; over ${BUDGET} it ellipsises on a phone`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the amounts are the exception, and they are the only one", () => {
|
||||
const amounts = actsInSource().filter(a => AMOUNTS.some(p => a.startsWith(p)));
|
||||
assert.ok(amounts.length > 0, "the raise labels have moved and this exemption is stale");
|
||||
amounts.forEach(a => assert.ok(!(a in ACTS),
|
||||
`"${a}" is listed as both an amount and a fixed act`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the whole sentence is kept where the short one cannot go", () => {
|
||||
test("a label may spell itself out at length", () => {
|
||||
const parts = ActionBar.undoParts({
|
||||
lead: "Dee's", act: "hand killed", long: "Dee's hand killed on the clock",
|
||||
});
|
||||
assert.equal(parts.act, "hand killed", "the pill gets the short one");
|
||||
assert.equal(parts.long, "Dee's hand killed on the clock", "the name gets the whole one");
|
||||
});
|
||||
|
||||
test("and without one it is still built from the halves", () => {
|
||||
assert.equal(ActionBar.undoParts({ lead: "Dee's", act: "fold" }).long, "Dee's fold");
|
||||
assert.equal(ActionBar.undoParts({ act: "the deal" }).long, "the deal",
|
||||
"an act with no actor should not be given a leading space");
|
||||
});
|
||||
|
||||
test("a plain string still works, with nothing to drop", () => {
|
||||
const parts = ActionBar.undoParts("something older");
|
||||
assert.deepEqual(parts, { lead: "", act: "something older", long: "something older" });
|
||||
});
|
||||
|
||||
test("every short form is inside the sentence it came from", () => {
|
||||
// Label in Name: a speech user saying the words they can see has to hit the
|
||||
// control, so the accessible name cannot replace the visible text with
|
||||
// different words.
|
||||
Object.entries(ACTS).forEach(([act, { long }]) => {
|
||||
assert.ok(long.includes(act), `the name "${long}" does not contain "${act}"`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the pill is called out loud", () => {
|
||||
test("the sentence is the accessible name, not only a hover", () => {
|
||||
// Before this the name was whatever text was visible — on a phone, "↶ hand
|
||||
// killed U" — and the whole of it lived in a `title`, which a touch screen has
|
||||
// no way to show and a screen reader is not obliged to read.
|
||||
assert.match(BAR, /undo\.setAttribute\("aria-label", `Undo \$\{undoLabel\}`\)/);
|
||||
assert.match(BAR, /undo\.title = `Undo \$\{undoLabel\}`/);
|
||||
});
|
||||
|
||||
test("and the decoration stays out of it", () => {
|
||||
const undoBlock = BAR.slice(BAR.indexOf('undo.className = "actionUndo"'),
|
||||
BAR.indexOf("acting.appendChild(undo)"));
|
||||
assert.match(undoBlock, /glyph\.setAttribute\("aria-hidden", "true"\)/,
|
||||
'the arrow is read as part of the name — "left arrow over hand killed"');
|
||||
assert.match(undoBlock, /key\.setAttribute\("aria-hidden", "true"\)/,
|
||||
"the keycap is read as part of the name");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user