fix/add tests, update heatmap range, finish android release, add readme
Stop tracking gradle.properties
This commit is contained in:
@@ -97,11 +97,7 @@ export const processLesson = async (user, subjectId) => {
|
||||
if (!item) throw new Error('Item not found');
|
||||
|
||||
item.srsLevel = 1;
|
||||
|
||||
const nextReview = new Date();
|
||||
nextReview.setUTCHours(nextReview.getUTCHours() + 2);
|
||||
nextReview.setUTCMinutes(0, 0, 0);
|
||||
item.nextReview = nextReview;
|
||||
item.nextReview = getSRSDate(1);
|
||||
|
||||
await item.save();
|
||||
return { success: true, item };
|
||||
|
||||
@@ -73,6 +73,21 @@ describe('Controllers', () => {
|
||||
await ReviewController.submitReview(mockReq({}), reply);
|
||||
expect(reply.code).toHaveBeenCalledWith(404);
|
||||
});
|
||||
|
||||
it('submitLesson should succeed', async () => {
|
||||
const reply = mockReply();
|
||||
ReviewService.processLesson.mockResolvedValue({ success: true });
|
||||
await ReviewController.submitLesson(mockReq({ subjectId: 100 }), reply);
|
||||
expect(reply.send).toHaveBeenCalledWith({ success: true });
|
||||
});
|
||||
|
||||
it('submitLesson should handle error', async () => {
|
||||
const reply = mockReply();
|
||||
ReviewService.processLesson.mockRejectedValue(new Error('Lesson error'));
|
||||
await ReviewController.submitLesson(mockReq({ subjectId: 100 }), reply);
|
||||
expect(reply.code).toHaveBeenCalledWith(404);
|
||||
expect(reply.send).toHaveBeenCalledWith({ error: 'Lesson error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sync Controller', () => {
|
||||
@@ -113,6 +128,20 @@ describe('Controllers', () => {
|
||||
expect(ReviewService.getQueue).toHaveBeenCalledWith(expect.anything(), 50, undefined);
|
||||
});
|
||||
|
||||
it('getLessonQueue should call service with explicit limit', async () => {
|
||||
const reply = mockReply();
|
||||
ReviewService.getLessonQueue.mockResolvedValue([]);
|
||||
await CollectionController.getLessonQueue(mockReq({}, {}, { limit: '10' }), reply);
|
||||
expect(ReviewService.getLessonQueue).toHaveBeenCalledWith(expect.anything(), 10);
|
||||
});
|
||||
|
||||
it('getLessonQueue should use default limit when none provided', async () => {
|
||||
const reply = mockReply();
|
||||
ReviewService.getLessonQueue.mockResolvedValue([]);
|
||||
await CollectionController.getLessonQueue(mockReq({}, {}, {}), reply);
|
||||
expect(ReviewService.getLessonQueue).toHaveBeenCalledWith(expect.anything(), 10);
|
||||
});
|
||||
|
||||
it('getStats should call service', async () => {
|
||||
const reply = mockReply();
|
||||
StatsService.getUserStats.mockResolvedValue({});
|
||||
|
||||
@@ -3,6 +3,7 @@ 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');
|
||||
@@ -10,7 +11,7 @@ vi.mock('../../src/models/StudyItem.js');
|
||||
describe('Review Service', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2023-01-10T00:00:00Z'));
|
||||
vi.setSystemTime(new Date('2023-01-10T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -47,50 +48,83 @@ describe('Review Service', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle standard success flow', async () => {
|
||||
it('should handle standard success flow (Level 1 -> 2)', async () => {
|
||||
const user = mockUser();
|
||||
delete user.stats;
|
||||
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||
const res = await ReviewService.processReview(user, 100, true);
|
||||
|
||||
const ZSRes = await ReviewService.processReview(user, 100, true);
|
||||
|
||||
expect(user.save).toHaveBeenCalled();
|
||||
expect(res.srsLevel).toBe(2);
|
||||
expect(ZSRes.srsLevel).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle failure flow', async () => {
|
||||
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 reset streak (diff > 1, no shield)', async () => {
|
||||
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 () => {
|
||||
@@ -116,29 +150,79 @@ describe('Review Service', () => {
|
||||
expect(item.stats).toEqual(expect.objectContaining({ correct: 1, total: 1 }));
|
||||
});
|
||||
|
||||
it('should not break on time travel (diff < 0)', async () => {
|
||||
const user = mockUser({ lastStudyDate: '2023-01-11', currentStreak: 5 });
|
||||
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||
it('should handle burning items (level 9 -> 10) and stop reviews', async () => {
|
||||
const user = mockUser();
|
||||
const item = mockItem(9);
|
||||
StudyItem.findOne.mockResolvedValue(item);
|
||||
|
||||
await ReviewService.processReview(user, 100, true);
|
||||
const result = await ReviewService.processReview(user, 100, true);
|
||||
|
||||
expect(user.stats.lastStudyDate).toBe('2023-01-10');
|
||||
expect(user.stats.currentStreak).toBe(5);
|
||||
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 mockFind = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue([]) };
|
||||
StudyItem.find.mockReturnValue(mockFind);
|
||||
const mockChain = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue([]) };
|
||||
StudyItem.find.mockReturnValue(mockChain);
|
||||
await ReviewService.getQueue({ _id: 'u1' }, 10, 'priority');
|
||||
expect(mockFind.sort).toHaveBeenCalled();
|
||||
expect(mockChain.sort).toHaveBeenCalled();
|
||||
});
|
||||
it('should shuffle (default)', async () => {
|
||||
const mockFind = { limit: vi.fn().mockResolvedValue([1, 2]) };
|
||||
StudyItem.find.mockReturnValue(mockFind);
|
||||
|
||||
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(mockFind.limit).toHaveBeenCalled();
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('Sync Service', () => {
|
||||
});
|
||||
|
||||
it('should sync items in chunks', async () => {
|
||||
const manyIds = Array.from({ length: 150 }, (_, i) => i + 1);
|
||||
const manyIds = Array.from({ length: 100 }, (_, i) => i + 1);
|
||||
const subjectData = manyIds.map(id => ({ data: { subject_id: id } }));
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
@@ -202,11 +202,90 @@ describe('Sync Service', () => {
|
||||
});
|
||||
|
||||
StudyItem.insertMany.mockResolvedValue(true);
|
||||
StudyItem.countDocuments.mockResolvedValue(150);
|
||||
StudyItem.countDocuments.mockResolvedValue(100);
|
||||
|
||||
await syncWithWaniKani(mockUser);
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
expect(StudyItem.insertMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should fetch and map radicals for items, handling missing meanings', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ data: { subject_id: 500 } }],
|
||||
pages: { next_url: null }
|
||||
})
|
||||
});
|
||||
|
||||
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{
|
||||
id: 500,
|
||||
data: {
|
||||
characters: 'Kanji',
|
||||
meanings: [{ primary: true, meaning: 'K_Mean' }],
|
||||
level: 5,
|
||||
readings: [],
|
||||
component_subject_ids: [10, 11, 12]
|
||||
}
|
||||
}]
|
||||
})
|
||||
});
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{
|
||||
id: 10,
|
||||
data: {
|
||||
characters: 'R1',
|
||||
meanings: [{ primary: true, meaning: 'Rad1' }],
|
||||
character_images: []
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
data: {
|
||||
characters: null,
|
||||
meanings: [{ primary: true, meaning: 'Rad2' }],
|
||||
character_images: [
|
||||
{ content_type: 'image/png', url: 'bad.png', metadata: {} },
|
||||
{ content_type: 'image/svg+xml', url: 'good.svg', metadata: { inline_styles: false } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
data: {
|
||||
characters: 'R2',
|
||||
meanings: [{ primary: false, meaning: 'Secondary' }],
|
||||
character_images: []
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
StudyItem.insertMany.mockResolvedValue(true);
|
||||
StudyItem.countDocuments.mockResolvedValue(1);
|
||||
|
||||
await syncWithWaniKani(mockUser);
|
||||
|
||||
expect(StudyItem.insertMany).toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
wkSubjectId: 500,
|
||||
radicals: expect.arrayContaining([
|
||||
expect.objectContaining({ meaning: 'Rad1', char: 'R1' }),
|
||||
expect.objectContaining({ meaning: 'Rad2', image: 'good.svg' }),
|
||||
expect.objectContaining({ meaning: 'Unknown', char: 'R2' })
|
||||
])
|
||||
})
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user