From 45a40dc48587dbc3f48298568cb9ecdcc0927fd0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:45:18 -0700 Subject: [PATCH] test(mobile): pin the replay contiguity-scan rejection branches The scan's three rejection rules were unpinned: deleting the `sawNewMessage` guard, the ordering check, or the retained-tail anchor each left the whole suite green. Cover the interleaved-new-row, reordered-id, and short-of-tail cases, and the replaced-window hasMore fallback. Each new test is mutation-proven to kill exactly one mutant. --- .../mobile-native-chat-stream-frame.test.ts | 51 +++++++++++++++++-- .../use-mobile-native-chat-session.test.ts | 22 ++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-stream-frame.test.ts b/mobile/src/session/mobile-native-chat-stream-frame.test.ts index de189ca42c8..ef94c83a7f1 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.test.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.test.ts @@ -133,19 +133,64 @@ describe('applyMobileNativeChatStreamFrame', () => { const merger = createNativeChatMerger() replaceList(merger, ['old-1', 'shared', 'old-tail'].map(message)) + // `hasMore: true` so the authoritative-removal rule can't short-circuit — + // the contiguity scan itself must reject the interleaved new message. const replay = [message('shared'), message('compacted-summary'), message('old-tail')] expect( applyMobileNativeChatStreamFrame({ merger, - frame: { type: 'snapshot', messages: replay, hasMore: false, beforeOffset: 0 }, + frame: { type: 'snapshot', messages: replay, hasMore: true, beforeOffset: 9 }, limit: 100, replaceSnapshot: false }) ).toEqual({ kind: 'messages', messages: replay, - hasMore: false, - beforeOffset: 0, + hasMore: true, + beforeOffset: 9, + windowReplaced: true + }) + }) + + it('replaces a replay that repeats retained ids out of order', () => { + const merger = createNativeChatMerger() + replaceList(merger, ['a', 'b', 'c'].map(message)) + + const replay = [message('a'), message('c'), message('b')] + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: replay, hasMore: true, beforeOffset: 4 }, + limit: 100, + replaceSnapshot: false + }) + ).toEqual({ + kind: 'messages', + messages: replay, + hasMore: true, + beforeOffset: 4, + windowReplaced: true + }) + }) + + it('replaces a replay that stops short of the retained newest row', () => { + const merger = createNativeChatMerger() + replaceList(merger, ['a', 'b', 'c'].map(message)) + + // Merging would keep 'c', a row the replayed window no longer carries. + const replay = [message('a'), message('b')] + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: replay, hasMore: true, beforeOffset: 1 }, + limit: 100, + replaceSnapshot: false + }) + ).toEqual({ + kind: 'messages', + messages: replay, + hasMore: true, + beforeOffset: 1, windowReplaced: true }) }) diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index b8ac586f43a..e1cd7a93294 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -311,6 +311,28 @@ describe('useMobileNativeChatSession', () => { }) }) + it('clears hasMore when a replaced window is shorter than the initial page', async () => { + // A replaced window without paging metadata is judged by its own length: + // shorter than a full page means the whole transcript is on screen. + const sendRequest = vi.fn() + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + expect(state?.hasMore).toBe(true) + + await act(async () => emit({ type: 'replacement', messages: [message('only')] })) + + expect(state?.hasMore).toBe(false) + }) + it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => { let resolveCursorPage: (response: unknown) => void = () => {} const sendRequest = vi