fix(teacher): 独立刷新老师目录以缩短配置更新延迟
This commit is contained in:
67
tests/unit/teacher-catalog-refresh.test.tsx
Normal file
67
tests/unit/teacher-catalog-refresh.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
// Actual hook against a changed server catalog, independently of chat activity.
|
||||
import { act, cleanup, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { useTeacherCompanion } from '@/pages/Chat/use-teacher-companion';
|
||||
import type { TeacherDefinition, TeacherTopic } from '../../shared/coding-teacher';
|
||||
|
||||
const api = vi.hoisted(() => ({ config: vi.fn(), catalog: vi.fn(), catalogRevision: vi.fn(), conversation: vi.fn(), list: vi.fn(), read: vi.fn(), events: vi.fn() }));
|
||||
vi.mock('@/lib/coding-teacher', () => ({ teacherApi: api, teacherTopicsPath: (id: string) => id, teacherConversationsPath: (id: string) => id }));
|
||||
vi.mock('@/stores/auth', () => ({ useAuthStore: (select: (state: unknown) => unknown) => select({ user: { userId: 'diagnostic-user' } }) }));
|
||||
const definition: TeacherDefinition = { schema_version: 1, teacher_id: 'coding-teacher', config_id: 'agent-a', name: 'Old name', description: '', avatar_id: 'avatar-06', welcome_message: '', suggested_questions: [], system_prompt: '', skills: [], model: { model_id: 'model', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 } };
|
||||
const original = { teacher_id: 'agent-a', version: 1, is_default: true, definition };
|
||||
const updated = { ...original, version: 2, definition: { ...definition, name: 'New name' } };
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); vi.useFakeTimers(); localStorage.clear();
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
api.config.mockResolvedValue({ enabled: true, definition });
|
||||
api.catalog.mockResolvedValue({ revision: 1, items: [original] });
|
||||
api.catalogRevision.mockResolvedValue({ revision: 1 });
|
||||
api.conversation.mockResolvedValue({ topic: null, before: null });
|
||||
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
|
||||
api.events.mockResolvedValue(Object.assign(new EventTarget(), { close: vi.fn() }));
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
|
||||
it.each(['empty-catalog', 'never-started', 'running', 'completed'] as const)('refreshes changed catalog within five seconds with %s chat', async (state) => {
|
||||
if (state === 'empty-catalog') api.catalog.mockResolvedValue({ revision: 1, items: [] });
|
||||
if (state === 'running' || state === 'completed') {
|
||||
const topic: TeacherTopic = { schemaVersion: 1, revision: 1, id: 'topic', accountId: 'diagnostic-user', projectId: 'project', sourceConversationId: 'project', version: 1, definition, createdAt: 'now', updatedAt: 'now', conversation: { agentId: 'agent-a', segmentTurns: 1, discussions: {} }, requests: [{ id: 'request', text: 'Question', references: [], createdAt: 'now', sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: state, response: '' }] };
|
||||
api.conversation.mockResolvedValue({ topic, before: null });
|
||||
}
|
||||
let view!: ReturnType<typeof renderHook<ReturnType<typeof useTeacherCompanion>, unknown>>;
|
||||
await act(async () => { view = renderHook(() => useTeacherCompanion({ projectId: 'project', sourceId: 'source', sourceBusy: false, sourceArchived: false, teacherOpen: false, teacherComposing: false })); });
|
||||
expect(view.result.current.selectedAgent?.version).toBe(state === 'empty-catalog' ? undefined : 1);
|
||||
api.catalog.mockResolvedValue({ revision: 2, items: [updated] });
|
||||
api.catalogRevision.mockResolvedValue({ revision: 2 });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
|
||||
expect(view.result.current.selectedAgent?.version).toBe(2);
|
||||
if (state === 'running') {
|
||||
expect(view.result.current.topic?.requests[0].status).toBe('running');
|
||||
expect(view.result.current.topic?.version).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps unchanged catalogs, pauses while hidden, retries failures and refreshes immediately on focus', async () => {
|
||||
let view!: ReturnType<typeof renderHook<ReturnType<typeof useTeacherCompanion>, unknown>>;
|
||||
await act(async () => {
|
||||
view = renderHook(() => useTeacherCompanion({ projectId: 'project', sourceId: 'source', sourceBusy: true, sourceArchived: false, teacherOpen: true, teacherComposing: true }));
|
||||
});
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000); });
|
||||
expect(api.catalog).toHaveBeenCalledTimes(1);
|
||||
expect(api.catalogRevision).toHaveBeenCalledTimes(2);
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000); });
|
||||
expect(api.catalogRevision).toHaveBeenCalledTimes(2);
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
api.catalogRevision.mockRejectedValueOnce(new Error('offline'));
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')); });
|
||||
expect(view.result.current.selectedAgent?.version).toBe(1);
|
||||
api.catalogRevision.mockResolvedValue({ revision: 2 });
|
||||
api.catalog.mockResolvedValue({ revision: 2, items: [updated] });
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')); });
|
||||
expect(view.result.current.selectedAgent?.version).toBe(2);
|
||||
view.unmount();
|
||||
const calls = api.catalogRevision.mock.calls.length;
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); window.dispatchEvent(new Event('focus')); });
|
||||
expect(api.catalogRevision).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
34
tests/unit/teacher-catalog-revision.test.ts
Normal file
34
tests/unit/teacher-catalog-revision.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
const mock = vi.hoisted(() => ({
|
||||
binding: vi.fn(), current: vi.fn(), token: vi.fn(), fetch: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../electron/services/works-square-session', () => ({
|
||||
getWorksSquareAccountBinding: mock.binding,
|
||||
isCurrentWorksSquareAccountBinding: mock.current,
|
||||
getValidWorksSquareAccessToken: mock.token,
|
||||
}));
|
||||
vi.mock('../../electron/utils/proxy-fetch', () => ({ proxyAwareFetch: mock.fetch }));
|
||||
import { teacherCatalogRevision } from '../../electron/coding-teacher/config-client';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mock.binding.mockReturnValue({ accountId: 'student', generation: 1 });
|
||||
mock.current.mockReturnValue(true);
|
||||
mock.token.mockResolvedValue('student-token');
|
||||
mock.fetch.mockResolvedValue(new Response(JSON.stringify({ revision: 12 })));
|
||||
});
|
||||
|
||||
it('reads only the small directory revision with current account authentication', async () => {
|
||||
expect(await teacherCatalogRevision()).toEqual({ revision: 12 });
|
||||
expect(mock.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(mock.fetch.mock.calls[0][0]).toMatch(/\/api\/coding-teacher\/teachers\/revision$/);
|
||||
expect(mock.fetch.mock.calls[0][1].headers.Authorization).toBe('Bearer student-token');
|
||||
});
|
||||
|
||||
it('discards a response when the account changes while waiting', async () => {
|
||||
mock.fetch.mockImplementation(async () => {
|
||||
mock.current.mockReturnValue(false);
|
||||
return new Response(JSON.stringify({ revision: 99 }));
|
||||
});
|
||||
await expect(teacherCatalogRevision()).rejects.toMatchObject({ code: 'teacher_account_changed' });
|
||||
});
|
||||
Reference in New Issue
Block a user