mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
* feat: 모바일 터미널 한글 미러 스텝 순수 모델 추가 * feat: 미러 델타 순서 보장용 send 체인 추가 * fix: 모바일 터미널 한글 입력을 미러 모델로 전환 * fix: 탭 상태 지연 중 한글 조합 상태 소실 방지 * fix: 미러 가드와 send 체인 리뷰 지적사항 반영 탭 상태 지연으로 활성 탭 타입이 일시적으로 null이 될 때 runMirrorStep의 stale-handle 가드가 조합 중 음절을 버리지 않도록 pending-clear 효과와 동일한 null 허용 패턴 적용. 테스트 하네스가 ref와 prop을 동일 소스에서 파생하도록 결합해 실제 경로의 lag 프레임을 검증. queueTerminalLiveMirrorSend의 previousSend await를 catch로 보호. * refactor(mobile): drop dead queueTerminalLivePendingFlush orphaned by the mirror model The mirror model migrated all live-input sends to queueTerminalLiveMirrorSend, leaving queueTerminalLivePendingFlush referenced only by its own tests. Remove the dead function and its three tests. Co-authored-by: Orca <help@stably.ai> * fix(mobile): expose live terminal keyboard target Co-authored-by: Orca <help@stably.ai> * fix(mobile): refocus live keyboard after dismissal Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: realitsyourman <wongil@demodev.io> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca <help@stably.ai>
231 lines
7.5 KiB
TypeScript
231 lines
7.5 KiB
TypeScript
import { createElement, type RefObject } from 'react'
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
|
import type { TextInput } from 'react-native'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
|
|
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
|
import {
|
|
getTerminalLiveAccessoryInactiveInputCommitResult,
|
|
useTerminalLiveAccessoryInputCommit,
|
|
type TerminalLiveAccessoryInputCommitResult
|
|
} from './use-terminal-live-accessory-input-commit'
|
|
|
|
type DeferredBoolean = {
|
|
readonly promise: Promise<boolean>
|
|
readonly resolve: (value: boolean) => void
|
|
}
|
|
|
|
function createDeferredBoolean(): DeferredBoolean {
|
|
let resolvePromise: (value: boolean) => void = () => {
|
|
throw new Error('deferred promise was resolved before initialization')
|
|
}
|
|
const promise = new Promise<boolean>((resolve) => {
|
|
resolvePromise = resolve
|
|
})
|
|
return { promise, resolve: resolvePromise }
|
|
}
|
|
|
|
function suppressReactTestRendererDeprecationWarning(): () => void {
|
|
const originalConsoleError = console.error
|
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
const firstArg = args[0]
|
|
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
|
return
|
|
}
|
|
originalConsoleError(...args)
|
|
})
|
|
return () => consoleErrorSpy.mockRestore()
|
|
}
|
|
|
|
type AccessoryInputCommitHarnessOptions = {
|
|
readonly heldText?: string
|
|
readonly sentText?: string
|
|
readonly pendingHandle?: string | null
|
|
readonly sendResult?: boolean
|
|
readonly flushResult?: boolean
|
|
readonly waitResult?: boolean
|
|
}
|
|
|
|
type AccessoryInputCommitHarness = {
|
|
readonly commit: (
|
|
input: TerminalLiveAccessoryInput
|
|
) => Promise<TerminalLiveAccessoryInputCommitResult>
|
|
readonly sent: readonly string[]
|
|
readonly applyLiveInputMirror: ReturnType<typeof vi.fn>
|
|
readonly flushPendingLiveInputText: ReturnType<typeof vi.fn>
|
|
readonly waitForPendingLiveInputFlush: ReturnType<typeof vi.fn>
|
|
readonly unmount: () => void
|
|
}
|
|
|
|
function createAccessoryInputCommitHarness({
|
|
heldText = '',
|
|
sentText = '',
|
|
pendingHandle = null,
|
|
sendResult = true,
|
|
flushResult = true,
|
|
waitResult = true
|
|
}: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness {
|
|
const activeHandle = 'terminal-a'
|
|
const heldLiveInputTextRef: RefObject<string> = { current: heldText }
|
|
const sentLiveInputTextRef: RefObject<string> = { current: sentText }
|
|
const pendingLiveInputHandleRef: RefObject<string | null> = { current: pendingHandle }
|
|
const liveInputRef: RefObject<TextInput | null> = { current: null }
|
|
const liveInputTerminalHandles = new Set([activeHandle])
|
|
const sent: string[] = []
|
|
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
|
|
current: async (_handle, bytes) => {
|
|
sent.push(bytes)
|
|
return sendResult
|
|
}
|
|
}
|
|
const applyLiveInputMirror = vi.fn((_handle: string, _fieldText: string) => {})
|
|
const clearPendingLiveInputCommit = vi.fn(() => {})
|
|
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
|
|
const waitForPendingLiveInputFlush = vi.fn(async () => waitResult)
|
|
const setLiveInputCapture = vi.fn((_text: string) => {})
|
|
|
|
let commit: AccessoryInputCommitHarness['commit'] | null = null
|
|
let renderer: ReactTestRenderer | null = null
|
|
|
|
function Harness(): null {
|
|
commit = useTerminalLiveAccessoryInputCommit({
|
|
activeHandle,
|
|
applyLiveInputMirror,
|
|
clearPendingLiveInputCommit,
|
|
flushPendingLiveInputText,
|
|
heldLiveInputTextRef,
|
|
liveInputRef,
|
|
liveInputTerminalHandles,
|
|
pendingLiveInputHandleRef,
|
|
sentLiveInputTextRef,
|
|
sendLiveTerminalInputRef,
|
|
setLiveInputCapture,
|
|
waitForPendingLiveInputFlush
|
|
})
|
|
return null
|
|
}
|
|
|
|
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
|
try {
|
|
act(() => {
|
|
renderer = create(createElement(Harness))
|
|
})
|
|
} finally {
|
|
restoreConsoleError()
|
|
}
|
|
if (!commit || !renderer) {
|
|
throw new Error('terminal live accessory input hook did not render')
|
|
}
|
|
|
|
return {
|
|
commit,
|
|
sent,
|
|
applyLiveInputMirror,
|
|
flushPendingLiveInputText,
|
|
waitForPendingLiveInputFlush,
|
|
unmount: () => {
|
|
act(() => renderer?.unmount())
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('terminal live accessory inactive input commit result', () => {
|
|
it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => {
|
|
// Given
|
|
const deferredFlush = createDeferredBoolean()
|
|
let settled = false
|
|
|
|
// When
|
|
const resultPromise = getTerminalLiveAccessoryInactiveInputCommitResult(
|
|
() => deferredFlush.promise
|
|
)
|
|
void resultPromise.then(() => {
|
|
settled = true
|
|
})
|
|
await Promise.resolve()
|
|
|
|
// Then
|
|
expect(settled).toBe(false)
|
|
deferredFlush.resolve(true)
|
|
await expect(resultPromise).resolves.toEqual({ kind: 'allow-raw' })
|
|
})
|
|
|
|
it('Given live input is disabled with a failed active flush When accessory raw fallback is requested Then suppresses raw send', async () => {
|
|
// Given
|
|
const waitForPendingLiveInputFlush = async (): Promise<boolean> => false
|
|
|
|
// When
|
|
const result = await getTerminalLiveAccessoryInactiveInputCommitResult(
|
|
waitForPendingLiveInputFlush
|
|
)
|
|
|
|
// Then
|
|
expect(result).toEqual({ kind: 'suppress-raw' })
|
|
})
|
|
})
|
|
|
|
describe('terminal live accessory input commit hook', () => {
|
|
it('Given raw accessory bytes with a held syllable When committed Then flushes held text before sending bytes', async () => {
|
|
// Given
|
|
const harness = createAccessoryInputCommitHarness({
|
|
heldText: '한',
|
|
sentText: '',
|
|
pendingHandle: 'terminal-a'
|
|
})
|
|
|
|
// When
|
|
const result = await harness.commit({ bytes: '\x1b' })
|
|
|
|
// Then
|
|
expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a')
|
|
expect(harness.sent).toEqual(['\x1b'])
|
|
expect(result).toEqual({ kind: 'handled' })
|
|
})
|
|
|
|
it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => {
|
|
// Given
|
|
const harness = createAccessoryInputCommitHarness({ pendingHandle: null })
|
|
|
|
// When
|
|
const result = await harness.commit({ bytes: '\x1b' })
|
|
|
|
// Then
|
|
expect(result).toEqual({ kind: 'allow-raw' })
|
|
expect(harness.flushPendingLiveInputText).not.toHaveBeenCalled()
|
|
expect(harness.sent).toEqual([])
|
|
})
|
|
|
|
it('Given accessory backspace with a held syllable When committed Then mirrors the emptied field without terminal bytes', async () => {
|
|
// Given
|
|
const harness = createAccessoryInputCommitHarness({
|
|
heldText: '한',
|
|
sentText: '',
|
|
pendingHandle: 'terminal-a'
|
|
})
|
|
|
|
// When
|
|
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
|
|
|
|
// Then
|
|
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '')
|
|
expect(result).toEqual({ kind: 'handled' })
|
|
expect(harness.sent).toEqual([])
|
|
})
|
|
|
|
it('Given accessory backspace with mirrored sent text When committed Then mirrors the shortened field so the diff emits DEL', async () => {
|
|
// Given
|
|
const harness = createAccessoryInputCommitHarness({
|
|
heldText: '',
|
|
sentText: 'ab',
|
|
pendingHandle: 'terminal-a'
|
|
})
|
|
|
|
// When
|
|
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
|
|
|
|
// Then
|
|
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a')
|
|
expect(result).toEqual({ kind: 'handled' })
|
|
})
|
|
})
|