148 lines
4.7 KiB
TypeScript
148 lines
4.7 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
$perSessionBrowse,
|
|
browseBackward,
|
|
browseForward,
|
|
deriveUserHistory,
|
|
isBrowsingHistory,
|
|
resetBrowseState
|
|
} from './composer-input-history'
|
|
|
|
const SESSION_A = 'session-a'
|
|
const SESSION_B = 'session-b'
|
|
|
|
// Newest-first user text ring, what the caller passes to browse*.
|
|
const HISTORY = ['third', 'second', 'first']
|
|
|
|
const MSG = (role: string, text: string) => ({ id: '', role, text })
|
|
|
|
beforeEach(() => {
|
|
$perSessionBrowse.set({})
|
|
})
|
|
|
|
describe('deriveUserHistory', () => {
|
|
it('returns user messages newest-first with empty/whitespace skipped', () => {
|
|
const messages = [
|
|
MSG('user', ' '),
|
|
MSG('assistant', 'hi'),
|
|
MSG('user', 'first'),
|
|
MSG('user', 'second')
|
|
]
|
|
|
|
expect(deriveUserHistory(messages, m => m.text)).toEqual(['second', 'first'])
|
|
})
|
|
})
|
|
|
|
describe('browseBackward', () => {
|
|
it('returns null when history is empty', () => {
|
|
expect(browseBackward(SESSION_A, '', [])).toBeNull()
|
|
})
|
|
|
|
it('returns the most recent entry on first press and saves the draft', () => {
|
|
const result = browseBackward(SESSION_A, 'unsent draft', HISTORY)
|
|
|
|
expect(result).toBe('third')
|
|
expect($perSessionBrowse.get()[SESSION_A]!.draftSnapshot).toBe('unsent draft')
|
|
})
|
|
|
|
it('moves to older entries on subsequent presses and stops at the oldest', () => {
|
|
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('third')
|
|
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('second')
|
|
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('first')
|
|
expect(browseBackward(SESSION_A, '', HISTORY)).toBeNull()
|
|
})
|
|
|
|
it('uses caller-provided history, not a mirrored ring', () => {
|
|
// The store never owns the ring — the caller passes it every press.
|
|
// If the ring changes between presses (e.g. a new message was sent),
|
|
// the next press sees the updated ring and the cursor continues
|
|
// from where it was within it.
|
|
expect(browseBackward(SESSION_A, '', ['youngest', 'older'])).toBe('youngest')
|
|
|
|
// Caller added a new message; ring is now [brand-new, youngest, older].
|
|
// Cursor was at 0, next press advances to 1 -> "youngest".
|
|
expect(
|
|
browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])
|
|
).toBe('youngest')
|
|
|
|
// One more press -> "older".
|
|
expect(
|
|
browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])
|
|
).toBe('older')
|
|
})
|
|
})
|
|
|
|
describe('browseForward', () => {
|
|
it('returns null when not browsing', () => {
|
|
expect(browseForward(SESSION_A, HISTORY)).toBeNull()
|
|
})
|
|
|
|
it('moves toward the present', () => {
|
|
browseBackward(SESSION_A, 'draft', HISTORY) // cursor 0 -> 'third'
|
|
browseBackward(SESSION_A, '', HISTORY) // cursor 1 -> 'second'
|
|
|
|
expect(browseForward(SESSION_A, HISTORY)).toEqual({
|
|
text: 'third',
|
|
returnedToPresent: false
|
|
})
|
|
})
|
|
|
|
it('restores the saved draft and resets when reaching the present', () => {
|
|
browseBackward(SESSION_A, 'my original draft', HISTORY)
|
|
|
|
const result = browseForward(SESSION_A, HISTORY)
|
|
|
|
expect(result).toEqual({ text: 'my original draft', returnedToPresent: true })
|
|
expect(isBrowsingHistory(SESSION_A)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('per-session isolation', () => {
|
|
it('tracks cursor and draft independently per session', () => {
|
|
browseBackward(SESSION_A, 'draft-a', HISTORY)
|
|
browseBackward(SESSION_A, '', HISTORY) // older
|
|
|
|
browseBackward(SESSION_B, 'draft-b', HISTORY)
|
|
|
|
const a = $perSessionBrowse.get()[SESSION_A]!
|
|
const b = $perSessionBrowse.get()[SESSION_B]!
|
|
|
|
expect(a.cursor).toBe(1)
|
|
expect(a.draftSnapshot).toBe('draft-a')
|
|
expect(b.cursor).toBe(0)
|
|
expect(b.draftSnapshot).toBe('draft-b')
|
|
})
|
|
})
|
|
|
|
describe('resetBrowseState', () => {
|
|
it('clears cursor and draft snapshot', () => {
|
|
browseBackward(SESSION_A, 'draft', HISTORY)
|
|
resetBrowseState(SESSION_A)
|
|
|
|
const s = $perSessionBrowse.get()[SESSION_A]!
|
|
|
|
expect(s.cursor).toBe(-1)
|
|
expect(s.draftSnapshot).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('session switch behavior', () => {
|
|
it('resets the previous session cursor and lets the new session derive its own ring', () => {
|
|
// Session A: user browsed into the past
|
|
browseBackward(SESSION_A, '', HISTORY)
|
|
expect(isBrowsingHistory(SESSION_A)).toBe(true)
|
|
|
|
// Caller switches to session B; resets A's browse state
|
|
resetBrowseState(SESSION_A)
|
|
|
|
// Session B's ring is derived from B's messages, not A's
|
|
const sessionBMessages = [MSG('user', 'hello-b'), MSG('user', 'world-b')]
|
|
const sessionBHistory = deriveUserHistory(sessionBMessages, m => m.text)
|
|
|
|
expect(browseBackward(SESSION_B, '', sessionBHistory)).toBe('world-b')
|
|
expect(browseBackward(SESSION_B, '', sessionBHistory)).toBe('hello-b')
|
|
expect(isBrowsingHistory(SESSION_A)).toBe(false)
|
|
})
|
|
})
|