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.
This commit is contained in:
Brennan Benson
2026-08-04 02:45:18 -07:00
parent 760e7214af
commit 45a40dc485
2 changed files with 70 additions and 3 deletions
@@ -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
})
})
@@ -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