336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
import React from 'react'
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
|
import { AppState } from 'react-native'
|
|
import * as Keychain from 'react-native-keychain'
|
|
import type {
|
|
HistoryCursor,
|
|
HistoryListEntry,
|
|
HistoryListOptions,
|
|
HistoryPageResult,
|
|
} from '../src/features/history/history-service'
|
|
|
|
const USER_A = '11111111-1111-4111-8111-111111111111'
|
|
|
|
let mockAuthUser: { id: string } | null = null
|
|
const mockListHistoryPage = jest.fn<Promise<HistoryPageResult>, [HistoryListOptions]>()
|
|
let mockRemoteChange: (() => void) | null = null
|
|
|
|
jest.mock('../src/lib/supabase', () => ({
|
|
supabase: {
|
|
channel: jest.fn(),
|
|
removeChannel: jest.fn(async () => 'ok'),
|
|
},
|
|
}))
|
|
jest.mock('../src/lib/auth-context', () => ({
|
|
useAuth: () => ({ user: mockAuthUser }),
|
|
}))
|
|
jest.mock('../src/features/history/history-service', () => ({
|
|
...jest.requireActual('../src/features/history/history-service'),
|
|
listHistoryPage: (options: HistoryListOptions) => mockListHistoryPage(options),
|
|
subscribeToHistory: (_userId: string, onRemoteChange: () => void) => {
|
|
mockRemoteChange = onRemoteChange
|
|
return { unsubscribe: async () => undefined }
|
|
},
|
|
}))
|
|
|
|
import {
|
|
appendHistoryPage,
|
|
applyHeadRefresh,
|
|
compareHistoryPosition,
|
|
EMPTY_HISTORY_WINDOW,
|
|
fetchHistoryHead,
|
|
type HistoryPageRequest,
|
|
} from '../src/features/history/history-window'
|
|
import { useHistorySync, type HistorySyncModel } from '../src/features/history/use-history-sync'
|
|
|
|
const keychainMock = Keychain as unknown as typeof Keychain & { __reset: () => void }
|
|
const BASE_TIME = Date.UTC(2026, 7, 21)
|
|
|
|
function entry(index: number, overrides: Partial<HistoryListEntry> = {}): HistoryListEntry {
|
|
const id = `e-${String(index).padStart(3, '0')}`
|
|
const createdAt = new Date(BASE_TIME - index * 60_000).toISOString()
|
|
return {
|
|
id,
|
|
user_id: USER_A,
|
|
title: null,
|
|
original_text: `original ${id}`,
|
|
polished_text: null,
|
|
focused_app: null,
|
|
focused_app_name: null,
|
|
focused_app_window_title: null,
|
|
mode: 'dictation',
|
|
status: 'completed',
|
|
error_code: null,
|
|
audio_storage_key: null,
|
|
duration: 2,
|
|
detected_language: 'ko',
|
|
mic_device: null,
|
|
word_count: 2,
|
|
stt_model: 'whisper',
|
|
llm_model: null,
|
|
stt_latency_ms: null,
|
|
llm_latency_ms: null,
|
|
app_version: '1.0.0',
|
|
summary_text: null,
|
|
is_favorite: index % 3 === 0,
|
|
revision: 1,
|
|
created_at: createdAt,
|
|
updated_at: createdAt,
|
|
activeJob: null,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function cursorOf(value: HistoryListEntry): HistoryCursor {
|
|
return { createdAt: value.created_at, id: value.id }
|
|
}
|
|
|
|
/** In-memory stand-in for the history table with keyset pagination. */
|
|
function createServer(count: number) {
|
|
let rows = Array.from({ length: count }, (_, index) => entry(index))
|
|
const page = (options: {
|
|
filter?: string
|
|
cursor?: HistoryCursor | null
|
|
pageSize?: number
|
|
}): HistoryPageResult => {
|
|
const pageSize = options.pageSize ?? 20
|
|
const cursor = options.cursor ?? null
|
|
const matching = rows
|
|
.filter((row) => options.filter !== 'favorites' || row.is_favorite)
|
|
.filter((row) => cursor === null || compareHistoryPosition(cursorOf(row), cursor) > 0)
|
|
.sort((a, b) => compareHistoryPosition(cursorOf(a), cursorOf(b)))
|
|
const entries = matching.slice(0, pageSize)
|
|
const last = entries.at(-1)
|
|
return {
|
|
entries,
|
|
nextCursor: entries.length === pageSize && last !== undefined ? cursorOf(last) : null,
|
|
}
|
|
}
|
|
return {
|
|
page,
|
|
remove: (id: string) => { rows = rows.filter((row) => row.id !== id) },
|
|
insert: (row: HistoryListEntry) => { rows = [...rows, row] },
|
|
}
|
|
}
|
|
|
|
interface Deferred<T> {
|
|
promise: Promise<T>
|
|
resolve: (value: T) => void
|
|
}
|
|
|
|
function deferred<T>(): Deferred<T> {
|
|
let resolve: (value: T) => void = () => undefined
|
|
const promise = new Promise<T>((settle) => { resolve = settle })
|
|
return { promise, resolve }
|
|
}
|
|
|
|
async function flush(): Promise<void> {
|
|
for (let index = 0; index < 20; index += 1) await Promise.resolve()
|
|
}
|
|
|
|
let model: HistorySyncModel | null = null
|
|
|
|
function Harness(): null {
|
|
model = useHistorySync()
|
|
return null
|
|
}
|
|
|
|
function current(): HistorySyncModel {
|
|
if (model === null) throw new Error('hook not mounted')
|
|
return model
|
|
}
|
|
|
|
async function mount(): Promise<ReactTestRenderer> {
|
|
let renderer: ReactTestRenderer | null = null
|
|
await act(async () => {
|
|
renderer = create(<Harness />)
|
|
await flush()
|
|
})
|
|
if (renderer === null) throw new Error('renderer missing')
|
|
return renderer
|
|
}
|
|
|
|
async function triggerRemoteChange(): Promise<void> {
|
|
await act(async () => {
|
|
mockRemoteChange?.()
|
|
jest.advanceTimersByTime(300)
|
|
await flush()
|
|
})
|
|
}
|
|
|
|
describe('history window policy', () => {
|
|
test('a head refresh that ends before the loaded window keeps the unverified tail and cursor', () => {
|
|
const loaded = { entries: [entry(0), entry(1), entry(2), entry(3)], nextCursor: cursorOf(entry(3)) }
|
|
const head = { entries: [entry(0), entry(1)], nextCursor: cursorOf(entry(1)) }
|
|
const next = applyHeadRefresh(loaded, head)
|
|
expect(next.entries.map((row) => row.id)).toEqual(['e-000', 'e-001', 'e-002', 'e-003'])
|
|
expect(next.nextCursor).toEqual(cursorOf(entry(3)))
|
|
})
|
|
|
|
test('a head refresh drops rows it no longer sees inside its range', () => {
|
|
const loaded = { entries: [entry(0), entry(1), entry(2), entry(3)], nextCursor: cursorOf(entry(3)) }
|
|
const head = { entries: [entry(0), entry(2), entry(3)], nextCursor: cursorOf(entry(3)) }
|
|
const next = applyHeadRefresh(loaded, head)
|
|
expect(next.entries.map((row) => row.id)).toEqual(['e-000', 'e-002', 'e-003'])
|
|
expect(next.nextCursor).toEqual(cursorOf(entry(3)))
|
|
})
|
|
|
|
test('a head refresh that reaches the end is authoritative', () => {
|
|
const loaded = { entries: [entry(0), entry(1), entry(2)], nextCursor: cursorOf(entry(2)) }
|
|
const next = applyHeadRefresh(loaded, { entries: [entry(0)], nextCursor: null })
|
|
expect(next).toEqual({ entries: [entry(0)], nextCursor: null })
|
|
})
|
|
|
|
test('an appended page never moves the cursor backwards', () => {
|
|
const refreshed = { entries: [entry(0), entry(1), entry(2), entry(3)], nextCursor: cursorOf(entry(3)) }
|
|
const stalePage = { entries: [entry(2)], nextCursor: cursorOf(entry(2)) }
|
|
expect(appendHistoryPage(refreshed, stalePage).nextCursor).toEqual(cursorOf(entry(3)))
|
|
const endPage = { entries: [entry(4)], nextCursor: null }
|
|
expect(appendHistoryPage(refreshed, endPage)).toEqual({
|
|
entries: [entry(0), entry(1), entry(2), entry(3), entry(4)],
|
|
nextCursor: null,
|
|
})
|
|
})
|
|
|
|
test('fetchHistoryHead pages until the oldest loaded entry is covered', async () => {
|
|
const server = createServer(200)
|
|
const requests: HistoryPageRequest[] = []
|
|
const fetchPage = async (request: HistoryPageRequest) => {
|
|
requests.push(request)
|
|
return server.page(request)
|
|
}
|
|
const loaded = server.page({ pageSize: 120 })
|
|
const head = await fetchHistoryHead(fetchPage, loaded)
|
|
expect(requests.map((request) => request.pageSize)).toEqual([50, 50, 50])
|
|
// Trimmed back to the loaded boundary: the window keeps its size.
|
|
expect(head).toEqual(loaded)
|
|
|
|
// The last page reaches the end of the results: still trimmed to the
|
|
// loaded boundary, with a cursor so the remaining rows stay pageable.
|
|
const small = createServer(70)
|
|
const loadedSmall = small.page({ pageSize: 60 })
|
|
const smallHead = await fetchHistoryHead(async (request) => small.page(request), loadedSmall)
|
|
expect(smallHead).toEqual(loadedSmall)
|
|
|
|
requests.length = 0
|
|
const first = await fetchHistoryHead(fetchPage, EMPTY_HISTORY_WINDOW)
|
|
expect(requests).toEqual([{ cursor: null, pageSize: 20 }])
|
|
expect(first.entries).toHaveLength(20)
|
|
})
|
|
})
|
|
|
|
describe('useHistorySync pagination window', () => {
|
|
beforeEach(() => {
|
|
jest.useFakeTimers()
|
|
keychainMock.__reset()
|
|
mockAuthUser = { id: USER_A }
|
|
mockRemoteChange = null
|
|
model = null
|
|
mockListHistoryPage.mockReset()
|
|
// The preset mock leaves currentState as a jest.fn; the poll only runs
|
|
// while the app is in the foreground.
|
|
Object.defineProperty(AppState, 'currentState', { value: 'active', configurable: true })
|
|
})
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers()
|
|
})
|
|
|
|
test('a background pull keeps pages loaded with loadMore and reconciles deletions across them', async () => {
|
|
const server = createServer(100)
|
|
mockListHistoryPage.mockImplementation(async (options) => server.page(options))
|
|
const renderer = await mount()
|
|
expect(current().entries).toHaveLength(20)
|
|
|
|
await act(async () => {
|
|
await current().loadMore()
|
|
await flush()
|
|
})
|
|
await act(async () => {
|
|
await current().loadMore()
|
|
await flush()
|
|
})
|
|
expect(current().entries).toHaveLength(60)
|
|
|
|
// Another device deletes a row deep in the loaded window and inserts a new one.
|
|
server.remove('e-045')
|
|
server.insert(entry(-1))
|
|
await triggerRemoteChange()
|
|
|
|
const ids = current().entries.map((row) => row.id)
|
|
expect(ids).toHaveLength(60)
|
|
expect(ids[0]).toBe('e-0-1')
|
|
expect(ids).not.toContain('e-045')
|
|
expect(ids).toContain('e-059')
|
|
expect(current().hasMore).toBe(true)
|
|
|
|
// The 45s deletion-reconciliation poll behaves the same way.
|
|
server.remove('e-010')
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(45_000 + 300)
|
|
await flush()
|
|
})
|
|
expect(current().entries.map((row) => row.id)).not.toContain('e-010')
|
|
expect(current().entries).toHaveLength(59)
|
|
|
|
await act(async () => {
|
|
renderer.unmount()
|
|
await flush()
|
|
})
|
|
})
|
|
|
|
test('a loadMore that resolves after a filter change is discarded', async () => {
|
|
const server = createServer(100)
|
|
const heldPage = deferred<HistoryPageResult>()
|
|
mockListHistoryPage.mockImplementation(async (options) => {
|
|
if (options.filter === 'all' && options.cursor !== null && options.cursor !== undefined) {
|
|
return heldPage.promise
|
|
}
|
|
return server.page(options)
|
|
})
|
|
const renderer = await mount()
|
|
expect(current().entries).toHaveLength(20)
|
|
|
|
let pendingLoadMore: Promise<void> = Promise.resolve()
|
|
await act(async () => {
|
|
pendingLoadMore = current().loadMore()
|
|
await flush()
|
|
})
|
|
|
|
await act(async () => {
|
|
current().setFilter('favorites')
|
|
await flush()
|
|
})
|
|
expect(current().filter).toBe('favorites')
|
|
expect(current().entries.every((row) => row.is_favorite)).toBe(true)
|
|
const favoritesCursorPage = server.page({ filter: 'favorites' })
|
|
|
|
await act(async () => {
|
|
heldPage.resolve(server.page({ filter: 'all', cursor: cursorOf(entry(19)) }))
|
|
await pendingLoadMore
|
|
await flush()
|
|
})
|
|
|
|
expect(current().entries.every((row) => row.is_favorite)).toBe(true)
|
|
expect(current().entries.map((row) => row.id)).toEqual(
|
|
favoritesCursorPage.entries.map((row) => row.id),
|
|
)
|
|
|
|
// The next page is requested with the favorites cursor, not the stale 'all' one.
|
|
mockListHistoryPage.mockClear()
|
|
await act(async () => {
|
|
await current().loadMore()
|
|
await flush()
|
|
})
|
|
expect(mockListHistoryPage).toHaveBeenCalledWith(expect.objectContaining({
|
|
filter: 'favorites',
|
|
cursor: favoritesCursorPage.nextCursor,
|
|
}))
|
|
expect(current().entries.every((row) => row.is_favorite)).toBe(true)
|
|
|
|
await act(async () => {
|
|
renderer.unmount()
|
|
await flush()
|
|
})
|
|
})
|
|
})
|