diff --git a/web/src/lib/components/collections/BoardView.relationGroup.svelte.test.ts b/web/src/lib/components/collections/BoardView.relationGroup.svelte.test.ts index f9852bdee..d3eb881da 100644 --- a/web/src/lib/components/collections/BoardView.relationGroup.svelte.test.ts +++ b/web/src/lib/components/collections/BoardView.relationGroup.svelte.test.ts @@ -775,6 +775,68 @@ describe('a REFUSED grouping must not write a group value (U4, codex round 1 P5) expect(plus).toHaveLength(0); }); + it('offers no bulk "Move all to" either — the FIFTH group-writing affordance', async () => { + // Round 8 enumeration. Round 7 enumerated four affordances that write the + // group value and this was not among them: the lane menu reads + // `statusField.options` and offers them as destinations, so a `status` + // field retyped to `multi_relation` with its options retained offers + // named lanes that no longer exist and calls `onMoveColumn` with a + // SCALAR — a write the server refuses for a list-shaped field. + // + // The item's status is ABSENT on purpose: a card carrying an ARRAY there + // trips scalar status formatting and the board throws before rendering, + // which is its own finding and not this one. + const coll = collection(); + coll.schema = JSON.stringify({ + fields: [ + { + key: 'status', + label: 'Status', + type: 'multi_relation', + collection: 'colors', + options: ['old-a', 'old-b'], + }, + ], + }); + const onMoveColumn = vi.fn(); + const screen = render(BoardView, { + props: { + items: [{ ...item('car-1'), fields: JSON.stringify({}) } as Item], + collection: coll, + wsSlug: 'ws', + groupField: 'status', + onStatusChange: vi.fn(), + onMoveColumn, + // Supplied so the menu has an entry that is NOT gated on the + // refusal — the control that proves it opened. + onTagColumn: vi.fn(), + } as never, + }); + + // PRECONDITIONS: refused, one lane, one card, and the lane menu really is + // reachable — so "no Move all to" is not "no menu". + expect(screen.container.textContent).toContain('more than one group'); + expect(screen.container.querySelectorAll('.kanban-column')).toHaveLength(1); + expect(screen.container.querySelectorAll('.item-card')).toHaveLength(1); + const menuButton = [...screen.container.querySelectorAll('button')].find( + (b) => (b.textContent ?? '').trim() === '\u22ef', + ) as HTMLButtonElement | undefined; + expect(menuButton, 'no lane actions menu trigger — re-point this test').toBeTruthy(); + menuButton!.click(); + await tick(); + await tick(); + + // PRECONDITION FOR THE ABSENCE: the menu really opened. Without this the + // leg passes against a menu that never rendered, which is how its first + // version stayed green with the gate deleted. "Tag all" is present for + // every lane and is not gated on the refusal. + const menuEntries = [...document.querySelectorAll('button')].map((b) => (b.textContent ?? '').trim()); + expect(menuEntries.some((t) => t.includes('Tag all'))).toBe(true); + + expect(menuEntries.filter((t) => t.includes('Move all to'))).toHaveLength(0); + expect(onMoveColumn).not.toHaveBeenCalled(); + }); + it('CONTROL: an ordinary board still offers the lane create control', () => { // Withholding it everywhere would remove a working affordance from every // board on the instance. diff --git a/web/src/lib/components/collections/BoardView.svelte b/web/src/lib/components/collections/BoardView.svelte index 93edcafe7..1b4d2ec71 100644 --- a/web/src/lib/components/collections/BoardView.svelte +++ b/web/src/lib/components/collections/BoardView.svelte @@ -781,7 +781,15 @@ ? () => openDraft(colValue) : undefined} onArchive={onArchiveColumn ? () => onArchiveColumn?.(colItems) : undefined} - onMove={onMoveColumn ? (status) => onMoveColumn?.(colItems, status) : undefined} + onMove={/* The FIFTH affordance that writes the group value — + round 7 enumerated four and missed this one (round 8). + It offers `statusField.options` as destinations, so a + `status` field retyped to `multi_relation` with its + options retained offers lanes that do not exist and + sends a SCALAR into a list field. */ + onMoveColumn && !isRelationGroup && !groupingRefusal + ? (status) => onMoveColumn?.(colItems, status) + : undefined} onTag={onTagColumn ? (tag) => onTagColumn?.(colItems, tag) : undefined} onUntag={onUntagColumn ? (tag) => onUntagColumn?.(colItems, tag) : undefined} onSetPriority={onSetPriorityColumn ? (p) => onSetPriorityColumn?.(colItems, p) : undefined} diff --git a/web/src/lib/components/fields/FieldEditor.multiRelation.svelte.test.ts b/web/src/lib/components/fields/FieldEditor.multiRelation.svelte.test.ts index 27d9eda84..5105abee8 100644 --- a/web/src/lib/components/fields/FieldEditor.multiRelation.svelte.test.ts +++ b/web/src/lib/components/fields/FieldEditor.multiRelation.svelte.test.ts @@ -661,3 +661,120 @@ describe('multi_relation — the hold ends when the write does, not only when it expect(onchange.mock.calls[1][0]).toEqual([BLUE.id]); }); }); + +describe('multi_relation — what the round-8 enumeration found in the round-8 fix', () => { + const editable = { field: multi, wsSlug: 'ws', username: 'dave' } as const; + + async function settle() { + await tick(); + await tick(); + await tick(); + } + const titles = () => [...document.querySelectorAll('.relation-title')].map((n) => n.textContent); + + it('prop AGREEMENT does not release a write that reports its own settlement', async () => { + // Remove C, then add C straight back. The second write is holding + // [Green,Red,Blue] — which EQUALS the prop nobody has changed yet, so the + // agreement effect read it as "the server confirmed us" and released + // mid-write. The first write's answer then arrived as [Green,Red] and + // became the base, and the next removal sent a list with C missing. + // + // Agreement is a coincidence test; settlement is the answer. Whichever + // one is available, only one of them may own the release. + const gates: Array<() => void> = []; + // Each write answers when its gate is opened, and answering ALSO lands + // its own list on the prop — which is what the pane does, and what makes + // this leg discriminate. An earlier version left the prop alone; with it + // untouched, a released hold and a held one produce the same next write, + // so the guard's mutant survived. + const onchange = vi.fn( + (v: string[]) => + new Promise((resolve) => { + gates.push(() => { + rerender({ ...editable, value: v, onchange }); + resolve(); + }); + }), + ); + const { rerender } = render(FieldEditor, { + props: { ...editable, value: [GREEN.id, RED.id, BLUE.id], onchange }, + }); + await tick(); + + // Remove Blue → [Green,Red] sent. + await fireEvent.click(buttons(/^\s*Remove\s*$/)[2]); + await tick(); + expect(onchange.mock.calls[0][0]).toEqual([GREEN.id, RED.id]); + + // Add Blue back → [Green,Red,Blue] sent, which equals the untouched prop. + await fireEvent.click(buttons(/\+\s*Add/)[0]); + await tick(); + const blueRow = [...document.querySelectorAll('button')].find((b) => + (b.textContent ?? '').includes('Blue'), + ); + expect(blueRow, 'the picker offered no Blue row to choose').toBeTruthy(); + await fireEvent.click(blueRow!); + await tick(); + expect(onchange.mock.calls[1][0]).toEqual([GREEN.id, RED.id, BLUE.id]); + + // PRECONDITION: both writes are outstanding and the screen shows the + // second one's list. + expect(titles()).toEqual(['Green', 'Red', 'Blue']); + + // The FIRST write answers, putting ITS list — [Green,Red] — on the prop. + gates[0](); + await settle(); + + // The hold must still be the SECOND write's, so the screen keeps showing + // Blue and the next edit bases on the list the user is looking at. + expect(titles()).toEqual(['Green', 'Red', 'Blue']); + await fireEvent.click(buttons(/^\s*Remove\s*$/)[0]); + expect(onchange.mock.calls[2][0]).toEqual([RED.id, BLUE.id]); + }); + + it('a consumer that throws SYNCHRONOUSLY does not strand the hold', async () => { + // The settlement handler is installed AFTER `onchange` returns, so a + // consumer that throws on the way out armed a hold nothing could ever + // release — and every later edit took the abandoned list as its base. + const onchange = vi.fn(() => { + throw new Error('consumer blew up'); + }); + render(FieldEditor, { props: { ...editable, value: [GREEN.id, RED.id, BLUE.id], onchange } }); + await tick(); + + // The component RETHROWS (the error is the consumer's to report, not ours + // to swallow); where it surfaces from a Svelte event handler is the + // harness's business and is deliberately not asserted here. + await fireEvent.click(buttons(/^\s*Remove\s*$/)[0]); + await settle(); + + // PRECONDITION: it really was called, so "no hold" is not "no write". + expect(onchange).toHaveBeenCalledTimes(1); + // The prop never changed, and with no hold the field shows it again. + expect(titles()).toEqual(['Green', 'Red', 'Blue']); + }); + + it('a RETARGETED editor does not have its hold cleared by the new value', async () => { + // The agreement effect compared CONTENT and not identity, so an editor + // reused for another item whose value happened to equal the held list + // cleared a hold that was never about this field. The stamp exists for + // exactly this question and the effect was not asking it. + const onchange = vi.fn(); + const { rerender } = render(FieldEditor, { + props: { ...editable, value: [GREEN.id, RED.id, BLUE.id], onchange }, + }); + await tick(); + await fireEvent.click(buttons(/^\s*Remove\s*$/)[0]); + expect(onchange.mock.calls[0][0]).toEqual([RED.id, BLUE.id]); + + // Same instance, different workspace, and the new item's value is the + // list the OLD workspace's hold is carrying. + await rerender({ field: multi, wsSlug: 'other-ws', username: 'dave', value: [RED.id, BLUE.id], onchange }); + await tick(); + // Back again. The old hold must still be in force — it was never + // confirmed by anything. + await rerender({ ...editable, value: [GREEN.id, RED.id, BLUE.id], onchange }); + await tick(); + expect(titles()).toEqual(['Red', 'Blue']); + }); +}); diff --git a/web/src/lib/components/fields/FieldEditor.svelte b/web/src/lib/components/fields/FieldEditor.svelte index 99b0634cf..27d5022d0 100644 --- a/web/src/lib/components/fields/FieldEditor.svelte +++ b/web/src/lib/components/fields/FieldEditor.svelte @@ -176,7 +176,22 @@ handlers — onchange is never called. * `$state` rather than a plain `let` (CONVE-1688's split): `relationValues` * reads it, so it belongs in the effect graph. */ - let pendingRelation = $state<{ identity: string; list: string[] } | null>(null); + let pendingRelation = $state<{ + identity: string; + list: string[]; + /** + * True when the consumer gave a settlement signal, which then OWNS the + * release and prop agreement must keep out of it. + * + * Agreement is a coincidence test, not an answer: remove C from + * [A,B,C] and add it straight back, and the second write is holding + * [A,B,C] — equal to the prop nobody has changed yet. The effect below + * read that as "the server confirmed us", released mid-write, and the + * FIRST write's response then arrived as [A,B] and became the base. C + * was lost by the mechanism built to stop exactly that. + */ + tracked: boolean; + } | null>(null); /** * Tickets for the hold, so the SETTLEMENT of an older write cannot release a @@ -209,8 +224,21 @@ handlers — onchange is never called. function writeRelationList(next: string[]) { const identity = relationIdentity; const ticket = holdOrder.take(identity); - pendingRelation = { identity, list: next }; - const outcome = onchange(next); + let outcome: void | Promise; + try { + pendingRelation = { identity, list: next, tracked: false }; + outcome = onchange(next); + } catch (err) { + // A consumer that throws SYNCHRONOUSLY never reaches the settlement + // path below, so the hold it just armed would stand forever and + // every later edit would base on the abandoned list. Release and + // rethrow: the throw is the consumer's to report, the stranded hold + // was ours. + if (pendingRelation?.identity === identity && !holdOrder.superseded(ticket)) { + pendingRelation = null; + } + throw err; + } // A consumer that returns nothing has told us nothing, and the hold falls // back to prop agreement (below). One that returns a promise is telling // us when its write SETTLED, which is the question the hold is actually @@ -219,6 +247,8 @@ handlers — onchange is never called. // rejected list on screen forever and ignored every later server value. const settlement = settlementOf(outcome); if (!settlement) return; + // Settlement owns the release from here — see `tracked`. + pendingRelation = { identity, list: next, tracked: true }; void (async () => { try { await settlement; @@ -255,6 +285,11 @@ handlers — onchange is never called. $effect(() => { const pending = pendingRelation; if (!pending) return; + // Not for a write that reports its own settlement, and not for a hold + // belonging to a DIFFERENT identity: a retargeted editor whose new value + // happens to equal the old one's held list would otherwise clear a hold + // that was never about this field. + if (pending.tracked || pending.identity !== relationIdentity) return; const incoming = Array.isArray(value) ? value.map((e) => (typeof e === 'string' ? e.trim() : '')).filter((e) => e !== '') : []; diff --git a/web/src/lib/components/items/ItemDetail.svelte b/web/src/lib/components/items/ItemDetail.svelte index 9636a759c..def26074b 100644 --- a/web/src/lib/components/items/ItemDetail.svelte +++ b/web/src/lib/components/items/ItemDetail.svelte @@ -2986,6 +2986,13 @@ try { forced = await confirmOpenChildrenOrThrow(e, parentRef, () => { if (!stillCurrent()) throw new Error('switched away'); + // The dialog can sit open for as long as the user takes, + // and this callback SENDS. Checking supersession only + // after it returns suppresses the display of a write that + // has already gone to the server — the retry guard's own + // rule, at the one door that reaches the network without + // passing through it. + if (fieldWrites.superseded(ticket)) throw new Error('superseded'); return submitWithOCC(true); }); } catch (retryErr) { @@ -3006,8 +3013,14 @@ // Switched items while the confirm modal was up — complete // silently rather than stamping A's result onto B. if (!stillCurrent() || !item) return; - if (fieldWrites.superseded(ticket)) return; if (forced) { + // Gated on CLAIM alone, never on supersession. A forced write + // that SUCCEEDED has changed the row, and the newer write may + // have failed without writing anything — discarding this + // response on the strength of a dispatch would then leave the + // server holding the confirmed value and the pane showing the + // old one, with nothing left to correct it. `claim` asks the + // question that matters: has anything newer actually written? if (!fieldWrites.claim(ticket)) return; item = withInflightTags(forced); showSaved(); @@ -3019,8 +3032,15 @@ // the in-flight save indicator. Force `item` to a fresh // reference so child components re-prop unambiguously // even if they cache by identity. - if (!fieldWrites.claim(ticket)) return; - saveStatus = 'idle'; + // NO claim here, and the reason is worth stating because the + // first version of this change had one. Declining writes NOTHING + // to the server: this re-props the client value it already had. + // Claiming advanced the applied mark all the same, so a write + // that had genuinely COMMITTED and was still in flight lost its + // claim on return — server changed, pane showing the old value, + // and nothing left to correct it. A branch that asserts no new + // server truth must not take the mark for one. + if (!fieldWrites.superseded(ticket)) saveStatus = 'idle'; item = { ...item }; toastStore.show('Status change cancelled', 'info'); return; diff --git a/web/src/lib/components/items/itemDetailOrdersFieldWrites.test.ts b/web/src/lib/components/items/itemDetailOrdersFieldWrites.test.ts index 3a5f80f47..b4e31cfbb 100644 --- a/web/src/lib/components/items/itemDetailOrdersFieldWrites.test.ts +++ b/web/src/lib/components/items/itemDetailOrdersFieldWrites.test.ts @@ -112,4 +112,84 @@ describe('ItemDetail field writes are ordered', () => { expect(between, `await between claim and ${where}`).not.toMatch(/\bawait\b/); } }); + + it('every claim is a GATE — the answer is acted on, never discarded', () => { + // Round 8 enumeration. The leg above matched `fieldWrites.claim(ticket)` + // anywhere, so replacing `if (!fieldWrites.claim(ticket)) return;` with a + // bare `fieldWrites.claim(ticket);` kept the proximity and no-await + // assertions green while every response was assigned regardless of the + // answer. A call whose result nothing reads is not a guard. + const calls = [...UPDATE_FIELD.matchAll(/fieldWrites\.claim\(ticket\)/g)]; + expect(calls.length).toBeGreaterThan(0); + for (const m of calls) { + const line = UPDATE_FIELD.slice( + UPDATE_FIELD.lastIndexOf('\n', m.index ?? 0) + 1, + UPDATE_FIELD.indexOf('\n', m.index ?? 0), + ).trim(); + expect(line, `claim result discarded: ${line}`).toBe('if (!fieldWrites.claim(ticket)) return;'); + } + }); + + it('the forced-write success is gated on CLAIM and never on supersession', () => { + // Round 8 enumeration, and a defect this unit's own first version + // introduced. A forced write that SUCCEEDED has changed the row; if the + // newer write then failed without writing, returning on supersession + // leaves the server holding the confirmed value and the pane showing the + // old one, with nothing left to correct it. `claim` asks whether anything + // newer actually WROTE, which is the question that matters here. + // Bounded at the branch's own `showSaved()`, not at the next landmark: + // the DECLINED branch below it legitimately mentions supersession (for + // the save indicator), and a slice running into it reports that as a + // violation here. + // The window starts where the dialog RETURNS, not at `if (forced) {`: + // a supersession check placed just above that line returns before the + // forced branch is ever reached, which is the defect itself. A slice + // anchored on the branch would miss it — measured, it survived. + const windowAt = UPDATE_FIELD.indexOf('if (!stillCurrent() || !item) return;'); + const forcedAt = UPDATE_FIELD.indexOf('if (forced) {', windowAt); + const savedAt = UPDATE_FIELD.indexOf('showSaved();', forcedAt); + expect(windowAt).toBeGreaterThan(-1); + expect(forcedAt).toBeGreaterThan(windowAt); + expect(savedAt).toBeGreaterThan(forcedAt); + const forcedBranch = UPDATE_FIELD.slice(windowAt, savedAt); + expect(forcedBranch).toContain('if (!fieldWrites.claim(ticket)) return;'); + expect(forcedBranch).not.toMatch(/fieldWrites\.superseded\(ticket\)/); + }); + + it('the DECLINED branch takes no claim — it writes no server truth', () => { + // The other half of the same defect. Declining writes nothing: it re-props + // the client value it already had. Claiming advanced the applied mark all + // the same, so a write that had genuinely COMMITTED and was still in + // flight lost its claim on return. A branch that asserts no new server + // truth must not take the mark for one. + const declined = UPDATE_FIELD.slice( + UPDATE_FIELD.indexOf('if (forced) {'), + UPDATE_FIELD.indexOf('BUG-2273: OCC retries were exhausted') > -1 + ? UPDATE_FIELD.indexOf('if (isUpdateConflictError(e)) {') + : UPDATE_FIELD.length, + ); + const afterForced = declined.slice(declined.indexOf('toastStore.show(\'Status change cancelled\'')); + expect(declined).toContain("toastStore.show('Status change cancelled'"); + // Between the end of the forced branch and the cancel toast there is no + // claim — only the saveStatus guard. + const cancelAt = declined.indexOf("toastStore.show('Status change cancelled'"); + const declinedBody = declined.slice(declined.indexOf('}', declined.indexOf('showSaved();')), cancelAt); + expect(declinedBody).not.toContain('fieldWrites.claim(ticket)'); + expect(afterForced.length).toBeGreaterThan(0); + }); + + it('the confirm callback asks about supersession BEFORE it sends', () => { + // The dialog can sit open for as long as the user takes, and this + // callback SENDS. Checking only after it returns suppresses the DISPLAY + // of a write that has already reached the server (round 8 enumeration). + const callback = UPDATE_FIELD.slice( + UPDATE_FIELD.indexOf('confirmOpenChildrenOrThrow('), + UPDATE_FIELD.indexOf('} catch (retryErr) {'), + ); + const guardAt = callback.indexOf('fieldWrites.superseded(ticket)'); + const sendAt = callback.indexOf('submitWithOCC(true)'); + expect(guardAt).toBeGreaterThan(-1); + expect(sendAt).toBeGreaterThan(-1); + expect(guardAt).toBeLessThan(sendAt); + }); }); diff --git a/web/src/lib/items/fieldWriteOrder.test.ts b/web/src/lib/items/fieldWriteOrder.test.ts index 266501119..95fa82719 100644 --- a/web/src/lib/items/fieldWriteOrder.test.ts +++ b/web/src/lib/items/fieldWriteOrder.test.ts @@ -31,8 +31,13 @@ class FakeRow { value: string[] = ['A', 'B', 'C']; updated_at = 't0'; private n = 0; + /** The last conflict thrown, so a test can assert the SAME object came back. */ + lastConflict: Conflict | null = null; patch(expected: string, next: string[]): Row { - if (expected !== this.updated_at) throw new Conflict('update_conflict'); + if (expected !== this.updated_at) { + this.lastConflict = new Conflict('update_conflict'); + throw this.lastConflict; + } this.value = [...next]; this.updated_at = `t${++this.n}`; return this.read(); @@ -155,10 +160,13 @@ describe('submitOrderedOCC — the retry never replays a superseded body', () => expect(row.value).toEqual(['C']); expect(outcome.ok).toBe(false); - // It fails with the CONFLICT it was given, not a synthesised error: the - // caller tells a superseded write apart by asking the same question this - // did, and dressing it up as something else would make that impossible. - expect(outcome.ok === false && outcome.e).toBeInstanceOf(Conflict); + // It fails with the CONFLICT it was given — the same object, not merely + // something of the same class. The caller tells a superseded write apart + // by asking the question this asked, and a synthesised error of the + // right shape would satisfy an `instanceof` while losing whatever the + // server said (round 8 enumeration: these assertions checked class and + // message, so swapping in a different Conflict stayed green). + expect(outcome.ok === false && outcome.e).toBe(row.lastConflict); }); it('NEGATIVE CONTROL: with no supersession question at all, A comes back', async () => { @@ -201,6 +209,14 @@ describe('submitOrderedOCC — the retry never replays a superseded body', () => // The other half of the control pair: the guard must not have turned the // OCC retry off. Somebody ELSE moves the row, and this write re-applies // its own value against the fresh one, which is BUG-2273 working. + // + // AND IT DESCRIBES A KNOWN LOSS, deliberately. The third party added D; + // the retry re-sends the list it computed BEFORE that, so D disappears + // although the local gesture only removed A. This leg asserts what the + // code does, not what it should do — closing that needs the retry to + // re-apply the GESTURE to the fresh list rather than replay its result, + // which is a write-contract change and is filed as BUG-3038. Invert this + // assertion when that lands; it is not an endorsement. const row = new FakeRow(); const order = new WriteOrder(); const ticket = order.take(TARGET); @@ -277,6 +293,47 @@ describe('submitOrderedOCC — the retry never replays a superseded body', () => expect(sends).toBe(3); // the first send plus two retries }); + it('does not RE-SEND once the view moves on DURING the refetch', async () => { + // Round 8 enumeration, reproduced. The post-refetch check asked only + // about supersession, so switching items while the GET was in flight + // still produced another PATCH at the item the pane had just left — + // invisible, because the caller then discards the response, and landing + // on the row all the same. + // + // This is the transition the leg below cannot see: there `stillCurrent` + // is false from the start, so the retry never begins. + const row = new FakeRow(); + const order = new WriteOrder(); + const ticket = order.take(TARGET); + row.patch('t0', ['A', 'B', 'C', 'D']); // a third party moved it first + let current = true; + let sends = 0; + + await expect( + submitOrderedOCC({ + order, + ticket, + maxRetries: 2, + initialExpected: 't0', + send: (expected) => { + sends++; + return Promise.resolve(row.patch(expected, ['B', 'C'])); + }, + refetch: () => { + current = false; // the user opens another item, right here + return Promise.resolve(row.read()); + }, + isConflict, + stillCurrent: () => current + }) + ).rejects.toBeInstanceOf(Conflict); + + // PRECONDITION: the first send really happened, so "one send" is a + // refusal to RETRY rather than a refusal to start. + expect(sends).toBe(1); + expect(row.value).toEqual(['A', 'B', 'C', 'D']); + }); + it('does not retry once the view has moved on', async () => { const row = new FakeRow(); const order = new WriteOrder(); @@ -303,6 +360,9 @@ describe('submitOrderedOCC — the retry never replays a superseded body', () => const order = new WriteOrder(); const ticket = order.take(TARGET); const refetch = vi.fn(() => Promise.resolve({ value: [], updated_at: 't9' })); + // By IDENTITY: `rejects.toThrow('network')` passes against any error + // carrying that word, including one this function made up. + const failure = new Error('network'); await expect( submitOrderedOCC({ @@ -310,12 +370,12 @@ describe('submitOrderedOCC — the retry never replays a superseded body', () => ticket, maxRetries: 2, initialExpected: 't0', - send: () => Promise.reject(new Error('network')), + send: () => Promise.reject(failure), refetch, isConflict, stillCurrent: () => true }) - ).rejects.toThrow('network'); + ).rejects.toBe(failure); expect(refetch).not.toHaveBeenCalled(); }); diff --git a/web/src/lib/items/fieldWriteOrder.ts b/web/src/lib/items/fieldWriteOrder.ts index 271c409e4..6f3cd0371 100644 Binary files a/web/src/lib/items/fieldWriteOrder.ts and b/web/src/lib/items/fieldWriteOrder.ts differ