2026-08-02 16:22:55 +03:00
|
|
|
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
|
|
|
import type { WalkthroughResult, WalkthroughSource } from '@/lib/walkthrough/types';
|
|
|
|
|
|
|
|
|
|
const SOURCE: WalkthroughSource = { kind: 'working-tree', scope: 'all' };
|
|
|
|
|
|
|
|
|
|
const result = (overrides: Partial<WalkthroughResult> = {}): WalkthroughResult => ({
|
|
|
|
|
source: SOURCE,
|
|
|
|
|
walkthrough: null,
|
|
|
|
|
hunks: [],
|
|
|
|
|
hunkCount: 0,
|
|
|
|
|
...overrides,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const finished = result({
|
|
|
|
|
walkthrough: {
|
|
|
|
|
title: 'Change',
|
|
|
|
|
focus: '',
|
|
|
|
|
chapters: [{
|
|
|
|
|
id: 'chapter-1',
|
|
|
|
|
title: 'Data',
|
|
|
|
|
icon: 'doc',
|
|
|
|
|
blurb: '',
|
|
|
|
|
stops: [{ id: 'stop-1-1', title: 'A', hunkIds: ['h'], importance: 'normal', prose: 'p' }],
|
|
|
|
|
}],
|
|
|
|
|
},
|
|
|
|
|
generatedAt: '2026-08-02T00:00:00.000Z',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Plain closures rather than mock helpers: bun's `mock()` is not typed with
|
|
|
|
|
// vitest's `mockResolvedValue` family, and the repo already prefers this style.
|
|
|
|
|
let readResult: WalkthroughResult = result();
|
|
|
|
|
let generateCalls = 0;
|
|
|
|
|
let releaseGeneration: (() => void) | undefined;
|
|
|
|
|
let lastReadModel: string | undefined;
|
|
|
|
|
let lastGenerateModel: string | undefined;
|
2026-08-03 01:27:27 +03:00
|
|
|
let lastReadLanguage: string | undefined;
|
|
|
|
|
let lastGenerateLanguage: string | undefined;
|
2026-08-02 16:22:55 +03:00
|
|
|
|
|
|
|
|
mock.module('@/lib/walkthrough/api', () => ({
|
|
|
|
|
fetchWalkthrough: async (
|
|
|
|
|
_directory: string,
|
|
|
|
|
_source: WalkthroughSource,
|
2026-08-03 01:27:27 +03:00
|
|
|
options: { model?: string; language?: string } = {},
|
2026-08-02 16:22:55 +03:00
|
|
|
) => {
|
|
|
|
|
lastReadModel = options.model;
|
2026-08-03 01:27:27 +03:00
|
|
|
lastReadLanguage = options.language;
|
2026-08-02 16:22:55 +03:00
|
|
|
return readResult;
|
|
|
|
|
},
|
|
|
|
|
generateWalkthrough: async (
|
|
|
|
|
_directory: string,
|
|
|
|
|
_source: WalkthroughSource,
|
2026-08-03 01:27:27 +03:00
|
|
|
options: { model?: string; language?: string } = {},
|
2026-08-02 16:22:55 +03:00
|
|
|
) => {
|
|
|
|
|
generateCalls += 1;
|
|
|
|
|
lastGenerateModel = options.model;
|
2026-08-03 01:27:27 +03:00
|
|
|
lastGenerateLanguage = options.language;
|
2026-08-02 16:22:55 +03:00
|
|
|
return new Promise<WalkthroughResult>((resolve) => {
|
|
|
|
|
releaseGeneration = () => resolve(finished);
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
cancelWalkthroughGeneration: async () => {},
|
2026-08-03 01:27:27 +03:00
|
|
|
// The store imports this for its progress poller. Leaving it out of the mock
|
|
|
|
|
// makes the whole module fail to load, which reads as an unrelated crash.
|
|
|
|
|
fetchWalkthroughStage: async () => null,
|
2026-08-02 16:22:55 +03:00
|
|
|
}));
|
|
|
|
|
mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'local' }));
|
|
|
|
|
|
|
|
|
|
const { useWalkthroughStore } = await import('./useWalkthroughStore');
|
|
|
|
|
|
|
|
|
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
|
|
|
|
|
|
describe('useWalkthroughStore — reattaching to a running generation', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
useWalkthroughStore.getState().reset();
|
|
|
|
|
readResult = result();
|
|
|
|
|
generateCalls = 0;
|
|
|
|
|
releaseGeneration = undefined;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
useWalkthroughStore.getState().reset();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a reload that finds work in progress ends up showing the finished result', async () => {
|
|
|
|
|
// What a refresh looks like: the read says a job is running, and the
|
|
|
|
|
// generation the client re-attaches to finishes a moment later.
|
|
|
|
|
readResult = result({ generating: true });
|
|
|
|
|
|
|
|
|
|
await useWalkthroughStore.getState().load('/repo', SOURCE);
|
|
|
|
|
await flush();
|
|
|
|
|
|
|
|
|
|
expect(generateCalls).toBe(1);
|
|
|
|
|
expect(useWalkthroughStore.getState().getEntry('/repo', SOURCE).status).toBe('generating');
|
|
|
|
|
|
|
|
|
|
releaseGeneration?.();
|
|
|
|
|
await flush();
|
|
|
|
|
|
|
|
|
|
const entry = useWalkthroughStore.getState().getEntry('/repo', SOURCE);
|
|
|
|
|
expect(entry.status).toBe('ready');
|
|
|
|
|
expect(entry.result?.walkthrough?.title).toBe('Change');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('does not re-attach when nothing is running', async () => {
|
|
|
|
|
readResult = result({ generating: false });
|
|
|
|
|
|
|
|
|
|
await useWalkthroughStore.getState().load('/repo', SOURCE);
|
|
|
|
|
await flush();
|
|
|
|
|
|
|
|
|
|
expect(generateCalls).toBe(0);
|
|
|
|
|
expect(useWalkthroughStore.getState().getEntry('/repo', SOURCE).status).toBe('ready');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a load while generating does not overwrite the pending state', async () => {
|
|
|
|
|
readResult = result({ generating: true });
|
|
|
|
|
await useWalkthroughStore.getState().load('/repo', SOURCE);
|
|
|
|
|
await flush();
|
|
|
|
|
|
|
|
|
|
await useWalkthroughStore.getState().load('/repo', SOURCE);
|
|
|
|
|
await flush();
|
|
|
|
|
|
|
|
|
|
expect(generateCalls).toBe(1);
|
|
|
|
|
expect(useWalkthroughStore.getState().getEntry('/repo', SOURCE).status).toBe('generating');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('useWalkthroughStore — model selection', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
useWalkthroughStore.getState().reset();
|
|
|
|
|
readResult = result();
|
|
|
|
|
generateCalls = 0;
|
|
|
|
|
lastReadModel = undefined;
|
|
|
|
|
lastGenerateModel = undefined;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
useWalkthroughStore.getState().reset();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('sends the chosen model with both the read and the generation', async () => {
|
|
|
|
|
useWalkthroughStore.getState().selectModel('/repo', SOURCE, 'anthropic/claude-haiku-4-5');
|
|
|
|
|
|
|
|
|
|
await useWalkthroughStore.getState().load('/repo', SOURCE);
|
|
|
|
|
await flush();
|
|
|
|
|
expect(lastReadModel).toBe('anthropic/claude-haiku-4-5');
|
|
|
|
|
|
|
|
|
|
void useWalkthroughStore.getState().generate('/repo', SOURCE);
|
|
|
|
|
await flush();
|
|
|
|
|
expect(lastGenerateModel).toBe('anthropic/claude-haiku-4-5');
|
|
|
|
|
releaseGeneration?.();
|
|
|
|
|
await flush();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('clearing the choice falls back to whatever the server resolves', async () => {
|
|
|
|
|
useWalkthroughStore.getState().selectModel('/repo', SOURCE, 'anthropic/claude-haiku-4-5');
|
|
|
|
|
useWalkthroughStore.getState().selectModel('/repo', SOURCE, null);
|
|
|
|
|
|
|
|
|
|
await useWalkthroughStore.getState().load('/repo', SOURCE);
|
|
|
|
|
await flush();
|
|
|
|
|
|
|
|
|
|
expect(lastReadModel).toBe(undefined);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('keeps choices apart per source', async () => {
|
|
|
|
|
const branch: WalkthroughSource = { kind: 'branch', baseRef: 'main', headRef: 'feature' };
|
|
|
|
|
useWalkthroughStore.getState().selectModel('/repo', SOURCE, 'anthropic/claude-haiku-4-5');
|
|
|
|
|
|
|
|
|
|
expect(useWalkthroughStore.getState().getSelectedModel("/repo", branch)).toBe(undefined);
|
|
|
|
|
expect(useWalkthroughStore.getState().getSelectedModel('/repo', SOURCE))
|
|
|
|
|
.toBe('anthropic/claude-haiku-4-5');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-03 01:27:27 +03:00
|
|
|
|
|
|
|
|
describe('useWalkthroughStore — walkthrough language', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
useWalkthroughStore.getState().reset();
|
|
|
|
|
readResult = result();
|
|
|
|
|
generateCalls = 0;
|
|
|
|
|
lastReadLanguage = undefined;
|
|
|
|
|
lastGenerateLanguage = undefined;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
useWalkthroughStore.getState().reset();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The read carries it too: readiness is an answer about a specific request,
|
|
|
|
|
// and the language instruction is part of that request.
|
|
|
|
|
test('sends the resolved language with both the read and the generation', async () => {
|
|
|
|
|
await useWalkthroughStore.getState().load('/repo', SOURCE, { language: 'uk' });
|
|
|
|
|
await flush();
|
|
|
|
|
expect(lastReadLanguage).toBe('uk');
|
|
|
|
|
|
|
|
|
|
void useWalkthroughStore.getState().generate('/repo', SOURCE, { language: 'uk' });
|
|
|
|
|
await flush();
|
|
|
|
|
expect(lastGenerateLanguage).toBe('uk');
|
|
|
|
|
releaseGeneration?.();
|
|
|
|
|
await flush();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('keeps an explicit choice apart per source', () => {
|
|
|
|
|
const branch: WalkthroughSource = { kind: 'branch', baseRef: 'main', headRef: 'feature' };
|
|
|
|
|
useWalkthroughStore.getState().selectLanguage('/repo', SOURCE, 'ja');
|
|
|
|
|
|
|
|
|
|
expect(useWalkthroughStore.getState().getSelectedLanguage('/repo', branch)).toBe(undefined);
|
|
|
|
|
expect(useWalkthroughStore.getState().getSelectedLanguage('/repo', SOURCE)).toBe('ja');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('clearing the choice returns to no explicit language', () => {
|
|
|
|
|
useWalkthroughStore.getState().selectLanguage('/repo', SOURCE, 'ja');
|
|
|
|
|
useWalkthroughStore.getState().selectLanguage('/repo', SOURCE, null);
|
|
|
|
|
|
|
|
|
|
expect(useWalkthroughStore.getState().getSelectedLanguage('/repo', SOURCE)).toBe(undefined);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a re-attach after a reload still names the language it would ask for', async () => {
|
|
|
|
|
readResult = result({ generating: true });
|
|
|
|
|
|
|
|
|
|
await useWalkthroughStore.getState().load('/repo', SOURCE, { language: 'pl' });
|
|
|
|
|
await flush();
|
|
|
|
|
|
|
|
|
|
expect(lastGenerateLanguage).toBe('pl');
|
|
|
|
|
releaseGeneration?.();
|
|
|
|
|
await flush();
|
|
|
|
|
});
|
|
|
|
|
});
|