mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
b6f7488591
- the assistant keeps facts for a trip (remember_for_trip) as a change card, applied into the user's trip notes; existing expenses can be changed - change card fields as a few icon chips with the rest folded under Details, the day band marks only what changes, the week grid as compact tiles - no map button on receipts of things that have no place on the map - tests follow the composer's accessible name, the receipt's status icon and the GL map's container; the hanging stream helper honours an early abort - Postgres probe: qualified columns in the upsert updates; entities restored to the generator's output; strict ratchet reads the SDK client as untyped
250 lines
12 KiB
TypeScript
250 lines
12 KiB
TypeScript
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { adminApi } from '../../../api/client';
|
|
import { type Addon, DEFAULT_OLLAMA_URL, MASKED } from './addonModel';
|
|
import { useLlmParsingConfig } from './useLlmParsingConfig';
|
|
|
|
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-013
|
|
|
|
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
|
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
|
vi.mock('../../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
|
|
|
|
/** A local connection of parsing's own, so the form is open and lists the server's models. */
|
|
const OWN_LOCAL = { provider: 'local', model: 'm' };
|
|
|
|
function llm(config?: Record<string, unknown>): Addon {
|
|
return {
|
|
id: 'llm_parsing',
|
|
name: 'AI',
|
|
description: '',
|
|
icon: 'Sparkles',
|
|
type: 'integration',
|
|
enabled: true,
|
|
config,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
toast.success.mockReset();
|
|
toast.error.mockReset();
|
|
vi.spyOn(adminApi, 'llmLocalModels').mockResolvedValue({ models: [{ name: 'qwen3.5:4b', size: 1 }] });
|
|
vi.spyOn(adminApi, 'llmLocalPull').mockResolvedValue(undefined);
|
|
vi.spyOn(adminApi, 'updateAddon').mockResolvedValue({});
|
|
});
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
describe('useLlmParsingConfig', () => {
|
|
it('FE-HOOK-LLMCFG-001: starts from the stored config, defaulting to the local provider', () => {
|
|
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
|
expect(result.current.provider).toBe('local');
|
|
expect(result.current.model).toBe('');
|
|
expect(result.current.vision).toBe('auto');
|
|
const stored = renderHook(() =>
|
|
useLlmParsingConfig(llm({ provider: 'openai', model: 'gpt-4o', baseUrl: 'https://o', apiKey: MASKED }))
|
|
);
|
|
expect(stored.result.current.provider).toBe('openai');
|
|
expect(stored.result.current.apiKey).toBe(MASKED);
|
|
});
|
|
|
|
it('FE-HOOK-LLMCFG-002: the local provider lists the installed models from the default URL', async () => {
|
|
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
|
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
|
|
expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL);
|
|
expect(result.current.isInstalled('qwen3.5:4b')).toBe(true);
|
|
expect(result.current.isInstalled('llama3')).toBe(false);
|
|
});
|
|
|
|
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing; switched to local it starts from the default, then uses the typed URL', async () => {
|
|
const { result } = renderHook(() =>
|
|
useLlmParsingConfig(llm({ provider: 'openai', model: 'gpt', baseUrl: ' http://box:11434/v1 ' }))
|
|
);
|
|
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
|
await act(() => result.current.loadModels());
|
|
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
|
// The OpenAI address does not follow to another provider (#2655).
|
|
act(() => result.current.setProvider('local'));
|
|
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL));
|
|
act(() => result.current.setBaseUrl(' http://box:11434/v1 '));
|
|
await act(() => result.current.loadModels());
|
|
expect(adminApi.llmLocalModels).toHaveBeenLastCalledWith('http://box:11434/v1');
|
|
});
|
|
|
|
it('FE-HOOK-LLMCFG-004: an unreachable server shows its message and empties the list', async () => {
|
|
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(new Error('ECONNREFUSED'));
|
|
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
|
await waitFor(() => expect(result.current.modelsErr).toBe('ECONNREFUSED'));
|
|
expect(result.current.installed).toEqual([]);
|
|
expect(result.current.loadingModels).toBe(false);
|
|
vi.mocked(adminApi.llmLocalModels).mockRejectedValue('nope');
|
|
await act(() => result.current.loadModels());
|
|
expect(result.current.modelsErr).toBe('admin.addons.ai.parsing.unreachable');
|
|
});
|
|
|
|
it("FE-HOOK-LLMCFG-011: a refused list shows the server's reason, not the status line (#2649)", async () => {
|
|
const refused = Object.assign(new Error('Request failed with status code 502'), {
|
|
response: { status: 502, data: { error: 'Local LLM server rejected the API key (401)' } },
|
|
});
|
|
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(refused);
|
|
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
|
await waitFor(() => expect(result.current.modelsErr).toBe('Local LLM server rejected the API key (401)'));
|
|
});
|
|
|
|
it('FE-HOOK-LLMCFG-005: a pull reports progress, selects the model and lists again', async () => {
|
|
let progress!: (p: { status?: string; total?: number; completed?: number; error?: string }) => void;
|
|
let finish!: () => void;
|
|
vi.mocked(adminApi.llmLocalPull).mockImplementation((_url, _id, onProgress) => {
|
|
progress = onProgress;
|
|
return new Promise<void>((r) => (finish = r));
|
|
});
|
|
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
|
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
|
|
let pending!: Promise<void>;
|
|
act(() => {
|
|
pending = result.current.pull('qwen3.5:4b');
|
|
});
|
|
expect(result.current.pulling).toBe('qwen3.5:4b');
|
|
expect(result.current.pullStatus).toBe('admin.addons.ai.parsing.pulling');
|
|
act(() => progress({ status: 'downloading', total: 200, completed: 50 }));
|
|
expect(result.current.pullStatus).toBe('downloading');
|
|
expect(result.current.pullPct).toBe(25);
|
|
await act(async () => {
|
|
finish();
|
|
await pending;
|
|
});
|
|
expect(toast.success).toHaveBeenCalledWith('admin.addons.ai.parsing.pulled');
|
|
expect(result.current.model).toBe('qwen3.5:4b');
|
|
expect(adminApi.llmLocalModels).toHaveBeenCalledTimes(2);
|
|
expect(result.current.pulling).toBeNull();
|
|
expect(result.current.pullPct).toBe(0);
|
|
expect(result.current.pullStatus).toBe('');
|
|
});
|
|
|
|
it('FE-HOOK-LLMCFG-006: a second pull while one runs is ignored', async () => {
|
|
vi.mocked(adminApi.llmLocalPull).mockReturnValue(new Promise<void>(() => {}));
|
|
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
|
act(() => {
|
|
void result.current.pull('a');
|
|
});
|
|
await act(() => result.current.pull('b'));
|
|
expect(adminApi.llmLocalPull).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('FE-HOOK-LLMCFG-007: an error frame or a failed pull toasts the message', async () => {
|
|
vi.mocked(adminApi.llmLocalPull).mockImplementation(async (_url, _id, onProgress) => {
|
|
onProgress({ error: 'manifest unknown' });
|
|
});
|
|
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
|
await act(() => result.current.pull('x'));
|
|
expect(toast.error).toHaveBeenCalledWith('manifest unknown');
|
|
vi.mocked(adminApi.llmLocalPull).mockRejectedValue('nope');
|
|
await act(() => result.current.pull('x'));
|
|
expect(toast.error).toHaveBeenLastCalledWith('admin.addons.ai.parsing.pullFailed');
|
|
expect(result.current.pulling).toBeNull();
|
|
});
|
|
|
|
it('FE-HOOK-LLMCFG-008: save sends the trimmed config with the masked key unchanged', async () => {
|
|
const { result } = renderHook(() =>
|
|
useLlmParsingConfig(llm({ provider: 'openai', model: ' gpt-4o ', baseUrl: ' https://o/v1 ', apiKey: MASKED }))
|
|
);
|
|
await act(() => result.current.save());
|
|
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', {
|
|
config: { provider: 'openai', model: 'gpt-4o', baseUrl: 'https://o/v1', apiKey: MASKED, vision: 'auto' },
|
|
});
|
|
expect(toast.success).toHaveBeenCalledWith('settings.aiParsing.toast.saved');
|
|
expect(result.current.saving).toBe(false);
|
|
});
|
|
|
|
it("FE-HOOK-LLMCFG-009: switching to Anthropic drops the previous provider's base URL", async () => {
|
|
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' })));
|
|
await act(async () => result.current.setVision('off'));
|
|
act(() => result.current.setProvider('anthropic'));
|
|
await act(() => result.current.save());
|
|
const calls = vi.mocked(adminApi.updateAddon).mock.calls;
|
|
expect(calls[calls.length - 1]?.[1]).toEqual({
|
|
config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' },
|
|
});
|
|
});
|
|
|
|
it('FE-HOOK-LLMCFG-009b: an Anthropic gateway URL survives a save (#2655)', async () => {
|
|
const { result } = renderHook(() =>
|
|
useLlmParsingConfig(llm({ provider: 'anthropic', model: 'gateway-model', baseUrl: 'http://gateway.lan:4000' }))
|
|
);
|
|
expect(result.current.baseUrl).toBe('http://gateway.lan:4000');
|
|
await act(() => result.current.save());
|
|
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1].config).toMatchObject({
|
|
provider: 'anthropic',
|
|
baseUrl: 'http://gateway.lan:4000',
|
|
});
|
|
});
|
|
|
|
it('FE-HOOK-LLMCFG-009c: a URL typed after picking Anthropic is saved, trimmed', async () => {
|
|
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
|
|
act(() => result.current.setProvider('anthropic'));
|
|
act(() => result.current.setBaseUrl(' http://gw:4000 '));
|
|
await act(() => result.current.save());
|
|
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1].config).toMatchObject({ baseUrl: 'http://gw:4000' });
|
|
});
|
|
|
|
it('FE-HOOK-LLMCFG-009d: away and back again brings the saved URL back; staying put keeps a typed one', () => {
|
|
const { result } = renderHook(() =>
|
|
useLlmParsingConfig(llm({ provider: 'anthropic', baseUrl: 'http://gateway.lan:4000' }))
|
|
);
|
|
act(() => result.current.setProvider('local'));
|
|
expect(result.current.baseUrl).toBe('');
|
|
act(() => result.current.setProvider('openai'));
|
|
expect(result.current.baseUrl).toBe('');
|
|
act(() => result.current.setProvider('anthropic'));
|
|
expect(result.current.baseUrl).toBe('http://gateway.lan:4000');
|
|
act(() => result.current.setBaseUrl('http://typed:4000'));
|
|
act(() => result.current.setProvider('anthropic'));
|
|
expect(result.current.baseUrl).toBe('http://typed:4000');
|
|
});
|
|
|
|
it('FE-HOOK-LLMCFG-009e: the placeholder names the address an empty field stands for', () => {
|
|
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
|
|
expect(result.current.baseUrlPlaceholder).toBe('http://localhost:11434/v1');
|
|
act(() => result.current.setProvider('openai'));
|
|
expect(result.current.baseUrlPlaceholder).toBe('https://api.openai.com/v1');
|
|
act(() => result.current.setProvider('anthropic'));
|
|
expect(result.current.baseUrlPlaceholder).toBe('https://api.anthropic.com');
|
|
});
|
|
|
|
it('FE-HOOK-LLMCFG-010: a failed save toasts and clears saving', async () => {
|
|
vi.mocked(adminApi.updateAddon).mockRejectedValue(new Error('x'));
|
|
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai' })));
|
|
await act(() => result.current.save());
|
|
expect(toast.error).toHaveBeenCalledWith('settings.aiParsing.toast.saveError');
|
|
expect(result.current.saving).toBe(false);
|
|
});
|
|
|
|
it('FE-HOOK-LLMCFG-012: without its own connection the form stays shut and asks the server nothing until opened', async () => {
|
|
const { result } = renderHook(() => useLlmParsingConfig(llm({ vision: 'on' })));
|
|
expect(result.current.ownConnection).toBe(false);
|
|
expect(result.current.formOpen).toBe(false);
|
|
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
|
act(() => result.current.startOwn());
|
|
expect(result.current.formOpen).toBe(true);
|
|
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL));
|
|
// The vision setting alone is saved without a connection.
|
|
await act(async () => result.current.setVision('off'));
|
|
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('llm_parsing', { config: { vision: 'off' } });
|
|
act(() => result.current.cancelOwn());
|
|
expect(result.current.formOpen).toBe(false);
|
|
});
|
|
|
|
it('FE-HOOK-LLMCFG-013: going back to the shared connection drops the own one and keeps the vision setting', async () => {
|
|
const { result } = renderHook(() =>
|
|
useLlmParsingConfig(llm({ provider: 'anthropic', model: 'claude-x', apiKey: MASKED, vision: 'on' }))
|
|
);
|
|
expect(result.current.ownConnection).toBe(true);
|
|
expect(result.current.savedModel).toBe('claude-x');
|
|
await act(() => result.current.switchToShared());
|
|
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', { config: { vision: 'on' } });
|
|
expect(result.current.ownConnection).toBe(false);
|
|
expect(result.current.formOpen).toBe(false);
|
|
expect(result.current.model).toBe('');
|
|
});
|
|
});
|