227 lines
8.2 KiB
TypeScript
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!']);
|
|
});
|
|
});
|