229 lines
7.2 KiB
JavaScript
229 lines
7.2 KiB
JavaScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import * as ReviewService from '../../src/services/review.service.js';
|
|
import { User } from '../../src/models/User.js';
|
|
import { ReviewLog } from '../../src/models/ReviewLog.js';
|
|
import { StudyItem } from '../../src/models/StudyItem.js';
|
|
import { getSRSDate } from '../../src/utils/dateUtils.js';
|
|
|
|
vi.mock('../../src/models/ReviewLog.js');
|
|
vi.mock('../../src/models/StudyItem.js');
|
|
|
|
describe('Review Service', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2023-01-10T12:00:00Z'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
describe('processReview', () => {
|
|
const mockUser = (stats = {}) => ({
|
|
_id: 'u1',
|
|
stats: { ...stats },
|
|
save: vi.fn()
|
|
});
|
|
|
|
const mockItem = (srs = 1) => ({
|
|
userId: 'u1',
|
|
wkSubjectId: 100,
|
|
srsLevel: srs,
|
|
stats: { correct: 0, total: 0 },
|
|
save: vi.fn(),
|
|
nextReview: null
|
|
});
|
|
|
|
it('should throw error if item not found', async () => {
|
|
const user = mockUser();
|
|
StudyItem.findOne.mockResolvedValue(null);
|
|
|
|
await expect(ReviewService.processReview(user, 999, true))
|
|
.rejects.toThrow('Item not found');
|
|
|
|
expect(StudyItem.findOne).toHaveBeenCalledWith({
|
|
userId: user._id,
|
|
wkSubjectId: 999
|
|
});
|
|
});
|
|
|
|
it('should handle standard success flow (Level 1 -> 2)', async () => {
|
|
const user = mockUser();
|
|
delete user.stats;
|
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
|
|
|
const ZSRes = await ReviewService.processReview(user, 100, true);
|
|
|
|
expect(user.save).toHaveBeenCalled();
|
|
expect(ZSRes.srsLevel).toBe(2);
|
|
});
|
|
|
|
it('should handle failure flow (Level 2 -> 1)', async () => {
|
|
const user = mockUser();
|
|
const item = mockItem(2);
|
|
StudyItem.findOne.mockResolvedValue(item);
|
|
|
|
await ReviewService.processReview(user, 100, false);
|
|
|
|
expect(item.srsLevel).toBe(1);
|
|
});
|
|
|
|
it('should increment streak (diff = 1)', async () => {
|
|
const user = mockUser({ lastStudyDate: '2023-01-09', currentStreak: 5 });
|
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
|
|
|
await ReviewService.processReview(user, 100, true);
|
|
|
|
expect(user.stats.currentStreak).toBe(6);
|
|
});
|
|
|
|
it('should maintain streak (diff = 0)', async () => {
|
|
const user = mockUser({ lastStudyDate: '2023-01-10', currentStreak: 5 });
|
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
|
|
|
await ReviewService.processReview(user, 100, true);
|
|
|
|
expect(user.stats.currentStreak).toBe(5);
|
|
});
|
|
|
|
it('should handle future lastStudyDate (diff < 1)', async () => {
|
|
const user = mockUser({ lastStudyDate: '2023-01-11', currentStreak: 5 });
|
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
|
|
|
await ReviewService.processReview(user, 100, true);
|
|
|
|
expect(user.stats.currentStreak).toBe(5);
|
|
expect(user.stats.lastStudyDate).toBe('2023-01-10');
|
|
});
|
|
|
|
it('should reset streak (diff > 1, no shield available)', async () => {
|
|
const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5, lastFreezeDate: '2023-01-09' });
|
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
|
|
|
await ReviewService.processReview(user, 100, true);
|
|
|
|
expect(user.stats.currentStreak).toBe(1);
|
|
});
|
|
|
|
it('should use shield (diff = 2, shield ready)', async () => {
|
|
const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5, lastFreezeDate: '2022-01-01' });
|
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
|
|
|
await ReviewService.processReview(user, 100, true);
|
|
|
|
expect(user.stats.currentStreak).toBe(6);
|
|
expect(user.stats.lastFreezeDate).toBeDefined();
|
|
expect(user.stats.lastFreezeDate.toISOString()).toContain('2023-01-10');
|
|
});
|
|
|
|
it('should NOT use shield if gap is too large (diff > 2)', async () => {
|
|
const user = mockUser({ lastStudyDate: '2023-01-07', currentStreak: 5, lastFreezeDate: '2022-01-01' });
|
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
|
|
|
await ReviewService.processReview(user, 100, true);
|
|
|
|
expect(user.stats.currentStreak).toBe(1);
|
|
expect(new Date(user.stats.lastFreezeDate).toISOString()).toContain('2022-01-01');
|
|
});
|
|
|
|
it('should use shield (diff = 2) when lastFreezeDate is undefined', async () => {
|
|
const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5 });
|
|
user.stats.lastFreezeDate = null;
|
|
|
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
|
|
|
await ReviewService.processReview(user, 100, true);
|
|
|
|
expect(user.stats.currentStreak).toBe(6);
|
|
expect(user.stats.lastFreezeDate).toBeDefined();
|
|
});
|
|
|
|
it('should initialize item stats if missing', async () => {
|
|
const user = mockUser();
|
|
const item = mockItem();
|
|
delete item.stats;
|
|
StudyItem.findOne.mockResolvedValue(item);
|
|
|
|
await ReviewService.processReview(user, 100, true);
|
|
|
|
expect(item.stats).toEqual(expect.objectContaining({ correct: 1, total: 1 }));
|
|
});
|
|
|
|
it('should handle burning items (level 9 -> 10) and stop reviews', async () => {
|
|
const user = mockUser();
|
|
const item = mockItem(9);
|
|
StudyItem.findOne.mockResolvedValue(item);
|
|
|
|
const result = await ReviewService.processReview(user, 100, true);
|
|
|
|
expect(item.srsLevel).toBe(10);
|
|
expect(item.nextReview).toBeNull();
|
|
expect(result.srsLevel).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe('processLesson', () => {
|
|
const mockUser = () => ({ _id: 'u1' });
|
|
const mockItem = () => ({
|
|
userId: 'u1',
|
|
wkSubjectId: 100,
|
|
srsLevel: 0,
|
|
save: vi.fn(),
|
|
nextReview: null
|
|
});
|
|
|
|
it('should throw error if item not found', async () => {
|
|
StudyItem.findOne.mockResolvedValue(null);
|
|
await expect(ReviewService.processLesson(mockUser(), 999))
|
|
.rejects.toThrow('Item not found');
|
|
});
|
|
|
|
it('should initialize lesson with correct SRS level and date (4 hours)', async () => {
|
|
const user = mockUser();
|
|
const item = mockItem();
|
|
StudyItem.findOne.mockResolvedValue(item);
|
|
|
|
const result = await ReviewService.processLesson(user, 100);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(item.srsLevel).toBe(1);
|
|
expect(item.save).toHaveBeenCalled();
|
|
|
|
const now = new Date('2023-01-10T12:00:00Z');
|
|
const expected = new Date(now.getTime() + 4 * 3600000);
|
|
expect(item.nextReview.toISOString()).toBe(expected.toISOString());
|
|
});
|
|
});
|
|
|
|
describe('getQueue', () => {
|
|
it('should sort by priority', async () => {
|
|
const mockChain = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue([]) };
|
|
StudyItem.find.mockReturnValue(mockChain);
|
|
await ReviewService.getQueue({ _id: 'u1' }, 10, 'priority');
|
|
expect(mockChain.sort).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should shuffle (default behavior)', async () => {
|
|
const mockChain = { limit: vi.fn().mockResolvedValue([1, 2]) };
|
|
StudyItem.find.mockReturnValue(mockChain);
|
|
await ReviewService.getQueue({ _id: 'u1' }, 10, 'random');
|
|
expect(mockChain.limit).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('getLessonQueue', () => {
|
|
it('should return sorted lesson queue', async () => {
|
|
const mockChain = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue(['l1', 'l2']) };
|
|
StudyItem.find.mockReturnValue(mockChain);
|
|
|
|
const result = await ReviewService.getLessonQueue({ _id: 'u1' }, 5);
|
|
|
|
expect(StudyItem.find).toHaveBeenCalledWith({ userId: 'u1', srsLevel: 0 });
|
|
expect(mockChain.sort).toHaveBeenCalledWith({ level: 1, wkSubjectId: 1 });
|
|
expect(mockChain.limit).toHaveBeenCalledWith(5);
|
|
expect(result).toHaveLength(2);
|
|
});
|
|
});
|
|
});
|