Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions src/__tests__/api/client-cn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, expect, it, vi } from 'vitest';
import { LeetCodeClient } from '../../api/client.js';

describe('LeetCodeClient cn getProblemById', () => {
it('uses list search to resolve cn problem ids by exact frontend id', async () => {
const client = new LeetCodeClient('leetcode.cn');

const getProblemsSpy = vi.spyOn(client, 'getProblems').mockResolvedValueOnce({
total: 1,
problems: [
{
questionId: '1',
questionFrontendId: '1',
title: '两数之和',
titleSlug: 'two-sum',
difficulty: 'Easy',
isPaidOnly: false,
acRate: 52.3,
topicTags: [],
status: 'ac',
},
],
});

const getProblemSpy = vi.spyOn(client, 'getProblem').mockResolvedValue({
questionId: '1',
questionFrontendId: '1',
title: '两数之和',
titleSlug: 'two-sum',
difficulty: 'Easy',
isPaidOnly: false,
acRate: 52.3,
topicTags: [],
status: 'ac',
content: '<p>Given an array of integers...</p>',
codeSnippets: [],
sampleTestCase: '',
exampleTestcases: '',
hints: [],
companyTags: [],
stats: '{}',
});

const result = await client.getProblemById('1');

expect(getProblemsSpy).toHaveBeenCalledWith({ searchKeywords: '1', limit: 50, skip: 0 });
expect(getProblemSpy).toHaveBeenCalledWith('two-sum');
expect(result.titleSlug).toBe('two-sum');
});

it('throws when cn list search does not contain the exact frontend id', async () => {
const client = new LeetCodeClient('leetcode.cn');

const getProblemsSpy = vi.spyOn(client, 'getProblems').mockResolvedValueOnce({
total: 12,
problems: [
{
questionId: '200',
questionFrontendId: '200',
title: '岛屿数量',
titleSlug: 'number-of-islands',
difficulty: 'Medium',
isPaidOnly: false,
acRate: 61.2,
topicTags: [],
status: null,
},
],
});

const getProblemSpy = vi.spyOn(client, 'getProblem');

await expect(client.getProblemById('2')).rejects.toThrow('Problem #2 not found');

expect(getProblemsSpy).toHaveBeenCalledWith({ searchKeywords: '2', limit: 50, skip: 0 });
expect(getProblemSpy).not.toHaveBeenCalled();
});

it('searches additional cn result pages until the exact frontend id is found', async () => {
const client = new LeetCodeClient('leetcode.cn');

const getProblemsSpy = vi
.spyOn(client, 'getProblems')
.mockResolvedValueOnce({
total: 120,
problems: Array.from({ length: 50 }, (_, index) => ({
questionId: String(index + 100),
questionFrontendId: String(index + 100),
title: `Problem ${index + 100}`,
titleSlug: `problem-${index + 100}`,
difficulty: 'Easy' as const,
isPaidOnly: false,
acRate: 50,
topicTags: [],
status: null,
})),
})
.mockResolvedValueOnce({
total: 120,
problems: [
{
questionId: '1',
questionFrontendId: '1',
title: '两数之和',
titleSlug: 'two-sum',
difficulty: 'Easy',
isPaidOnly: false,
acRate: 52.3,
topicTags: [],
status: 'ac',
},
],
});

const getProblemSpy = vi.spyOn(client, 'getProblem').mockResolvedValue({
questionId: '1',
questionFrontendId: '1',
title: '两数之和',
titleSlug: 'two-sum',
difficulty: 'Easy',
isPaidOnly: false,
acRate: 52.3,
topicTags: [],
status: 'ac',
content: '<p>Given an array of integers...</p>',
codeSnippets: [],
sampleTestCase: '',
exampleTestcases: '',
hints: [],
companyTags: [],
stats: '{}',
});

await client.getProblemById('1');

expect(getProblemsSpy).toHaveBeenNthCalledWith(1, { searchKeywords: '1', limit: 50, skip: 0 });
expect(getProblemsSpy).toHaveBeenNthCalledWith(2, { searchKeywords: '1', limit: 50, skip: 50 });
expect(getProblemSpy).toHaveBeenCalledWith('two-sum');
});
});
116 changes: 116 additions & 0 deletions src/__tests__/api/cn-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { describe, expect, it } from 'vitest';
import {
normalizeCnDailyChallenge,
normalizeCnProblemDetail,
normalizeCnProblemList,
normalizeCnSkillStats,
normalizeCnUserProfile,
} from '../../api/adapters/cn.js';
import { CnProblemDetailSchema, CnProblemListSchema } from '../../schemas/api.js';

