diff --git a/src/components/features/debate/DebateContextControl.tsx b/src/components/features/debate/DebateContextControl.tsx index e9e2b0c08..19af42a1b 100644 --- a/src/components/features/debate/DebateContextControl.tsx +++ b/src/components/features/debate/DebateContextControl.tsx @@ -9,6 +9,8 @@ import { useDebateContextPlan } from '@/lib/debate/context-state'; import type { ContextOutputItem, ContextSourceItem, DebateContextPlan } from '@/lib/debate/context'; import { useCurrentDebate, useDebateStore } from '@/lib/stores/debate-store'; import { outputKey, useDebateContextStore, type DebateContextUsage } from '@/lib/stores/debate-context-store'; +import { useSessionStore } from '@/lib/stores/session-store'; +import { useSparringStore } from '@/lib/stores/sparring-store'; import type { DebateContextFeature, DebateContextSide } from '../../../../supabase/functions/_shared/debate-context'; interface DebateContextControlProps { @@ -42,6 +44,12 @@ export function DebateContextControl({ feature, side, includeSources = true, deb const debateId = debateIdOverride !== undefined ? debateIdOverride : (oneOff ? null : debate?.id ?? null); const plan = useDebateContextPlan(feature, side, debateId, { includeSources }); const pinsLoaded = useDebateContextStore((state) => (debateId ? state.pinsLoaded[debateId] : true)); + // The pin limit discounts pins on results that are not listed. Until saved + // results have loaded, a missing result may just not be here yet; if a list + // failed to load, every pin counts. + const serverHydration = useSessionStore((state) => state.serverHydration); + const sparringHydrated = useSparringStore((state) => state.hydrated); + const sparringComplete = useSparringStore((state) => state.historyComplete); const loadPins = useDebateContextStore((state) => state.loadPins); const usage = useDebateContextStore((state) => state.lastUsage[feature]); const [open, setOpen] = useState(false); @@ -99,14 +107,26 @@ export function DebateContextControl({ feature, side, includeSources = true, deb - + ); } -function DebateContextReview({ plan, motion, usage }: { plan: DebateContextPlan; motion?: string; usage?: DebateContextUsage }) { +function DebateContextReview({ plan, motion, usage, pinsReady, resultsComplete }: { + plan: DebateContextPlan; + motion?: string; + usage?: DebateContextUsage; + pinsReady: boolean; + resultsComplete: boolean; +}) { const { t } = useTranslation(); const setSourceChoice = useDebateContextStore((state) => state.setSourceChoice); const setOutputChoice = useDebateContextStore((state) => state.setOutputChoice); @@ -116,7 +136,7 @@ function DebateContextReview({ plan, motion, usage }: { plan: DebateContextPlan; const onPin = async (output: ContextOutputItem) => { try { - await togglePin(plan.debateId, output.ref); + await togglePin(plan.debateId, output.ref, resultsComplete ? plan.resultKeys : undefined); } catch (error) { toast({ title: error instanceof Error && error.message === 'pin_limit' @@ -180,6 +200,7 @@ function DebateContextReview({ plan, motion, usage }: { plan: DebateContextPlan; output={output} onChange={(include) => setOutputChoice(plan.feature, plan.debateId, output.ref, include)} onPin={() => void onPin(output)} + pinDisabled={!pinsReady} /> ))} @@ -221,7 +242,7 @@ function SourceRow({ source, onChange }: { source: ContextSourceItem; onChange: ); } -function OutputRow({ output, onChange, onPin }: { output: ContextOutputItem; onChange: (include: boolean) => void; onPin: () => void }) { +function OutputRow({ output, onChange, onPin, pinDisabled }: { output: ContextOutputItem; onChange: (include: boolean) => void; onPin: () => void; pinDisabled: boolean }) { const { t } = useTranslation(); const id = `debate-context-output-${output.ref.kind}-${output.ref.id}`; const note = output.included ? statusNote(output, t) : null; @@ -247,6 +268,7 @@ function OutputRow({ output, onChange, onPin }: { output: ContextOutputItem; onC ? t('debateContext.unpinAria', { title: output.title || kind }) : t('debateContext.pinAria', { title: output.title || kind })} onClick={onPin} + disabled={pinDisabled} > {output.pinned ? : } diff --git a/src/lib/debate/context.ts b/src/lib/debate/context.ts index 4cdfd36da..fecb09031 100644 --- a/src/lib/debate/context.ts +++ b/src/lib/debate/context.ts @@ -64,6 +64,8 @@ export interface DebateContextPlan { policy: DebateContextPolicy; sources: ContextSourceItem[]; outputs: ContextOutputItem[]; + /** Every result in this debate, the open Ask chat included; pins on anything else no longer count. */ + resultKeys: ReadonlySet; request: DebateContextRequest; } @@ -153,6 +155,10 @@ export function planDebateContext( const pinnedKeys = new Set(inputs.pins.map(outputKey)); const candidates = policy.outputs ? outputCandidates(debateId, inputs) : []; + // A pin on a result that isn't loaded here, because it was deleted or is + // older than local history keeps, is never shown or sent. It must not use up + // the limit, or the student could never free that slot. + const resultKeys = new Set(outputCandidates(debateId, { ...inputs, excludeChatId: null }).map((item) => outputKey(item.ref))); const defaults = selectDefaultOutputs( candidates.map((item) => ({ ...item.ref, side: item.side, createdAt: item.createdAt, pinned: pinnedKeys.has(outputKey(item.ref)) })), feature, @@ -194,6 +200,7 @@ export function planDebateContext( policy, sources, outputs, + resultKeys, request: { debateId, side, diff --git a/src/lib/stores/debate-context-store.ts b/src/lib/stores/debate-context-store.ts index 33846be2e..ea743827d 100644 --- a/src/lib/stores/debate-context-store.ts +++ b/src/lib/stores/debate-context-store.ts @@ -40,7 +40,8 @@ interface DebateContextState { lastUsage: Partial>; loadPins: (debateId: string) => Promise; - togglePin: (debateId: string, ref: DebateOutputRef) => Promise; + /** Only pins on `resultKeys`, the debate's existing results, count toward the limit. */ + togglePin: (debateId: string, ref: DebateOutputRef, resultKeys?: ReadonlySet) => Promise; setSourceChoice: (feature: DebateContextFeature, debateId: string, sourceId: string, include: boolean) => void; setOutputChoice: (feature: DebateContextFeature, debateId: string, ref: DebateOutputRef, include: boolean) => void; resetOverride: (feature: DebateContextFeature) => void; @@ -52,9 +53,20 @@ function ensureOverride(current: DebateContextOverride | undefined, debateId: st return current && current.debateId === debateId ? current : { debateId, sources: {}, outputs: {} }; } -// Counts pin changes per debate, so a load that started before a change -// cannot bring back what the student just pinned or unpinned. -const pinChanges: Record = {}; +// Every local pin change, per debate and in order. A load replays the changes +// it may have missed: ones made after it started, and ones that were still +// saving at any point during the load, because its read may predate them. +// `before` is the result's state before this change; if the change fails, the +// result goes back to it. +interface PinChange { seq: number; ref: DebateOutputRef; pinned: boolean; before: boolean; saving: boolean } +const pinChanges: Record = {}; +let pinSeq = 0; +// Each debate's pin writes run one at a time, so the database ends in the +// same state as the screen even after a quick pin and unpin. +const pinWrites: Record> = {}; +// Bumped when local data is reset on sign-out. Pin work started before that +// belongs to the previous account, so it must not write or change state. +let pinGeneration = 0; function withPin(pins: DebateOutputRef[], ref: DebateOutputRef, pinned: boolean): DebateOutputRef[] { const without = pins.filter((item) => outputKey(item) !== outputKey(ref)); @@ -68,44 +80,73 @@ export const useDebateContextStore = create()((set, get) => lastUsage: {}, loadPins: async (debateId) => { - const changesBefore = pinChanges[debateId] ?? 0; + const startedAt = pinSeq; + const generation = pinGeneration; + const savingAtStart = new Set((pinChanges[debateId] ?? []).filter((change) => change.saving)); const { data, error } = await supabase .from('debate_context_pins') .select('item_kind,item_id') .eq('debate_id', debateId); + if (generation !== pinGeneration) return; // Pins are an optional preference. Until the table exists everywhere, or // if it cannot be read, requests fall back to the automatic selection. - if (error || (pinChanges[debateId] ?? 0) !== changesBefore) { + if (error) { set((state) => ({ pinsLoaded: { ...state.pinsLoaded, [debateId]: true } })); return; } - const pins = ((data ?? []) as Array<{ item_kind: unknown; item_id: unknown }>).flatMap((row) => + let pins = ((data ?? []) as Array<{ item_kind: unknown; item_id: unknown }>).flatMap((row) => isDebateContextOutputKind(row.item_kind) && typeof row.item_id === 'string' ? [{ kind: row.item_kind as DebateContextOutputKind, id: row.item_id }] : []); + for (const change of pinChanges[debateId] ?? []) { + if (change.seq > startedAt || change.saving || savingAtStart.has(change)) pins = withPin(pins, change.ref, change.pinned); + } set((state) => ({ pins: { ...state.pins, [debateId]: pins }, pinsLoaded: { ...state.pinsLoaded, [debateId]: true }, })); }, - togglePin: async (debateId, ref) => { + togglePin: async (debateId, ref, resultKeys) => { const current = get().pins[debateId] ?? []; const pinned = current.some((item) => outputKey(item) === outputKey(ref)); - if (!pinned && current.length >= MAX_PINNED_OUTPUTS) throw new Error('pin_limit'); - pinChanges[debateId] = (pinChanges[debateId] ?? 0) + 1; - set((state) => ({ pins: { ...state.pins, [debateId]: withPin(current, ref, !pinned) } })); - const { data } = await supabase.auth.getUser(); - const userId = data.user?.id; - const result = !userId - ? { error: { message: 'Sign in to pin results' } } - : pinned + const counted = resultKeys ? current.filter((item) => resultKeys.has(outputKey(item))) : current; + if (!pinned && counted.length >= MAX_PINNED_OUTPUTS) throw new Error('pin_limit'); + const generation = pinGeneration; + const change: PinChange = { seq: ++pinSeq, ref, pinned: !pinned, before: pinned, saving: true }; + (pinChanges[debateId] ??= []).push(change); + set((state) => ({ pins: { ...state.pins, [debateId]: withPin(state.pins[debateId] ?? [], ref, !pinned) } })); + const save = async (): Promise<{ error: { message?: string; code?: string } | null }> => { + if (generation !== pinGeneration) return { error: null }; + const { data } = await supabase.auth.getUser(); + const userId = data.user?.id; + if (generation !== pinGeneration) return { error: null }; + if (!userId) return { error: { message: 'Sign in to pin results' } }; + return pinned ? await supabase.from('debate_context_pins').delete() .eq('user_id', userId).eq('debate_id', debateId).eq('item_kind', ref.kind).eq('item_id', ref.id) : await supabase.from('debate_context_pins').insert({ user_id: userId, debate_id: debateId, item_kind: ref.kind, item_id: ref.id }); + }; + const write = (pinWrites[debateId] ?? Promise.resolve()).then(save); + pinWrites[debateId] = write.catch((): void => undefined); + const result: Awaited> = await write + .catch((error: unknown) => ({ error: { message: error instanceof Error ? error.message : undefined } })); + change.saving = false; + if (generation !== pinGeneration) return; + // The row already exists, so the result is pinned as asked. + if (change.pinned && result.error?.code === '23505') return; if (result.error) { - // Undo only this change; other pins may have changed in the meantime. - set((state) => ({ pins: { ...state.pins, [debateId]: withPin(state.pins[debateId] ?? [], ref, pinned) } })); + // Undo only this change; other pins may have changed in the meantime. A + // later change to the same result is queued behind this one and decides + // its final state. It assumed this change was saved, so if it fails too + // it must go back to the state before this one. + const next = pinChanges[debateId].find((item) => item.seq > change.seq && outputKey(item.ref) === outputKey(ref)); + pinChanges[debateId] = pinChanges[debateId].filter((item) => item !== change); + if (next) { + next.before = change.before; + return; + } + set((state) => ({ pins: { ...state.pins, [debateId]: withPin(state.pins[debateId] ?? [], ref, change.before) } })); throw new Error(result.error.message ?? 'Could not save the pin'); } }, @@ -134,7 +175,12 @@ export const useDebateContextStore = create()((set, get) => return { lastUsage }; }), - resetLocalData: () => set({ pins: {}, pinsLoaded: {}, overrides: {}, lastUsage: {} }), + resetLocalData: () => { + pinGeneration += 1; + for (const debateId of Object.keys(pinChanges)) delete pinChanges[debateId]; + for (const debateId of Object.keys(pinWrites)) delete pinWrites[debateId]; + set({ pins: {}, pinsLoaded: {}, overrides: {}, lastUsage: {} }); + }, })); /** Reads the server's `meta.debateContext`, ignoring anything malformed. */ diff --git a/src/lib/stores/session-store.ts b/src/lib/stores/session-store.ts index abea0a433..436398935 100644 --- a/src/lib/stores/session-store.ts +++ b/src/lib/stores/session-store.ts @@ -217,6 +217,11 @@ export interface SessionState { clearLibrary: () => Promise; hydrateFromServer: () => Promise; + /** + * This page load's server hydration: `partial` when a saved-result list + * failed to load and may be missing results. Not persisted. + */ + serverHydration: 'pending' | 'partial' | 'complete'; clearAllData: () => Promise; resetLocalData: () => void; clearSession: () => void; @@ -484,6 +489,7 @@ export const useSessionStore = create()( setFactoidsForm: (data) => set((state) => ({ factoidsForm: mergeFormState(state.factoidsForm, data) })), pendingFlowchartData: null as FlowchartData | null, + serverHydration: 'pending', setPendingFlowchartData: (data) => set({ pendingFlowchartData: data }), addSource: async (source) => { @@ -1173,6 +1179,7 @@ export const useSessionStore = create()( savedFlowcharts: flowchartsData ? serverFlowcharts : state.savedFlowcharts, sourceFolders: mergeSourceFolders([...(sourceFoldersData ? [] : state.sourceFolders), ...serverSourceFolders], sourcesData ? serverSources : state.sourceLibrary), sourceLibrary: sourcesData ? serverSources : state.sourceLibrary, + serverHydration: casesData && attacksData && brainstormsData && factoidsData && chatsData && crossfiresData ? 'complete' : 'partial', })); }, @@ -1207,6 +1214,7 @@ export const useSessionStore = create()( }); }, resetLocalData: () => set({ + serverHydration: 'pending', savedCases: [], savedAttacks: [], savedBrainstorms: [], @@ -1273,6 +1281,7 @@ export const useSessionStore = create()( askLoading, askStreamingAssistant, pendingFlowchartData, + serverHydration, ...persisted }) => persisted, } diff --git a/src/lib/stores/sparring-store.ts b/src/lib/stores/sparring-store.ts index 045581514..1ecf0dcf9 100644 --- a/src/lib/stores/sparring-store.ts +++ b/src/lib/stores/sparring-store.ts @@ -55,6 +55,8 @@ interface SparringStoreState { privateSessionBackup: SparringSession | null; savedSessions: SparringSession[]; hydrated: boolean; + /** False until saved history has loaded successfully; hydration can finish without it. */ + historyComplete: boolean; beginWorkspaceSession: () => void; endWorkspaceSession: () => void; startSession: (input: StartSessionInput) => SparringSession; @@ -300,6 +302,7 @@ export const useSparringStore = create()( privateSessionBackup: null as SparringSession | null, savedSessions: [] as SparringSession[], hydrated: false, + historyComplete: false, beginWorkspaceSession: () => set((state) => ({ currentSession: null, @@ -462,7 +465,7 @@ export const useSparringStore = create()( prepareForUser: (userId) => { if (get().ownerUserId === userId) return; hydrationEpoch += 1; - set({ ownerUserId: userId, currentSession: null, privateSessionBackup: null, savedSessions: [], hydrated: false }); + set({ ownerUserId: userId, currentSession: null, privateSessionBackup: null, savedSessions: [], hydrated: false, historyComplete: false }); }, hydrateSavedSessions: async () => { @@ -484,7 +487,7 @@ export const useSparringStore = create()( set({ hydrated: true }); return; } - set((state) => ({ savedSessions: mergeSessions(state.savedSessions, parseSavedRows(data)), hydrated: true })); + set((state) => ({ savedSessions: mergeSessions(state.savedSessions, parseSavedRows(data)), hydrated: true, historyComplete: true })); }, loadSavedSession: (id) => { @@ -511,7 +514,7 @@ export const useSparringStore = create()( }, resetLocalData: () => { hydrationEpoch += 1; - set({ ownerUserId: null, currentSession: null, privateSessionBackup: null, savedSessions: [], hydrated: false }); + set({ ownerUserId: null, currentSession: null, privateSessionBackup: null, savedSessions: [], hydrated: false, historyComplete: false }); }, }), { diff --git a/src/test/debate/debate-context.test.tsx b/src/test/debate/debate-context.test.tsx index 90917d992..b10e29126 100644 --- a/src/test/debate/debate-context.test.tsx +++ b/src/test/debate/debate-context.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { resetMockState, setMockInvokeResponse, setMockSessionToken, supabaseMock } from '@/test/mocks/supabase'; vi.mock('@/lib/supabase/client', async () => { @@ -19,6 +19,7 @@ import { brainstorm } from '@/lib/ai/client'; import { useDebateContextStore } from '@/lib/stores/debate-context-store'; import { useDebateStore } from '@/lib/stores/debate-store'; import { useSessionStore, type SavedItem, type Source } from '@/lib/stores/session-store'; +import { useSparringStore } from '@/lib/stores/sparring-store'; import { enableOneOffMode, resetDebates, seedCurrentDebate, TEST_DEBATE_ID } from '@/test/helpers/debate'; const OTHER_DEBATE = '99999999-2222-4333-8444-555555555555'; @@ -95,6 +96,16 @@ describe('Current Debate context plan', () => { const plan = planDebateContext('ask', 'both', TEST_DEBATE_ID, inputs({ pins: [{ kind: 'chat', id: uuid(20) }], excludeChatId: uuid(20) })); expect(plan.outputs.map((item) => item.ref.id)).not.toContain(uuid(20)); }); + + it('lists every result in the debate for the pin limit, including the chat open in Ask', () => { + const plan = planDebateContext('ask', 'both', TEST_DEBATE_ID, inputs({ + pins: [{ kind: 'case', id: uuid(10) }, { kind: 'chat', id: uuid(20) }, { kind: 'case', id: uuid(40) }], + excludeChatId: uuid(20), + })); + expect(plan.resultKeys.has(`case:${uuid(10)}`)).toBe(true); + expect(plan.resultKeys.has(`chat:${uuid(20)}`)).toBe(true); + expect(plan.resultKeys.has(`case:${uuid(40)}`)).toBe(false); + }); }); describe('Current Debate context in requests and the Review control', () => { @@ -107,7 +118,9 @@ describe('Current Debate context in requests and the Review control', () => { sourceLibrary: [source(1), source(2, { isActive: false })], savedCases: [result(10, caseData) as SavedItem], savedAttacks: [], savedBrainstorms: [], savedFactoids: [], savedChats: [], savedCrossfires: [], + serverHydration: 'complete', }); + useSparringStore.setState({ hydrated: true, historyComplete: true }); }); afterEach(() => { resetDebates(); @@ -178,6 +191,46 @@ describe('Current Debate context in requests and the Review control', () => { await waitFor(() => expect(control).toHaveTextContent('Using 2 sources and 1 saved result')); expect(within(sheet).getByRole('button', { name: /Pin Result 10/ })).toHaveAttribute('aria-pressed', 'false'); }); + + it('keeps pin buttons disabled until saved results have loaded', async () => { + // Pins on results that have not loaded yet cannot be counted toward the limit. + seedCurrentDebate(); + useSessionStore.setState({ serverHydration: 'pending' }); + useSparringStore.setState({ hydrated: false, historyComplete: false }); + render(); + fireEvent.click(within(screen.getByTestId('debate-context-control')).getByRole('button', { name: 'Review' })); + const sheet = await screen.findByRole('dialog'); + await waitFor(() => expect(useDebateContextStore.getState().pinsLoaded[TEST_DEBATE_ID]).toBe(true)); + const pin = within(sheet).getByRole('button', { name: /Pin Result 10/ }); + + expect(pin).toBeDisabled(); + act(() => useSessionStore.setState({ serverHydration: 'complete' })); + expect(pin).toBeDisabled(); + act(() => useSparringStore.setState({ hydrated: true, historyComplete: true })); + expect(pin).toBeEnabled(); + }); + + it.each([ + ['a saved-result query', () => useSessionStore.setState({ serverHydration: 'partial' })], + ['the sparring history query', () => useSparringStore.setState({ historyComplete: false })], + ])('counts every pin toward the limit when %s failed', async (_name, failHydration) => { + // A result missing because its list failed to load may still exist. + seedCurrentDebate(); + failHydration(); + render(); + fireEvent.click(within(screen.getByTestId('debate-context-control')).getByRole('button', { name: 'Review' })); + const sheet = await screen.findByRole('dialog'); + await waitFor(() => expect(useDebateContextStore.getState().pinsLoaded[TEST_DEBATE_ID]).toBe(true)); + const unlisted = [40, 41, 42].map((n) => ({ kind: 'case' as const, id: uuid(n) })); + act(() => useDebateContextStore.setState((state) => ({ pins: { ...state.pins, [TEST_DEBATE_ID]: unlisted } }))); + + const pin = within(sheet).getByRole('button', { name: /Pin Result 10/ }); + expect(pin).toBeEnabled(); + fireEvent.click(pin); + await new Promise((done) => setTimeout(done, 0)); + + expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual(unlisted); + }); }); describe('Pinned results', () => { @@ -215,10 +268,158 @@ describe('Pinned results', () => { useDebateContextStore.setState({ pins: { [TEST_DEBATE_ID]: [caseRef] } }); const pinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, attackRef); - await useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); + const unpinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); insert.resolve({ error: { message: 'offline' } }); await expect(pinning).rejects.toThrow('offline'); + await unpinning; expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([]); }); + + it('keeps saved pins when the student pins before the first load returns', async () => { + const load = deferred<{ data: unknown; error: null }>(); + supabaseMock.from.mockImplementationOnce((() => ({ select: () => ({ eq: () => load.promise }) })) as never); + + const loading = useDebateContextStore.getState().loadPins(TEST_DEBATE_ID); + await useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, attackRef); + load.resolve({ data: [{ item_kind: 'case', item_id: caseRef.id }], error: null }); + await loading; + + expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([caseRef, attackRef]); + }); + + it('keeps a pin that finished saving while an older load was in flight', async () => { + const insert = deferred<{ error: null }>(); + const load = deferred<{ data: unknown; error: null }>(); + supabaseMock.from + .mockImplementationOnce((() => ({ insert: () => insert.promise })) as never) + .mockImplementationOnce((() => ({ select: () => ({ eq: () => load.promise }) })) as never); + + const pinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); + await new Promise((done) => setTimeout(done, 0)); + // The load reads the table before the insert commits, then answers after it. + const loading = useDebateContextStore.getState().loadPins(TEST_DEBATE_ID); + insert.resolve({ error: null }); + await pinning; + load.resolve({ data: [], error: null }); + await loading; + + expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([caseRef]); + }); + + it('saves a quick pin and unpin of the same result in the order they were made', async () => { + const insert = deferred<{ error: null }>(); + const calls: string[] = []; + const deleted = { eq: () => deleted, then: (done: (value: { error: null }) => void) => { calls.push('delete'); done({ error: null }); } }; + supabaseMock.from + .mockImplementationOnce((() => ({ insert: () => { calls.push('insert'); return insert.promise; } })) as never) + .mockImplementationOnce((() => ({ delete: () => deleted })) as never); + + const pinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); + const unpinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); + await new Promise((done) => setTimeout(done, 0)); + expect(calls).toEqual(['insert']); + insert.resolve({ error: null }); + await Promise.all([pinning, unpinning]); + + expect(calls).toEqual(['insert', 'delete']); + expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([]); + }); + + it('does not count pins on deleted results toward the limit', async () => { + const live = [12, 14].map((n) => ({ kind: 'case' as const, id: uuid(n) })); + const existing = new Set([...live, caseRef, attackRef].map((ref) => `${ref.kind}:${ref.id}`)); + useDebateContextStore.setState({ pins: { [TEST_DEBATE_ID]: [{ kind: 'case', id: uuid(40) }, ...live] } }); + + // Two quick clicks share one rendered plan; the second must see the first. + const first = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, attackRef, existing); + const second = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef, existing); + await expect(first).resolves.toBeUndefined(); + await expect(second).rejects.toThrow('pin_limit'); + }); + + it('keeps a later pin when an earlier save of the same result fails', async () => { + const firstInsert = deferred<{ error: { message: string } }>(); + const calls: string[] = []; + const deleted = { eq: () => deleted, then: (done: (value: { error: null }) => void) => { calls.push('delete'); done({ error: null }); } }; + supabaseMock.from + .mockImplementationOnce((() => ({ insert: () => { calls.push('insert'); return firstInsert.promise; } })) as never) + .mockImplementationOnce((() => ({ delete: () => deleted })) as never) + .mockImplementationOnce((() => ({ insert: () => { calls.push('insert'); return Promise.resolve({ error: null }); } })) as never); + + // Pin, unpin and pin again while the first save is still pending. + const toggles = [0, 1, 2].map(() => useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef)); + firstInsert.resolve({ error: { message: 'offline' } }); + await Promise.all(toggles); + + expect(calls).toEqual(['insert', 'delete', 'insert']); + expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([caseRef]); + }); + + it('ends unpinned when a quick pin and unpin both fail to save', async () => { + const deleted = { eq: () => deleted, then: (done: (value: { error: { message: string } }) => void) => done({ error: { message: 'offline' } }) }; + supabaseMock.from + .mockImplementationOnce((() => ({ insert: () => Promise.resolve({ error: { message: 'offline' } }) })) as never) + .mockImplementationOnce((() => ({ delete: () => deleted })) as never); + + const pinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); + const unpinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); + await Promise.allSettled([pinning, unpinning]); + + // Neither save reached the database, so the result was never pinned. + expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([]); + }); + + it('treats pinning a result that is already saved as pinned', async () => { + const deleted = { eq: () => deleted, then: (done: (value: { error: { message: string } }) => void) => done({ error: { message: 'offline' } }) }; + supabaseMock.from + .mockImplementationOnce((() => ({ delete: () => deleted })) as never) + .mockImplementationOnce((() => ({ insert: () => Promise.resolve({ error: { code: '23505', message: 'duplicate key' } }) })) as never); + useDebateContextStore.setState({ pins: { [TEST_DEBATE_ID]: [caseRef] } }); + + // The unpin fails, so the row stays; pinning again must not unpin locally. + const unpinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); + const pinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); + await Promise.all([unpinning, pinning]); + + expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([caseRef]); + }); + + it('drops pin writes left over from before a sign-out', async () => { + const oldInsert = deferred<{ error: { message: string } }>(); + const calls: string[] = []; + supabaseMock.from + .mockImplementationOnce((() => ({ insert: () => { calls.push('old'); return oldInsert.promise; } })) as never) + .mockImplementationOnce((() => ({ insert: () => { calls.push('new'); return Promise.resolve({ error: null }); } })) as never); + + const oldPin = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); + const oldQueued = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, attackRef); + await new Promise((done) => setTimeout(done, 0)); + useDebateContextStore.getState().resetLocalData(); + + // The next account's change must not wait behind the old one. + await useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef); + expect(calls).toEqual(['old', 'new']); + + // The old failure must not undo the new account's pin, and the queued old + // write must never run under the new account. + oldInsert.resolve({ error: { message: 'offline' } }); + await expect(oldPin).resolves.toBeUndefined(); + await expect(oldQueued).resolves.toBeUndefined(); + expect(calls).toEqual(['old', 'new']); + expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([caseRef]); + }); + + it('ignores a pin load that returns after a sign-out', async () => { + const load = deferred<{ data: unknown; error: null }>(); + supabaseMock.from.mockImplementationOnce((() => ({ select: () => ({ eq: () => load.promise }) })) as never); + + const loading = useDebateContextStore.getState().loadPins(TEST_DEBATE_ID); + useDebateContextStore.getState().resetLocalData(); + load.resolve({ data: [{ item_kind: 'case', item_id: caseRef.id }], error: null }); + await loading; + + expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toBeUndefined(); + expect(useDebateContextStore.getState().pinsLoaded[TEST_DEBATE_ID]).toBeUndefined(); + }); });