Files
NetMesh/components/ai/ChatInput.test.tsx

353 lines
12 KiB
TypeScript
Raw Permalink Normal View History

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ChatInput from './ChatInput';
import {
CHAT_INPUT_MAX_HEIGHT,
CHAT_INPUT_MIN_HEIGHT,
resolveChatInputAriaHeight,
resolveChatInputMaxHeight,
resolveChatInputResizeHeight,
resolveVisibleChatInputHeight,
resolveVisibleChatInputMaxHeight,
} from './chatInputResize';
import { TooltipProvider } from '../ui/tooltip';
test('clamps composer dragging to the usable pane height', () => {
assert.equal(resolveChatInputMaxHeight(900), CHAT_INPUT_MAX_HEIGHT);
assert.equal(resolveChatInputMaxHeight(180), CHAT_INPUT_MIN_HEIGHT);
assert.equal(resolveChatInputResizeHeight(128, 500, 420, 360), 208);
assert.equal(resolveChatInputResizeHeight(128, 500, 700, 360), CHAT_INPUT_MIN_HEIGHT);
assert.equal(resolveChatInputResizeHeight(300, 500, 300, 360), 360);
});
test('keeps the requested composer height while the pane is temporarily hidden or constrained', () => {
assert.equal(resolveVisibleChatInputMaxHeight(0), null);
assert.equal(resolveVisibleChatInputMaxHeight(Number.NaN), null);
assert.equal(resolveVisibleChatInputHeight(360, 220), 220);
assert.equal(resolveVisibleChatInputHeight(360, 400), 360);
assert.equal(resolveVisibleChatInputHeight(null, 400), null);
});
test('reports an accessible composer height inside the available range', () => {
assert.equal(resolveChatInputAriaHeight(null, CHAT_INPUT_MIN_HEIGHT), CHAT_INPUT_MIN_HEIGHT);
assert.equal(resolveChatInputAriaHeight(360, 220), 220);
assert.equal(resolveChatInputAriaHeight(160, 220), 160);
});
test('renders an accessible composer resize handle', () => {
const html = renderToStaticMarkup(
<TooltipProvider>
<ChatInput value="" onChange={() => {}} onSend={() => {}} />
</TooltipProvider>,
);
assert.match(html, /role="separator"/);
assert.match(html, /aria-orientation="horizontal"/);
assert.match(html, /aria-label="ai\.chat\.resizeInput"/);
assert.match(html, /cursor-ns-resize/);
});
test('expanded composer grows the text area while keeping controls at the bottom', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /data-section="ai-chat-input-body"/);
assert.match(source, /composerHeight != null \? 'flex min-h-0 flex-1 flex-col'/);
assert.match(source, /data-section="ai-chat-input-footer"/);
assert.match(source, /className="shrink-0/);
assert.doesNotMatch(source, /<Expand/);
assert.doesNotMatch(source, /setExpanded/);
});
test('parked composer closes body-portaled menus', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /parked = false/);
assert.match(source, /if \(parked\) closeAllMenus\(\)/);
assert.match(source, /becameParked/);
});
test('first keystrokes stay local so IME and Chromium spellcheck cannot stall the composer', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /spellCheck=\{false\}/);
assert.match(source, /autoCorrect="off"/);
assert.match(source, /autoCapitalize="off"/);
assert.match(source, /nativeEvent\.isComposing/);
assert.match(source, /onCompositionEnd=/);
assert.match(source, /onBlur=\{\(\) => commitComposerText\(readComposerText\(\)\)\}/);
assert.match(source, /onChangeRef\.current\(textareaRef\.current\?\.value \?\? composerTextRef\.current\)/);
assert.doesNotMatch(source, /onSend\(\);\s*commitComposerText\(''\);/);
assert.match(source, /defaultValue=\{value\}/);
assert.match(source, /field-sizing-fixed/);
assert.doesNotMatch(source, /value=\{composerText\}/);
assert.match(source, /createComposerHasTextStore/);
assert.match(source, /ComposerSendUi/);
assert.match(source, /chatInputPropsAreEqual/);
});
test('composer resizing also ends when pointer capture is unexpectedly lost', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /resizeStartRef\.current = null;[\s\S]*releasePointerCapture/);
assert.match(source, /onLostPointerCapture=\{handleComposerResizeEnd\}/);
});
test('virtualizes the host mention list without changing its option contract', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /VariableSizeVirtualList/);
assert.match(source, /ref=\{atMentionListRef\}/);
assert.match(source, /aria-activedescendant=\{hosts\[activeMenuIndex\] \? `at-mention-/);
assert.match(source, /onMouseEnter=\{\(\) => setActiveMenuIndex\(idx\)\}/);
assert.match(source, /onClick=\{\(\) => handleSelectAtMention\(host\)\}/);
assert.match(source, /max-h-\[280px\]/);
});
test('does not render a standalone slash command toolbar button', () => {
const html = renderToStaticMarkup(
<TooltipProvider>
<ChatInput
value=""
onChange={() => {}}
onSend={() => {}}
isStreaming={false}
disabled={false}
agentName="Catty Agent"
quickMessages={[{
id: 'qm-1',
slug: 'hello',
name: 'Hello',
description: 'Greeting',
content: 'Say hello',
}]}
/>
</TooltipProvider>,
);
assert.match(html, /textarea/);
assert.doesNotMatch(html, /aria-label="ai\.chat\.slashCommands"/);
});
test('renders separate steer and stop actions for a running Codex App Server turn', () => {
const html = renderToStaticMarkup(
<TooltipProvider>
<ChatInput
value="change direction"
onChange={() => {}}
onSend={() => {}}
onSteer={() => {}}
onStop={() => {}}
isStreaming
canSteer
lockTurnConfiguration
disabled={false}
agentName="Codex"
modelPresets={[{ id: 'gpt-test', name: 'GPT Test' }]}
selectedModelId="gpt-test"
onModelSelect={() => {}}
/>
</TooltipProvider>,
);
assert.match(html, /aria-label="ai\.codex\.steer\.addInstruction"/);
assert.match(html, /placeholder="ai\.codex\.steer\.placeholder"/);
assert.match(html, /aria-label="Stop"/);
assert.match(html, /disabled=""[^>]*aria-label="ai\.chat\.selectModel"/);
});
test('allows terminal-selection-only steering submissions', () => {
const html = renderToStaticMarkup(
<TooltipProvider>
<ChatInput
value=""
onChange={() => {}}
onSend={() => {}}
onSteer={() => {}}
onStop={() => {}}
isStreaming
canSteer
disabled={false}
agentName="Codex"
files={[{
id: 'terminal-selection',
filename: 'terminal-selection.txt',
dataUrl: 'data:text/plain;base64,dGVzdA==',
base64Data: 'dGVzdA==',
mediaType: 'text/plain',
terminalSelection: true,
lineCount: 1,
}]}
/>
</TooltipProvider>,
);
assert.match(html, /<form[^>]*data-allow-empty-submit="true"/);
assert.match(html, /aria-label="ai\.codex\.steer\.addInstruction"/);
assert.doesNotMatch(
html,
/<button[^>]*disabled=""[^>]*aria-label="ai\.codex\.steer\.addInstruction"/,
);
});
test('renders the Catty context usage ring after the model chip', () => {
const html = renderToStaticMarkup(
<TooltipProvider>
<ChatInput
value=""
onChange={() => {}}
onSend={() => {}}
agentName="Catty Agent"
contextUsage={{
sessionId: 'session-1',
inputTokens: 64_000,
contextWindow: 128_000,
estimated: true,
}}
/>
</TooltipProvider>,
);
assert.match(html, /role="progressbar"/);
assert.match(html, /stroke-dasharray=/);
assert.match(html, /stroke-dashoffset=/);
assert.match(html, /class="h-4 w-4"/);
assert.doesNotMatch(html, /text-\[7px\]/);
assert.match(html, /aria-valuenow="50"/);
});
test('slash picker system commands clear the local composer', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);\s*commitComposerText\(''\);/s);
});
test('keeps thinking on a separate chip instead of mixing it into the model list', () => {
const html = renderToStaticMarkup(
<TooltipProvider>
<ChatInput
value=""
onChange={() => {}}
onSend={() => {}}
agentName="Codex"
modelPresets={[{
id: 'gpt-5.5',
name: 'GPT-5.5',
thinkingLevels: ['low', 'medium', 'high'],
}]}
selectedModelId="gpt-5.5/high"
onModelSelect={() => {}}
/>
</TooltipProvider>,
);
assert.match(html, /aria-label="ai\.chat\.selectModel"/);
assert.match(html, /aria-label="ai\.chat\.thinkingLevel"/);
assert.match(html, />High</);
assert.doesNotMatch(html, /GPT-5\.5 \/ High/);
});
test('Catty composer exposes a provider switcher without a mixed thinking submenu', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /ComposerModelPicker/);
assert.match(source, /ComposerThinkingChip/);
assert.match(source, /cattyReasoningLevelsForSelection/);
assert.match(source, /if \(!thinkingLevel\) return;/);
assert.doesNotMatch(source, /showThinkingLevels/);
});
test('Catty thinking chip is hidden for OpenAI models that reject reasoningEffort', () => {
const openai = {
id: 'p1',
providerId: 'openai' as const,
name: 'OpenAI',
enabled: true,
};
const gpt4o = renderToStaticMarkup(
<TooltipProvider>
<ChatInput
value=""
onChange={() => {}}
onSend={() => {}}
agentName="Catty Agent"
thinkingLevel="high"
onThinkingLevelChange={() => {}}
providerSwitcher={{
providers: [openai],
selectedProviderId: 'p1',
selectedModelId: 'gpt-4o',
onSelect: () => {},
}}
/>
</TooltipProvider>,
);
assert.doesNotMatch(gpt4o, /aria-label="ai\.chat\.thinkingLevel"/);
const gpt51Chat = renderToStaticMarkup(
<TooltipProvider>
<ChatInput
value=""
onChange={() => {}}
onSend={() => {}}
agentName="Catty Agent"
thinkingLevel="high"
onThinkingLevelChange={() => {}}
providerSwitcher={{
providers: [openai],
selectedProviderId: 'p1',
selectedModelId: 'gpt-5.1-chat-latest',
onSelect: () => {},
}}
/>
</TooltipProvider>,
);
assert.doesNotMatch(gpt51Chat, /aria-label="ai\.chat\.thinkingLevel"/);
const gpt55 = renderToStaticMarkup(
<TooltipProvider>
<ChatInput
value=""
onChange={() => {}}
onSend={() => {}}
agentName="Catty Agent"
thinkingLevel="high"
onThinkingLevelChange={() => {}}
providerSwitcher={{
providers: [openai],
selectedProviderId: 'p1',
selectedModelId: 'gpt-5.5',
onSelect: () => {},
}}
/>
</TooltipProvider>,
);
assert.match(gpt55, /aria-label="ai\.chat\.thinkingLevel"/);
});
test('ChatInput wires /compact through getSystemSlashCommand and canCompact', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /getSystemSlashCommand/);
assert.match(source, /systemCommand === 'compact'/);
assert.match(source, /canCompact/);
assert.match(source, /onCompact\?\.\(\)/);
assert.match(source, /command\.slug !== 'compact' \|\| canCompact/);
});
test('renders a Mention Note picker entry with a searchable note list', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /openInputPanelMenu\('noteMention'\)/);
assert.match(source, /ai\.chat\.menuMentionNote/);
assert.match(source, /aria-activedescendant=\{noteMentionItems\[activeMenuIndex\] \? `\$\{noteListId\}/);
assert.match(source, /createVaultNoteSearchIndex\(notes\)/);
assert.match(source, /onClick=\{\(\) => handleSelectNoteMention\(note\)\}/);
assert.match(source, /onKeyDown=\{handleNoteMentionKeyDown\}/);
});
test('renders vault note attachment chips with the note title', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /file\.vaultNoteId \? \(/);
assert.match(source, /\{file\.vaultNoteTitle \|\| file\.filename\}/);
});