@@ -53,6 +53,39 @@ const goDo = async () => {
5353}
5454const tile = ( name ) => page . locator ( 'button.group' , { hasText : name } )
5555
56+ // EditableGrid renders its <thead> at mount but fills <tbody> from an async load()
57+ // ($effect in EditableGrid.svelte) — so a check that waits on the header and then reads
58+ // rows/tbody/a <datalist> races that fetch and can sample an EMPTY grid. That one defect
59+ // is behind both known flakes (source-type-inert, mixed-denom) and every latent grid read
60+ // (om-yd1h). Fix the WAIT, never the timeout: gate on the loaded rows. Real rows carry
61+ // data-row-index; the trailing draft row does not, so this counts exactly what load()
62+ // produced. `exact` is for AFTER a delete, where the count DROPS and a `>=` would
63+ // short-circuit on the pre-delete rows.
64+ const dataRowSel = 'section table tbody tr[data-row-index]'
65+ const awaitRowCount = ( n , { sel = dataRowSel , exact = false } = { } ) =>
66+ page
67+ . waitForFunction (
68+ ( [ s , want , eq ] ) => {
69+ const c = document . querySelectorAll ( s ) . length
70+ return eq ? c === want : c >= want
71+ } ,
72+ [ sel , n , exact ] ,
73+ { timeout : 5000 } ,
74+ )
75+ . catch ( ( ) => { } ) // let the ok() below report the miss, with real numbers
76+
77+ // Poll a REST endpoint until it settles, instead of sleeping a fixed guess after a write.
78+ // Returns the last value seen either way, so the ok() reports real state on a miss.
79+ const awaitApi = async ( path , pred , { tries = 60 , gap = 100 } = { } ) => {
80+ let v
81+ for ( let i = 0 ; i < tries ; i ++ ) {
82+ v = await api ( path )
83+ if ( pred ( v ) ) break
84+ await new Promise ( ( r ) => setTimeout ( r , gap ) )
85+ }
86+ return v
87+ }
88+
5689try {
5790 await page . goto ( BASE , { waitUntil : 'networkidle' } )
5891 await page . getByRole ( 'heading' , { name : 'CoinRollHunter' } ) . waitFor ( { timeout : 8000 } )
@@ -146,7 +179,7 @@ try {
146179 // step 1: a forgotten keeper (5 halves = $2.50) shrinks the float — NOT a loss
147180 await page . getByRole ( 'spinbutton' ) . nth ( 0 ) . fill ( '5' )
148181 await page . getByRole ( 'button' , { name : 'Add' , exact : true } ) . first ( ) . click ( )
149- await page . waitForTimeout ( 400 )
182+ await awaitApi ( '/keepers' , ( k ) => k . length === 2 ) // wait for the write, not a fixed guess
150183 ok ( 'reconcile recorded forgotten keeper' , ( await api ( '/keepers' ) ) . length === 2 )
151184 const sumMid = await api ( '/summary' )
152185 ok ( 'keeper reduced float (not a loss)' , ( sumMid . losses ?? 0 ) === 0 && sumMid . to_redeposit < sumBefore . to_redeposit ,
@@ -179,8 +212,9 @@ try {
179212 await page . getByRole ( 'button' , { name : 'Edit' } ) . click ( )
180213 await page . getByRole ( 'button' , { name : 'Losses' , exact : true } ) . click ( )
181214 await page . locator ( 'section table thead th' ) . first ( ) . waitFor ( { timeout : 5000 } )
182- const lossRows = await page . locator ( 'section table tbody tr:has(button[title="Delete row"])' ) . count ( )
183215 const apiLosses = ( await api ( '/losses' ) ) . length
216+ await awaitRowCount ( apiLosses ) // the header is up; wait for load() to land the rows
217+ const lossRows = await page . locator ( 'section table tbody tr:has(button[title="Delete row"])' ) . count ( )
184218 ok ( 'Losses grid round-trips' , lossRows === apiLosses && apiLosses >= 1 , `dom ${ lossRows } vs api ${ apiLosses } ` )
185219
186220 // === Overview reflects shrinkage ===
@@ -196,8 +230,7 @@ try {
196230 ( await page . getByText ( 'Buys' , { exact : true } ) . count ( ) ) > 0 &&
197231 ( await page . getByText ( 'Branches' , { exact : true } ) . count ( ) ) > 0 &&
198232 ( await page . getByText ( 'Avg buy' , { exact : true } ) . count ( ) ) > 0 )
199- // spot freshness chip — source label varies (manual seed vs. the ADR-007 poller),
200- // so match the chip by its unique title, not the source string.
233+ // spot freshness chip — matched by its unique (static) title attribute.
201234 ok ( 'spot freshness chip visible (ADR-007)' , await page . locator ( 'span[title*="background"]' ) . first ( ) . isVisible ( ) )
202235 // hit-rate report: the endpoint (data) plus the grid, which lives on the
203236 // Insights tab — the analysis altitude was lifted out of Overview in the ADR-012
@@ -221,8 +254,9 @@ try {
221254 await newRow . getByPlaceholder ( 'Mercury' ) . fill ( 'Mercury' )
222255 await newRow . locator ( 'input[type=checkbox]' ) . check ( )
223256 await newRow . locator ( 'button[title="Add row"]' ) . click ( )
224- await page . waitForTimeout ( 500 )
225- const taxLot = ( await api ( '/lots' ) ) . find ( ( l ) => l . category === 'Silver' && l . subcategory === 'Mercury' && l . trophy === true )
257+ const isTaxLot = ( l ) => l . category === 'Silver' && l . subcategory === 'Mercury' && l . trophy === true
258+ await awaitApi ( '/lots' , ( ls ) => ls . some ( isTaxLot ) ) // wait for create+reload, not a fixed guess
259+ const taxLot = ( await api ( '/lots' ) ) . find ( isTaxLot )
226260 ok ( 'Holdings taxonomy persists (category/subcategory/trophy)' , ! ! taxLot , taxLot ? `lot ${ taxLot . id } ` : 'not found' )
227261 // The new find has basis 0 (grid default) — summary must still serialize (no +Inf in unreal_pct).
228262 const sumZeroBasis = await api ( '/summary' )
@@ -266,6 +300,7 @@ try {
266300 await page . getByRole ( 'button' , { name : 'Edit' } ) . click ( )
267301 await page . getByRole ( 'button' , { name : 'Roll txns' , exact : true } ) . click ( )
268302 await page . locator ( 'section table thead th' ) . first ( ) . waitFor ( { timeout : 5000 } )
303+ await awaitRowCount ( ( await api ( '/roll-txns' ) ) . length ) // wait for the rows before reading them
269304 // cells: date(0) bank(1) action(2) denom(3) unit(4) source(5); the first select
270305 // in a row is the action select. The draft row defaults to 'buy' so it's excluded.
271306 const returnSourceCells = await page . $$eval ( 'section table tbody tr' , ( rows ) =>
@@ -301,14 +336,22 @@ try {
301336 await page . getByRole ( 'button' , { name : 'Edit' , exact : true } ) . click ( )
302337 await page . getByRole ( 'button' , { name : 'Holdings' , exact : true } ) . click ( )
303338 await page . locator ( 'section table thead th' ) . first ( ) . waitFor ( { timeout : 5000 } )
339+ // The header is up but rows arrive from an async load(); wait for at least one real
340+ // row (which also means the suggestion caches behind the datalists have landed).
341+ await awaitRowCount ( 1 )
304342 }
305343 // Existing rows only — the trailing new-row draft has no Delete button.
306344 const locCells = ( ) =>
307345 page . locator ( 'tbody tr:has(button[title="Delete row"]) input[list="dl-holdings-location"]' )
308346 const commit = async ( input , value ) => {
309347 await input . fill ( value )
310- await input . blur ( ) // onchange → saveRow
311- await page . waitForTimeout ( 500 )
348+ // blur fires onchange → saveRow → PUT /api/lots/{id}; wait for that write to land,
349+ // not a fixed guess, before the caller re-reads the row from the API.
350+ const saved = page
351+ . waitForResponse ( ( r ) => r . request ( ) . method ( ) === 'PUT' && / \/ a p i \/ l o t s \/ / . test ( r . url ( ) ) , { timeout : 5000 } )
352+ . catch ( ( ) => { } )
353+ await input . blur ( )
354+ await saved
312355 }
313356
314357 await goHoldings ( )
@@ -445,6 +488,7 @@ try {
445488 // Bank, on a different grid: the branch you bought from must suggest itself back.
446489 await page . getByRole ( 'button' , { name : 'Roll txns' , exact : true } ) . click ( )
447490 await page . locator ( 'section table thead th' ) . first ( ) . waitFor ( { timeout : 5000 } )
491+ await awaitRowCount ( ( await api ( '/roll-txns' ) ) . length ) // wait for load() to fill the bank suggestions
448492 // id is derived from the grid TITLE ('Roll transactions'), not the tab label.
449493 const bankOpts = await dlOptions ( 'dl-roll-transactions-bank' )
450494 const usedBanks = [ ...new Set ( ( await api ( '/roll-txns' ) ) . map ( ( r ) => r . bank ) . filter ( Boolean ) ) ]
@@ -538,9 +582,12 @@ try {
538582 await page . evaluate ( ( ) => {
539583 document . querySelector ( 'section table' ) . parentElement . scrollTop = 15000
540584 } )
541- await page . waitForTimeout ( 800 )
585+ // Scrolling row 0 out of the window blurs its editor (commitIfLeaving) and unmounts it;
586+ // wait for the unmount, then for the blur-triggered PUT to land — not a fixed guess.
587+ await page . locator ( 'tbody tr[data-row-index="0"]' ) . waitFor ( { state : 'detached' , timeout : 5000 } ) . catch ( ( ) => { } )
542588 ok ( 'the edited row really did unmount (otherwise the guard is untested)' ,
543589 ( await page . locator ( 'tbody tr[data-row-index="0"]' ) . count ( ) ) === 0 )
590+ await awaitApi ( '/lots' , ( ls ) => ls . find ( ( l ) => l . id === editId ) ?. qty === 4242 )
544591 const afterScroll = ( await api ( '/lots' ) ) . find ( ( l ) => l . id === editId )
545592 ok ( 'an in-flight grid edit survives its row being virtualized away' ,
546593 afterScroll ?. qty === 4242 , `db qty ${ afterScroll ?. qty } (expected 4242)` )
@@ -579,20 +626,13 @@ try {
579626 }
580627 const confirmDlg = page . getByRole ( 'dialog' )
581628 const cancelBtn = ( ) => confirmDlg . getByRole ( 'button' , { name : 'Cancel' , exact : true } )
582- const awaitRowCount = ( n ) =>
583- page
584- . waitForFunction (
585- ( [ sel , want ] ) => document . querySelectorAll ( sel ) . length === want ,
586- [ supplyRowSel , n ] ,
587- { timeout : 5000 } ,
588- )
589- . catch ( ( ) => { } ) // let the ok() below report the miss, with numbers
590629
591630 // Wait for the async GET /api/supplies to populate the grid BEFORE counting — the
592631 // `thead th` we waited on renders before load() resolves, so a bare count here would
593- // race the fetch and could read 0 (the flake class tracked in om-yd1h). We seeded
594- // exactly two rows into this otherwise-untouched grid, so 2 is the settled count.
595- await awaitRowCount ( 2 )
632+ // race the fetch and could read 0 (the flake class this bead, om-yd1h, fixes). We seeded
633+ // exactly two rows into this otherwise-untouched grid, so 2 is the settled count. (Uses
634+ // the shared awaitRowCount, scoped to the Supplies delete-button rows.)
635+ await awaitRowCount ( 2 , { sel : supplyRowSel } )
596636 const rowsBefore = await supplyRows ( ) . count ( )
597637 ok ( 'fixture: both QA supply rows are in the grid' ,
598638 rowsBefore === 2 && ( await supplyItems ( ) ) . includes ( SUPPLY_GONE ) , `${ rowsBefore } row(s)` )
@@ -647,7 +687,7 @@ try {
647687 await confirmDlg . waitFor ( { timeout : 5000 } )
648688 await confirmDlg . getByRole ( 'button' , { name : 'Delete' , exact : true } ) . click ( )
649689 await confirmDlg . waitFor ( { state : 'detached' , timeout : 5000 } )
650- await awaitRowCount ( rowsBefore - 1 )
690+ await awaitRowCount ( rowsBefore - 1 , { sel : supplyRowSel , exact : true } ) // count DROPS — must be exact
651691 const rowsAfter = await supplyRows ( ) . count ( )
652692 const itemsAfter = await supplyItems ( )
653693 ok ( 'Confirm removes the row (grid)' , rowsAfter === rowsBefore - 1 ,
0 commit comments