Files
iofficeai--aionui/tests/unit/renderer/useLiveTranscriptInsertion.dom.test.ts
2026-07-13 13:20:22 +08:00

227 lines
8.2 KiB
TypeScript

/**
* @license
* Copyright 2025 AionUi (aionui.com)
* SPDX-License-Identifier: Apache-2.0
*
* Anchor-based live transcript insertion (streaming voice input, Task B5):
* while a streaming session is active, the text after the anchor is owned by
* the session and replaced wholesale on every live update. `null` restores
* the pre-session anchor; the terminal `onTranscript` then appends the final
* text via the regular transcript handler (no double write).
*/
import { act, renderHook } from '@testing-library/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { describe, expect, it } from 'vitest';
import { createChainedDispatch, useLiveTranscriptInsertion } from '@/renderer/hooks/system/useLiveTranscriptInsertion';
import { appendSpeechTranscript } from '@/renderer/hooks/system/useSpeechInput';
import { useLatestRef } from '@/renderer/hooks/ui/useLatestRef';
/** Real useState harness so the hook is exercised against actual React state. */
const useHarness = () => {
const [input, setInput] = useState('');
const { handleLiveTranscript } = useLiveTranscriptInsertion(setInput);
return { input, setInput, handleLiveTranscript };
};
describe('useLiveTranscriptInsertion', () => {
it('inserts live text into an empty input, replaces on update, and clears on null without double-writing the final', () => {
const { result } = renderHook(useHarness);
act(() => result.current.handleLiveTranscript('hello'));
expect(result.current.input).toBe('hello');
act(() => result.current.handleLiveTranscript('hello world'));
expect(result.current.input).toBe('hello world');
act(() => result.current.handleLiveTranscript(null));
expect(result.current.input).toBe('');
// Terminal onTranscript flow: the regular handler appends the final text
// onto the restored anchor — exactly one copy ends up in the input.
act(() => result.current.setInput((prev) => appendSpeechTranscript(prev, 'hello world')));
expect(result.current.input).toBe('hello world');
});
it('anchors to the existing input, joins with newline, and restores the draft on null', () => {
const { result } = renderHook(useHarness);
act(() => result.current.setInput('draft'));
act(() => result.current.handleLiveTranscript('a'));
expect(result.current.input).toBe('draft\na');
act(() => result.current.handleLiveTranscript('ab'));
expect(result.current.input).toBe('draft\nab');
act(() => result.current.handleLiveTranscript(null));
expect(result.current.input).toBe('draft');
});
it('ignores null when no session is active', () => {
const { result } = renderHook(useHarness);
act(() => result.current.setInput('untouched'));
act(() => result.current.handleLiveTranscript(null));
expect(result.current.input).toBe('untouched');
});
it('captures a fresh anchor for a second session after the first completes', () => {
const { result } = renderHook(useHarness);
// First session.
act(() => result.current.handleLiveTranscript('first'));
act(() => result.current.handleLiveTranscript(null));
expect(result.current.input).toBe('');
act(() => result.current.setInput((prev) => appendSpeechTranscript(prev, 'first')));
expect(result.current.input).toBe('first');
// Second session anchors on the input that now includes the first final.
act(() => result.current.handleLiveTranscript('second'));
expect(result.current.input).toBe('first\nsecond');
act(() => result.current.handleLiveTranscript(null));
expect(result.current.input).toBe('first');
});
it('keeps the anchor unchanged for whitespace-only live text (appendSpeechTranscript rules)', () => {
const { result } = renderHook(useHarness);
act(() => result.current.setInput('draft'));
act(() => result.current.handleLiveTranscript(' '));
expect(result.current.input).toBe('draft');
// The session is still active: a later real update replaces the live region.
act(() => result.current.handleLiveTranscript('spoken'));
expect(result.current.input).toBe('draft\nspoken');
act(() => result.current.handleLiveTranscript(null));
expect(result.current.input).toBe('draft');
});
});
/**
* Mimics SendBox's controlled-prop shape: the "parent" owns the state and the
* "child" only sees a `value` prop plus a PLAIN `(value: string) => void`
* onChange (no functional updates). React batches plain-value calls within
* one tick — last write wins — so the child must chain same-tick updates
* through `createChainedDispatch`, exactly as SendBox does.
*/
const useControlledHarness = () => {
// Parent layer.
const [input, setInput] = useState('');
const onChange = useCallback((value: string) => setInput(value), []);
// Child layer (SendBox shape): `input` prop + plain `onChange`.
const latestInputRef = useLatestRef(input);
const onChangeRef = useLatestRef(onChange);
const speechDispatch = useMemo(
() =>
createChainedDispatch(
() => latestInputRef.current,
(value) => onChangeRef.current(value)
),
[latestInputRef, onChangeRef]
);
useEffect(() => {
speechDispatch.reset();
}, [input, speechDispatch]);
const handleSpeechTranscript = useCallback(
(transcript: string) => {
speechDispatch.dispatch((prev) => appendSpeechTranscript(prev, transcript));
},
[speechDispatch]
);
const { handleLiveTranscript } = useLiveTranscriptInsertion(speechDispatch.dispatch);
return { input, setInput, handleLiveTranscript, handleSpeechTranscript };
};
describe('useLiveTranscriptInsertion — controlled SendBox shape (plain onChange)', () => {
it('does not double the final text when the live-region restore and the terminal append land in the same tick', () => {
const { result } = renderHook(useControlledHarness);
act(() => result.current.setInput('draft'));
// Streaming session over "draft".
act(() => result.current.handleLiveTranscript('hello world'));
expect(result.current.input).toBe('draft\nhello world');
// Terminal sequence from useSpeechInput fires synchronously in ONE tick:
// onLiveTranscript(null) then onTranscript('hello world'). Without the
// pending-value chain both writers resolve against the stale committed
// prop and the batched last write yields "draft\nhello world\nhello world".
act(() => {
result.current.handleLiveTranscript(null);
result.current.handleSpeechTranscript('hello world');
});
expect(result.current.input).toBe('draft\nhello world');
});
it('chains same-tick live updates of an in-flight session', () => {
const { result } = renderHook(useControlledHarness);
act(() => {
result.current.handleLiveTranscript('he');
result.current.handleLiveTranscript('hello');
});
expect(result.current.input).toBe('hello');
act(() => {
result.current.handleLiveTranscript(null);
result.current.handleSpeechTranscript('hello');
});
expect(result.current.input).toBe('hello');
});
});
describe('createChainedDispatch', () => {
it('chains same-tick functional updates through the pending value', () => {
const emitted: string[] = [];
const { dispatch } = createChainedDispatch(
() => 'base',
(value) => emitted.push(value)
);
dispatch((prev) => `${prev}+1`);
dispatch((prev) => `${prev}+2`);
expect(emitted).toEqual(['base+1', 'base+1+2']);
});
it('treats a plain value mid-chain as the new base', () => {
const emitted: string[] = [];
const { dispatch } = createChainedDispatch(
() => 'base',
(value) => emitted.push(value)
);
dispatch((prev) => `${prev}+1`);
dispatch('override');
dispatch((prev) => `${prev}!`);
expect(emitted).toEqual(['base+1', 'override', 'override!']);
});
it('re-reads the committed value after reset()', () => {
let committed = 'a';
const emitted: string[] = [];
const { dispatch, reset } = createChainedDispatch(
() => committed,
(value) => emitted.push(value)
);
dispatch((prev) => `${prev}1`);
expect(emitted).toEqual(['a1']);
// Commit lands (possibly with an external edit), chain is reset.
committed = 'typed';
reset();
dispatch((prev) => `${prev}!`);
expect(emitted).toEqual(['a1', 'typed!']);
});
});