describe('cn adapters', () => {
it('normalizes daily challenge payload from todayRecord', () => {
Expand Down Expand Up @@ -33,6 +36,119 @@ describe('cn adapters', () => {
expect(result.link).toBe('/problems/two-sum/');
});

it('normalizes problem list payload from leetcode.cn', () => {
const result = normalizeCnProblemList({
problemsetQuestionList: {
total: 3,
questions: [
{
frontendQuestionId: '1',
title: 'Two Sum',
titleCn: '两数之和',
titleSlug: 'two-sum',
difficulty: 'Easy',
paidOnly: false,
acRate: '52.3',
status: 'AC',
topicTags: [{ name: 'Array', nameTranslated: '数组', id: 'array', slug: 'array' }],
},
{
frontendQuestionId: '2',
title: 'Add Two Numbers',
titleCn: '两数相加',
titleSlug: 'add-two-numbers',
difficulty: 'Medium',
paidOnly: false,
acRate: '42.1',
status: 'TRIED',
topicTags: [],
},
{
frontendQuestionId: '3',
title: 'Longest Substring Without Repeating Characters',
titleCn: '无重复字符的最长子串',
titleSlug: 'longest-substring-without-repeating-characters',
difficulty: 'Medium',
paidOnly: false,
acRate: '38.4',
status: 'NOT_STARTED',
topicTags: [],
},
],
},
});

expect(result.total).toBe(3);
expect(result.problems[0]).toMatchObject({
questionId: '1',
questionFrontendId: '1',
title: '两数之和',
titleSlug: 'two-sum',
difficulty: 'Easy',
isPaidOnly: false,
acRate: 52.3,
status: 'ac',
});
expect(result.problems[0]?.topicTags).toEqual([{ name: '数组', slug: 'array' }]);
expect(result.problems[1]?.status).toBe('notac');
expect(result.problems[2]?.status).toBeNull();
});

it('accepts leetcode.cn problem list status enums before normalization', () => {
const parsed = CnProblemListSchema.parse({
problemsetQuestionList: {
total: 3,
questions: [
{ frontendQuestionId: '1', status: 'AC' },
{ frontendQuestionId: '2', status: 'TRIED' },
{ frontendQuestionId: '3', status: 'NOT_STARTED' },
],
},
});

expect(parsed.problemsetQuestionList.questions).toHaveLength(3);
});

it('normalizes problem detail payload from leetcode.cn', () => {
const parsed = CnProblemDetailSchema.parse({
question: {
questionId: '1',
questionFrontendId: '1',
title: 'Two Sum',
translatedTitle: '两数之和',
titleSlug: 'two-sum',
translatedContent: '<p>给定一个整数数组 nums 和一个整数目标值 target...</p>',
difficulty: 'Easy',
isPaidOnly: false,
acRate: '52.3',
status: 'AC',
topicTags: [{ name: 'Array', slug: 'array', translatedName: '数组' }],
codeSnippets: [{ lang: 'TypeScript', langSlug: 'typescript', code: 'function twoSum() {}' }],
sampleTestCase: '[2,7,11,15]\n9',
exampleTestcases: '[2,7,11,15]\n9',
hints: ['Use a hash map.'],
stats: '{}',
},
});

const result = normalizeCnProblemDetail(parsed);

expect(result).toMatchObject({
questionId: '1',
questionFrontendId: '1',
title: '两数之和',
titleSlug: 'two-sum',
difficulty: 'Easy',
isPaidOnly: false,
acRate: 52.3,
status: 'ac',
sampleTestCase: '[2,7,11,15]\n9',
exampleTestcases: '[2,7,11,15]\n9',
});
expect(result.topicTags).toEqual([{ name: '数组', slug: 'array' }]);
expect(result.companyTags).toBeNull();
});

it('normalizes cn profile payload into shared user profile shape', () => {
const profile = normalizeCnUserProfile('night-slayer', {
userProfilePublicProfile: {
Expand Down
8 changes: 8 additions & 0 deletions src/__tests__/api/query-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import { getQueryPack } from '../../api/query-resolver.js';
import { PROBLEM_LIST_QUERY as PROBLEM_LIST_QUERY_GLOBAL } from '../../api/queries.global.js';
import { PROBLEM_DETAIL_QUERY as PROBLEM_DETAIL_QUERY_GLOBAL } from '../../api/queries.global.js';
import { DAILY_CHALLENGE_QUERY as DAILY_CHALLENGE_QUERY_GLOBAL } from '../../api/queries.global.js';
import { DAILY_CHALLENGE_QUERY_CN } from '../../api/queries.cn.js';

Expand All @@ -14,5 +16,11 @@ describe('query resolver', () => {
const pack = getQueryPack('leetcode.cn');
expect(pack.DAILY_CHALLENGE_QUERY).toContain('todayRecord');
expect(pack.DAILY_CHALLENGE_QUERY).toBe(DAILY_CHALLENGE_QUERY_CN);
expect(pack.PROBLEM_LIST_QUERY).not.toBe(PROBLEM_LIST_QUERY_GLOBAL);
expect(pack.PROBLEM_LIST_QUERY).toContain('titleCn');
expect(pack.PROBLEM_LIST_QUERY).toContain('frontendQuestionId');
expect(pack.PROBLEM_DETAIL_QUERY).not.toBe(PROBLEM_DETAIL_QUERY_GLOBAL);
expect(pack.PROBLEM_DETAIL_QUERY).toContain('translatedTitle');
expect(pack.PROBLEM_DETAIL_QUERY).toContain('translatedContent');
});
});
10 changes: 10 additions & 0 deletions src/__tests__/commands/solve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,16 @@ describe('Solve Commands', () => {
// Should warn about existing file but not fail
expect(leetcodeClient.getProblemById).toHaveBeenCalled();
});

it('should not create a file when problem id lookup fails', async () => {
vi.mocked(existsSync).mockReturnValue(false);
vi.mocked(leetcodeClient.getProblemById).mockRejectedValueOnce(new Error('Problem #1 not found'));

const result = await pickCommand('1', { open: false });

expect(result).toBe(false);
expect(writeFile).not.toHaveBeenCalled();
});
});

describe('file creation', () => {
Expand Down
45 changes: 43 additions & 2 deletions src/__tests__/tui/auth-effects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,20 @@
getPath: vi.fn(() => '/tmp/.leetcode/credentials.v2.enc.json'),
},
mockLeetCodeClient: {
setSite: vi.fn(),
setCredentials: vi.fn(),
checkAuth: vi.fn(),
},
}));

const { mockConfig } = vi.hoisted(() => ({
mockConfig: {
setSite: vi.fn(),
getSite: vi.fn(() => 'leetcode.com'),
getConfig: vi.fn(() => ({ site: 'leetcode.com' })),
},
}));

vi.mock('../../storage/credentials.js', () => ({
credentials: mockCredentials,
describeCredentialStatus: vi.fn((status: { reason: string | null }) => {
Expand All @@ -60,6 +69,10 @@
leetcodeClient: mockLeetCodeClient,
}));

vi.mock('../../storage/config.js', () => ({
config: mockConfig,
}));

import { executeCommand } from '../../tui/commands/effects.js';

async function flushAsync(): Promise<void> {
Expand All @@ -80,11 +93,11 @@
readOnly: true,
reason: null,
path: null,
} as any);

Check warning on line 96 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 24)

Unexpected any. Specify a different type

Check warning on line 96 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 20)

Unexpected any. Specify a different type

Check warning on line 96 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20)

Unexpected any. Specify a different type

Check warning on line 96 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 24)

