P1-3: grader/interviewer node unit tests (24 passing)
grader.node.spec.ts — 13 tests: LLM mock validation, breakout logic (shorts/IDontKnow), error handling, scoring/indexing, zh/ja language support interviewer.node.spec.ts — 11 tests: empty questions, index bounds, standard presentation, follow-up mode, zh/ja/en localization
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { graderNode } from './grader.node';
|
||||
import { HumanMessage, AIMessage } from '@langchain/core/messages';
|
||||
|
||||
function mockModel(response: any) {
|
||||
return {
|
||||
invoke: jest.fn().mockResolvedValue({
|
||||
content: JSON.stringify(response),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function baseState(overrides: any = {}) {
|
||||
return {
|
||||
messages: [new HumanMessage('test answer')],
|
||||
questions: [{ id: 'q1', questionText: 'What is JS?', keyPoints: ['point1'], dimension: 'llm' }],
|
||||
currentQuestionIndex: 0,
|
||||
scores: {},
|
||||
feedbackHistory: [],
|
||||
followUpCount: 0,
|
||||
shouldFollowUp: false,
|
||||
questionCount: 5,
|
||||
language: 'en',
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('graderNode', () => {
|
||||
describe('validation guards', () => {
|
||||
it('should throw when model is missing', async () => {
|
||||
await expect(graderNode(baseState(), { configurable: {} } as any)).rejects.toThrow('Missing model');
|
||||
});
|
||||
|
||||
it('should return empty object when last message is not HumanMessage', async () => {
|
||||
const state = baseState({ messages: [new AIMessage('I am AI')] });
|
||||
const result = await graderNode(state, { configurable: { model: mockModel({}) } } as any);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should skip question and advance index when current question not found', async () => {
|
||||
const state = baseState({ currentQuestionIndex: 99, questions: [{ id: 'q1', questionText: 'Q', keyPoints: ['k'], dimension: 'llm' }] });
|
||||
const result = await graderNode(state, { configurable: { model: mockModel({}) } } as any);
|
||||
expect(result.currentQuestionIndex).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('breakout logic (shouldFollowUp overrides)', () => {
|
||||
it('should NOT follow up when followUpCount >= 2 even if LLM says follow up', async () => {
|
||||
const model = mockModel({ score: 5, feedback: 'needs work', should_follow_up: true });
|
||||
const state = baseState({ followUpCount: 2 });
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect(result.shouldFollowUp).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT follow up when score >= 8 even if LLM says follow up', async () => {
|
||||
const model = mockModel({ score: 9, feedback: 'good', should_follow_up: true });
|
||||
const state = baseState();
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect(result.shouldFollowUp).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT follow up when user says "I don\'t know"', async () => {
|
||||
const model = mockModel({ score: 2, feedback: 'no answer', should_follow_up: true });
|
||||
const state = baseState({ messages: [new HumanMessage("no idea")] });
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect(result.shouldFollowUp).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow follow up when conditions are met', async () => {
|
||||
const model = mockModel({ score: 5, feedback: 'incomplete', should_follow_up: true });
|
||||
const state = baseState({ followUpCount: 0 });
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect(result.shouldFollowUp).toBe(true);
|
||||
expect(result.followUpCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle LLM returning invalid JSON gracefully', async () => {
|
||||
const model = { invoke: jest.fn().mockResolvedValue({ content: 'NOT JSON' }) };
|
||||
const result = await graderNode(baseState(), { configurable: { model } } as any);
|
||||
expect(result.currentQuestionIndex).toBe(1);
|
||||
expect(result.shouldFollowUp).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoring and indexing', () => {
|
||||
it('should advance currentQuestionIndex when not following up', async () => {
|
||||
const model = mockModel({ score: 6, feedback: 'ok', should_follow_up: false });
|
||||
const result = await graderNode(baseState(), { configurable: { model } } as any);
|
||||
expect(result.currentQuestionIndex).toBe(1);
|
||||
expect(result.scores).toBeDefined();
|
||||
});
|
||||
|
||||
it('should keep currentQuestionIndex when following up', async () => {
|
||||
const model = mockModel({ score: 5, feedback: 'needs work', should_follow_up: true });
|
||||
const state = baseState({ followUpCount: 0 });
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect(result.currentQuestionIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('should record score under question id in scores map', async () => {
|
||||
const model = mockModel({ score: 7, feedback: 'good', should_follow_up: false });
|
||||
const state = baseState({ questions: [{ id: 'q-test', questionText: 'Q', keyPoints: ['k'], dimension: 'llm' }] });
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect((result.scores as any)['q-test']).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('language support', () => {
|
||||
it('should handle Chinese language', async () => {
|
||||
const model = mockModel({ score: 8, feedback: '很好', should_follow_up: false });
|
||||
const state = baseState({ language: 'zh' });
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle Japanese language', async () => {
|
||||
const model = mockModel({ score: 8, feedback: '良い', should_follow_up: false });
|
||||
const state = baseState({ language: 'ja' });
|
||||
const result = await graderNode(state, { configurable: { model } } as any);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { interviewerNode } from './interviewer.node';
|
||||
import { AIMessage } from '@langchain/core/messages';
|
||||
|
||||
function baseState(overrides: any = {}) {
|
||||
return {
|
||||
questions: [{ id: 'q1', questionText: 'What is JS?', keyPoints: ['point1'], dimension: 'llm' }],
|
||||
currentQuestionIndex: 0,
|
||||
shouldFollowUp: false,
|
||||
language: 'en',
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('interviewerNode', () => {
|
||||
describe('empty questions handling', () => {
|
||||
it('should return apology message when questions array is empty', async () => {
|
||||
const state = baseState({ questions: [] });
|
||||
const result = await interviewerNode(state);
|
||||
expect(result.messages).toBeDefined();
|
||||
expect((result.messages as any)[0].content).toContain("sorry");
|
||||
});
|
||||
|
||||
it('should return apology message when questions is undefined', async () => {
|
||||
const state = baseState({ questions: undefined });
|
||||
const result = await interviewerNode(state);
|
||||
expect(result.messages).toBeDefined();
|
||||
expect((result.messages as any)[0].content).toContain("sorry");
|
||||
});
|
||||
|
||||
it('should return Chinese apology when language is zh', async () => {
|
||||
const state = baseState({ questions: [], language: 'zh' });
|
||||
const result = await interviewerNode(state);
|
||||
expect((result.messages as any)[0].content).toContain('抱歉');
|
||||
});
|
||||
|
||||
it('should return Japanese apology when language is ja', async () => {
|
||||
const state = baseState({ questions: [], language: 'ja' });
|
||||
const result = await interviewerNode(state);
|
||||
expect((result.messages as any)[0].content).toContain('申し訳');
|
||||
});
|
||||
});
|
||||
|
||||
describe('question index range checks', () => {
|
||||
it('should return shouldFollowUp: false when currentQuestionIndex >= questions.length', async () => {
|
||||
const state = baseState({ currentQuestionIndex: 5, questions: [{ id: 'q1', questionText: 'Q', keyPoints: ['k'], dimension: 'llm' }] });
|
||||
const result = await interviewerNode(state);
|
||||
expect(result.shouldFollowUp).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('standard question presentation', () => {
|
||||
it('should present the current question', async () => {
|
||||
const result = await interviewerNode(baseState());
|
||||
expect(result.messages).toBeDefined();
|
||||
const msg = (result.messages as any)[0].content as string;
|
||||
expect(msg).toContain('Question 1');
|
||||
expect(msg).toContain('What is JS?');
|
||||
});
|
||||
|
||||
it('should include answer instruction', async () => {
|
||||
const result = await interviewerNode(baseState());
|
||||
const msg = (result.messages as any)[0].content as string;
|
||||
expect(msg).toContain('answer');
|
||||
});
|
||||
|
||||
it('should use Chinese labels when language is zh', async () => {
|
||||
const state = baseState({ language: 'zh' });
|
||||
const result = await interviewerNode(state);
|
||||
const msg = (result.messages as any)[0].content as string;
|
||||
expect(msg).toContain('问题');
|
||||
expect(msg).toContain('回答');
|
||||
});
|
||||
|
||||
it('should use Japanese labels when language is ja', async () => {
|
||||
const state = baseState({ language: 'ja' });
|
||||
const result = await interviewerNode(state);
|
||||
const msg = (result.messages as any)[0].content as string;
|
||||
expect(msg).toContain('質問');
|
||||
expect(msg).toContain('回答');
|
||||
});
|
||||
});
|
||||
|
||||
describe('follow-up mode', () => {
|
||||
it('should include feedback in follow-up prompt', async () => {
|
||||
const state = baseState({
|
||||
shouldFollowUp: true,
|
||||
feedbackHistory: [new AIMessage('Feedback: You need more details')],
|
||||
});
|
||||
const result = await interviewerNode(state);
|
||||
const msg = (result.messages as any)[0].content as string;
|
||||
expect(msg).toContain('Follow-up');
|
||||
expect(msg).toContain('You need more details');
|
||||
});
|
||||
|
||||
it('should reset shouldFollowUp to false after processing', async () => {
|
||||
const state = baseState({
|
||||
shouldFollowUp: true,
|
||||
feedbackHistory: [new AIMessage('Feedback: More info needed')],
|
||||
});
|
||||
const result = await interviewerNode(state);
|
||||
expect(result.shouldFollowUp).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user