/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useSmoothText } from '@/hooks/use-smooth-text'
interface ProbeProps {
content: string
isStreaming: boolean
snapOnNonAppend?: boolean
}
/**
* Minimal dependency-free hook harness (the repo has no `@testing-library/react`). Mounts the hook in
* a real React root under jsdom so effects and refs run exactly as in the app. Fake timers keep the
* paced reveal from advancing, so each assertion observes the synchronous reveal decision only.
*/
function renderSmoothText(initial: ProbeProps) {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
const root: Root = createRoot(container)
const props = { ...initial }
let latest = ''
function Probe(p: ProbeProps) {
latest = useSmoothText(p.content, p.isStreaming, { snapOnNonAppend: p.snapOnNonAppend })
return null
}
const render = () =>
act(() => {
root.render()
})
render()
return {
value: () => latest,
rerender: (next: Partial) => {
Object.assign(props, next)
render()
},
unmount: () => act(() => root.unmount()),
}
}
const LONG = `# Existing Document\n\n${'Lorem ipsum dolor sit amet, '.repeat(8)}`
describe('useSmoothText — streaming that begins on an already-open document', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})
it('reveals a pre-existing document in full when an edit stream starts (no full-file replay)', () => {
// The editor mounts showing a static file (no stream yet).
const h = renderSmoothText({ content: '', isStreaming: false, snapOnNonAppend: true })
expect(h.value()).toBe('')
// The agent begins editing it: the first streamed value carries the whole existing document.
// It must appear instantly, not replay word-by-word from the first character.
h.rerender({ content: LONG, isStreaming: true })
expect(h.value()).toBe(LONG)
h.unmount()
})
it('still animates a brand-new file from the start (short content stays below the threshold)', () => {
// A create stream mounts already-streaming with a tiny first chunk → begins empty and paces in.
const h = renderSmoothText({ content: '# New file', isStreaming: true, snapOnNonAppend: true })
expect(h.value()).toBe('')
h.unmount()
})
it('shows content that is already large at mount in full (mount-time skip, unchanged)', () => {
const h = renderSmoothText({ content: LONG, isStreaming: true, snapOnNonAppend: true })
expect(h.value()).toBe(LONG)
h.unmount()
})
it('does not pre-reveal for chat (mounts already streaming with a small first chunk)', () => {
// Chat (no snapOnNonAppend) mounts streaming; the not-streaming→streaming edge never occurs, so
// the new transition skip cannot fire and ordinary paced reveal is preserved.
const h = renderSmoothText({ content: 'Hello', isStreaming: true })
expect(h.value()).toBe('')
h.unmount()
})
})