Unexpected any. Specify a different type

Check warning on line 96 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 22)

Unexpected any. Specify a different type

Check warning on line 96 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 20)

Unexpected any. Specify a different type

Check warning on line 96 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 22)

Unexpected any. Specify a different type

Check warning on line 96 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 24)

Unexpected any. Specify a different type

Check warning on line 96 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Unexpected any. Specify a different type

const dispatched: AppMsg[] = [];
executeCommand(
{ type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token' },
{ type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token', site: 'leetcode.com' },
(msg) => dispatched.push(msg)
);

Expand All @@ -105,11 +118,11 @@
readOnly: false,
reason: 'KEYCHAIN_UNAVAILABLE',
path: null,
} as any);

Check warning on line 121 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 24)

Unexpected any. Specify a different type

Check warning on line 121 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 20)

Unexpected any. Specify a different type

Check warning on line 121 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20)

Unexpected any. Specify a different type

Check warning on line 121 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 24)

Unexpected any. Specify a different type

Check warning on line 121 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 22)

Unexpected any. Specify a different type

Check warning on line 121 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 20)

Unexpected any. Specify a different type

Check warning on line 121 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 22)

Unexpected any. Specify a different type

Check warning on line 121 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 24)

Unexpected any. Specify a different type

Check warning on line 121 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Unexpected any. Specify a different type

