import { beforeEach, describe, expect, it, vi } from 'vitest'; import { downloadLearningCourse, listInstalledLearningCourses, saveLearningProgress, streamLearningRuntime, } from '@/lib/learning'; import type { LearningProgressWrite, LearningRuntimeBridgeRequest } from '../../shared/learning'; const hostApiFetchMock = vi.hoisted(() => vi.fn()); const invokeIpcMock = vi.hoisted(() => vi.fn()); const subscribeIpcEventMock = vi.hoisted(() => vi.fn()); vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), })); vi.mock('@/lib/api-client', () => ({ invokeIpc: (...args: unknown[]) => invokeIpcMock(...args), subscribeIpcEvent: (...args: unknown[]) => subscribeIpcEventMock(...args), })); describe('Learning renderer client', () => { beforeEach(() => { hostApiFetchMock.mockReset(); invokeIpcMock.mockReset(); subscribeIpcEventMock.mockReset(); hostApiFetchMock.mockResolvedValue({ success: true, data: { courseId: 'course-1', contentHash: 'a'.repeat(64), moduleId: 'module-1', sceneOrder: 2, actionIndex: 1, positionMs: 300, completed: false, updatedAt: '2026-08-16T00:00:00.000Z', }, }); }); it('projects installed course IPC results without exposing Main archive paths', async () => { const course = { id: 'course-1', origin: 'ops_large', status: 'published', title: 'Python 入门', }; const mainRecord = { schemaVersion: 1, course, installedAt: '2026-08-16T00:00:00.000Z', archivePath: 'C:\\Users\\private\\course.zip', }; invokeIpcMock .mockResolvedValueOnce(mainRecord) .mockResolvedValueOnce([mainRecord]); await expect(downloadLearningCourse('course-1')).resolves.toEqual({ schemaVersion: 1, course, installedAt: '2026-08-16T00:00:00.000Z', }); await expect(listInstalledLearningCourses()).resolves.toEqual([{ schemaVersion: 1, course, installedAt: '2026-08-16T00:00:00.000Z', }]); }); it('writes only the strict cloud progress contract and keeps module hashes local', async () => { const progress: LearningProgressWrite & { moduleContentHash: string } = { contentHash: 'a'.repeat(64), moduleId: 'module-1', moduleContentHash: 'b'.repeat(64), sceneOrder: 2, actionIndex: 1, positionMs: 300, completed: false, }; await saveLearningProgress('course-1', progress); expect(hostApiFetchMock).toHaveBeenCalledWith( '/api/works/learning/courses/course-1/progress', expect.objectContaining({ method: 'PUT' }), ); const init = hostApiFetchMock.mock.calls[0][1] as RequestInit; expect(JSON.parse(String(init.body))).toEqual({ contentHash: 'a'.repeat(64), moduleId: 'module-1', sceneOrder: 2, actionIndex: 1, positionMs: 300, completed: false, }); }); it('subscribes through the allowlisted API seam, rejects malformed runtime events, and cleans up', async () => { const request: LearningRuntimeBridgeRequest = { requestId: 'request-1', courseId: 'course-1', contentHash: 'a'.repeat(64), capability: 'quiz/v1/submit', method: 'POST', context: {}, body: { answer: 'A' }, }; const cleanup = vi.fn(); let listener: ((payload: unknown) => void) | undefined; subscribeIpcEventMock.mockImplementation((channel: string, callback: (payload: unknown) => void) => { expect(channel).toBe('learning:runtime-event'); listener = callback; return cleanup; }); invokeIpcMock.mockImplementation(async () => { listener?.({ requestId: 'request-1', type: 'chunk', chunk: 'not-bytes' }); listener?.({ requestId: 'other-request', type: 'end' }); listener?.({ requestId: 'request-1', type: 'start', status: 200, contentType: 'application/json' }); listener?.({ requestId: 'request-1', type: 'chunk', chunk: new Uint8Array([1, 2]) }); listener?.({ requestId: 'request-1', type: 'end' }); }); const events: unknown[] = []; await streamLearningRuntime(request, (event) => events.push(event)); expect(invokeIpcMock).toHaveBeenCalledWith('learning:runtimeRequest', request); expect(events).toEqual([ { requestId: 'request-1', type: 'start', status: 200, contentType: 'application/json' }, { requestId: 'request-1', type: 'chunk', chunk: new Uint8Array([1, 2]) }, { requestId: 'request-1', type: 'end' }, ]); expect(cleanup).toHaveBeenCalledTimes(1); }); it('cleans up the runtime subscription when the IPC request fails', async () => { const cleanup = vi.fn(); subscribeIpcEventMock.mockReturnValue(cleanup); invokeIpcMock.mockRejectedValue(new Error('request failed')); await expect(streamLearningRuntime({ requestId: 'request-2', courseId: 'course-1', contentHash: 'a'.repeat(64), capability: 'quiz/v1/submit', method: 'POST', context: {}, body: {}, }, vi.fn())).rejects.toThrow('request failed'); expect(cleanup).toHaveBeenCalledTimes(1); }); });