const dispatched: AppMsg[] = [];
executeCommand(
{ type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token' },
{ type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token', site: 'leetcode.com' },
(msg) => dispatched.push(msg)
);

Expand All @@ -132,7 +145,7 @@
source: null,
path: null,
message: 'Unset LEETCODE_SESSION and LEETCODE_CSRF_TOKEN to log out.',
} as any);

Check warning on line 148 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 24)

Unexpected any. Specify a different type

Check warning on line 148 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 20)

Unexpected any. Specify a different type

Check warning on line 148 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20)

Unexpected any. Specify a different type

Check warning on line 148 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 24)

Unexpected any. Specify a different type

Check warning on line 148 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 22)

Unexpected any. Specify a different type

Check warning on line 148 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 20)

Unexpected any. Specify a different type

Check warning on line 148 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 22)

Unexpected any. Specify a different type

Check warning on line 148 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 24)

Unexpected any. Specify a different type

Check warning on line 148 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Unexpected any. Specify a different type

const dispatched: AppMsg[] = [];
executeCommand({ type: 'CMD_LOGOUT' }, (msg) => dispatched.push(msg));
Expand All @@ -155,7 +168,7 @@
readOnly: false,
reason: 'KEYCHAIN_UNAVAILABLE',
path: null,
} as any);

Check warning on line 171 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 24)

Unexpected any. Specify a different type

Check warning on line 171 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 20)

Unexpected any. Specify a different type

Check warning on line 171 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20)

Unexpected any. Specify a different type

Check warning on line 171 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 24)

Unexpected any. Specify a different type

Check warning on line 171 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 22)

Unexpected any. Specify a different type

Check warning on line 171 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 20)

Unexpected any. Specify a different type

Check warning on line 171 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 22)

Unexpected any. Specify a different type

Check warning on line 171 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 24)

Unexpected any. Specify a different type

Check warning on line 171 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Unexpected any. Specify a different type

const dispatched: AppMsg[] = [];
executeCommand({ type: 'CMD_CHECK_AUTH' }, (msg) => dispatched.push(msg));
Expand All @@ -165,4 +178,32 @@
expect(dispatched).toContainEqual({ type: 'GLOBAL_ERROR', error: 'System keychain is unavailable.' });
expect(dispatched).toContainEqual({ type: 'AUTH_CHECK_COMPLETE', user: null });
});

it('should persist selected site before verifying login credentials', async () => {
mockCredentials.status.mockResolvedValueOnce({
mode: 'keychain',
backend: 'keychain',
source: null,
hasCredentials: false,
readOnly: false,
reason: null,
path: null,
} as any);

Check warning on line 191 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 24)

Unexpected any. Specify a different type

Check warning on line 191 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 20)

Unexpected any. Specify a different type

Check warning on line 191 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20)

Unexpected any. Specify a different type

Check warning on line 191 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 24)

Unexpected any. Specify a different type

Check warning on line 191 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 22)

Unexpected any. Specify a different type

Check warning on line 191 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 20)

Unexpected any. Specify a different type

Check warning on line 191 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 22)

Unexpected any. Specify a different type

Check warning on line 191 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (windows-latest, 24)

Unexpected any. Specify a different type

Check warning on line 191 in src/__tests__/tui/auth-effects.test.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Unexpected any. Specify a different type
mockLeetCodeClient.checkAuth.mockResolvedValueOnce({
isSignedIn: true,
username: 'dong',
});

const dispatched: AppMsg[] = [];
executeCommand(
{ type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token', site: 'leetcode.cn' },
(msg) => dispatched.push(msg)
);

await flushAsync();

expect(mockConfig.setSite).toHaveBeenCalledWith('leetcode.cn');
expect(mockLeetCodeClient.setSite).toHaveBeenCalledWith('leetcode.cn');
expect(dispatched).toContainEqual({ type: 'LOGIN_SUCCESS', username: 'dong' });
});
});
Loading
Loading