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
3 changes: 2 additions & 1 deletion src/background/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ console.log("Leetcode Buddy - Background Service Worker Starting");
chrome.runtime.onInstalled.addListener(async () => {
console.log("Leetcode Buddy installed");
await loadAliases();
await computeNextProblem();
// On first install, sync all solved problems from LeetCode
await computeNextProblem(true);
await installRedirectRule();
});

Expand Down
36 changes: 27 additions & 9 deletions src/background/problemLogic.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,10 @@ export function computeCategoryProgress(category, solvedProblems) {
/**
* Compute next unsolved problem across all categories
* Syncs with LeetCode to get current solve status
* @param {boolean} syncAllSolved - If true, scan all problems and mark all solved ones (for first install only)
* @returns {Promise<Object|null>} Next problem info or null if error
*/
export async function computeNextProblem() {
export async function computeNextProblem(syncAllSolved = false) {
await loadProblemSet();
await loadAliases();

Expand All @@ -137,20 +138,37 @@ export async function computeNextProblem() {

const state = await getState();
const statusMap = await fetchAllProblemStatuses();
const solvedProblems = new Set();

// Start with existing solved problems from state
const solvedProblems = new Set(state.solvedProblems || []);

// First pass: Mark ALL solved problems (only on first install)
// This ensures all solved problems are tracked when extension is first installed
// But respects user's reset progress choice on subsequent startups
if (syncAllSolved) {
for (let catIdx = 0; catIdx < problemSet.categories.length; catIdx++) {
const category = problemSet.categories[catIdx];

for (let probIdx = 0; probIdx < category.problems.length; probIdx++) {
const problem = category.problems[probIdx];
const canonicalSlug = resolveProblemAlias(problem.slug);
const status = statusMap.get(canonicalSlug);

if (status === "ac") {
solvedProblems.add(problem.slug);
}
}
}
}

// Iterate through all categories to find first unsolved problem
// Second pass: Find first unsolved problem in order
for (let catIdx = 0; catIdx < problemSet.categories.length; catIdx++) {
const category = problemSet.categories[catIdx];

for (let probIdx = 0; probIdx < category.problems.length; probIdx++) {
const problem = category.problems[probIdx];
const canonicalSlug = resolveProblemAlias(problem.slug);
const status = statusMap.get(canonicalSlug);

if (status === "ac") {
solvedProblems.add(problem.slug);
} else {

if (!solvedProblems.has(problem.slug)) {
// Found first unsolved problem
currentCategoryIndex = catIdx;
currentProblemIndex = probIdx;
Expand Down
164 changes: 161 additions & 3 deletions tests/background/problemLogic.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,23 +241,36 @@ describe('problemLogic.js', () => {
expect(nextProblem.categoryName).toBe('Arrays & Hashing');
expect(nextProblem.categoryIndex).toBe(0);
expect(nextProblem.problemIndex).toBe(0);

// Should have no solved problems
expect(nextProblem.solvedCount).toBe(0);

// Verify saveState was called with empty solvedProblems
expect(chrome.storage.sync.set).toHaveBeenCalled();
const setCalls = chrome.storage.sync.set.mock.calls;
const lastCall = setCalls[setCalls.length - 1];
const savedSolvedProblems = lastCall[0].solvedProblems;
expect(savedSolvedProblems.length).toBe(0);
});

it('should skip solved problems and return next unsolved', async () => {
// Pre-populate state with solved problems (simulating normal operation after first install)
chrome.storage.sync.get.mockResolvedValue({
currentCategoryIndex: 0,
currentProblemIndex: 0,
solvedProblems: []
solvedProblems: ['two-sum', 'valid-anagram']
});

// Set up mocks for this specific test
// Mock problem set (loadProblemSet calls fetch)
global.fetch
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue(mockProblemSet)
})
// Mock aliases (loadAliases calls fetch)
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue(mockAliases)
})
// Mock LeetCode API (fetchAllProblemStatuses calls fetch)
Expand All @@ -275,16 +288,87 @@ describe('problemLogic.js', () => {
})
});

// Call without syncAllSolved (normal operation, uses existing state)
const nextProblem = await problemLogic.computeNextProblem();

// Should skip two-sum and valid-anagram (both solved) and return group-anagrams
expect(nextProblem).toBeTruthy();
expect(nextProblem.problem.slug).toBe('group-anagrams');
expect(nextProblem.categoryIndex).toBe(0);
expect(nextProblem.problemIndex).toBe(2);

// Should have both solved problems from state
expect(nextProblem.solvedCount).toBe(2);

// Verify saveState was called with both solved problems
expect(chrome.storage.sync.set).toHaveBeenCalled();
const setCalls = chrome.storage.sync.set.mock.calls;
const lastCall = setCalls[setCalls.length - 1];
const savedSolvedProblems = lastCall[0].solvedProblems;
expect(savedSolvedProblems).toContain('two-sum');
expect(savedSolvedProblems).toContain('valid-anagram');
expect(savedSolvedProblems.length).toBe(2);
});

it('should move to next category when current category complete', async () => {
it('should mark all solved problems even when solved out of order (on first install)', async () => {
chrome.storage.sync.get.mockResolvedValue({
currentCategoryIndex: 0,
currentProblemIndex: 0,
solvedProblems: []
});

// Set up mocks for this specific test
// Mock problem set (loadProblemSet calls fetch)
global.fetch
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue(mockProblemSet)
})
// Mock aliases (loadAliases calls fetch)
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue(mockAliases)
})
// Mock LeetCode API (fetchAllProblemStatuses calls fetch)
// User solved problem #3 (group-anagrams) and #5 (two-sum-ii) out of order
// But not #1 (two-sum), #2 (valid-anagram), or #4 (valid-palindrome)
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue({
stat_status_pairs: [
{ stat: { question__title_slug: 'two-sum' }, status: null },
{ stat: { question__title_slug: 'valid-anagram' }, status: null },
{ stat: { question__title_slug: 'group-anagrams' }, status: 'ac' },
{ stat: { question__title_slug: 'valid-palindrome' }, status: null },
{ stat: { question__title_slug: 'two-sum-ii' }, status: 'ac' }
]
})
});

// Call with syncAllSolved=true (simulating first install)
const nextProblem = await problemLogic.computeNextProblem(true);

// Should return problem #1 (two-sum) as first unsolved
expect(nextProblem).toBeTruthy();
expect(nextProblem.problem.slug).toBe('two-sum');
expect(nextProblem.categoryIndex).toBe(0);
expect(nextProblem.problemIndex).toBe(0);

// Should have marked both #3 and #5 as solved
expect(nextProblem.solvedCount).toBe(2);

// Verify saveState was called with both solved problems
expect(chrome.storage.sync.set).toHaveBeenCalled();
const setCalls = chrome.storage.sync.set.mock.calls;
const lastCall = setCalls[setCalls.length - 1];
const savedSolvedProblems = lastCall[0].solvedProblems;
expect(savedSolvedProblems).toContain('group-anagrams');
expect(savedSolvedProblems).toContain('two-sum-ii');
expect(savedSolvedProblems.length).toBe(2);
});

it('should not sync all solved problems on normal startup (respects reset)', async () => {
// Simulate user reset progress - storage is cleared
chrome.storage.sync.get.mockResolvedValue({
currentCategoryIndex: 0,
currentProblemIndex: 0,
Expand All @@ -295,10 +379,66 @@ describe('problemLogic.js', () => {
// Mock problem set (loadProblemSet calls fetch)
global.fetch
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue(mockProblemSet)
})
// Mock aliases (loadAliases calls fetch)
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue(mockAliases)
})
// Mock LeetCode API - user has solved problems, but we shouldn't sync them
// because syncAllSolved=false (normal startup after reset)
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue({
stat_status_pairs: [
{ stat: { question__title_slug: 'two-sum' }, status: 'ac' },
{ stat: { question__title_slug: 'valid-anagram' }, status: 'ac' },
{ stat: { question__title_slug: 'group-anagrams' }, status: null }
]
})
});

// Call with syncAllSolved=false (default, simulating normal startup)
const nextProblem = await problemLogic.computeNextProblem(false);

// Should return problem #1 (two-sum) as first unsolved
// Even though it's solved in LeetCode, we respect the reset
expect(nextProblem).toBeTruthy();
expect(nextProblem.problem.slug).toBe('two-sum');
expect(nextProblem.categoryIndex).toBe(0);
expect(nextProblem.problemIndex).toBe(0);

// Should NOT have synced solved problems from LeetCode
expect(nextProblem.solvedCount).toBe(0);

// Verify saveState was called with empty solvedProblems
expect(chrome.storage.sync.set).toHaveBeenCalled();
const setCalls = chrome.storage.sync.set.mock.calls;
const lastCall = setCalls[setCalls.length - 1];
const savedSolvedProblems = lastCall[0].solvedProblems;
expect(savedSolvedProblems.length).toBe(0);
});

it('should move to next category when current category complete', async () => {
// Pre-populate state with all solved problems from first category
chrome.storage.sync.get.mockResolvedValue({
currentCategoryIndex: 0,
currentProblemIndex: 0,
solvedProblems: ['two-sum', 'valid-anagram', 'group-anagrams']
});

// Set up mocks for this specific test
// Mock problem set (loadProblemSet calls fetch)
global.fetch
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue(mockProblemSet)
})
// Mock aliases (loadAliases calls fetch)
.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue(mockAliases)
})
// Mock LeetCode API (fetchAllProblemStatuses calls fetch)
Expand All @@ -316,6 +456,7 @@ describe('problemLogic.js', () => {
})
});

// Call without syncAllSolved (normal operation, uses existing state)
const nextProblem = await problemLogic.computeNextProblem();

// Should move to next category (Two Pointers) and return first problem there
Expand All @@ -324,27 +465,43 @@ describe('problemLogic.js', () => {
expect(nextProblem.categoryName).toBe('Two Pointers');
expect(nextProblem.categoryIndex).toBe(1);
expect(nextProblem.problemIndex).toBe(0);

// Should have all 3 problems from first category as solved
expect(nextProblem.solvedCount).toBe(3);

// Verify saveState was called with all solved problems
expect(chrome.storage.sync.set).toHaveBeenCalled();
const setCalls = chrome.storage.sync.set.mock.calls;
const lastCall = setCalls[setCalls.length - 1];
const savedSolvedProblems = lastCall[0].solvedProblems;
expect(savedSolvedProblems).toContain('two-sum');
expect(savedSolvedProblems).toContain('valid-anagram');
expect(savedSolvedProblems).toContain('group-anagrams');
expect(savedSolvedProblems.length).toBe(3);
});

it('should return last problem info when all problems solved', async () => {
// Clear caches at start of test to ensure fresh state
clearCaches();

// Pre-populate state with all solved problems
chrome.storage.sync.get.mockResolvedValue({
currentCategoryIndex: 0,
currentProblemIndex: 0,
solvedProblems: []
solvedProblems: ['two-sum', 'valid-anagram', 'group-anagrams', 'valid-palindrome', 'two-sum-ii']
});

// Use mockImplementation to match URL and return appropriate response
global.fetch.mockImplementation((url) => {
if (url.includes('neetcode250.json')) {
return Promise.resolve({
ok: true,
json: jest.fn().mockResolvedValue(mockProblemSet)
});
}
if (url.includes('problemAliases.json')) {
return Promise.resolve({
ok: true,
json: jest.fn().mockResolvedValue(mockAliases)
});
}
Expand All @@ -365,6 +522,7 @@ describe('problemLogic.js', () => {
return Promise.reject(new Error(`Unexpected fetch URL: ${url}`));
});

// Call without syncAllSolved (normal operation, uses existing state)
const nextProblem = await problemLogic.computeNextProblem();

// When all solved, returns last problem info with allSolved flag
Expand Down
6 changes: 5 additions & 1 deletion tests/integration/problemSolve.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,19 +114,22 @@ describe('Problem Solving Integration', () => {
]
};

// Pre-populate state with solved problem (simulating it was solved via extension)
chrome.storage.sync.get.mockResolvedValue({
currentCategoryIndex: 0,
currentProblemIndex: 0,
solvedProblems: []
solvedProblems: ['two-sum']
});

// Mock problem set load (loadProblemSet calls fetch)
global.fetch.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue(mockProblemSet)
});

// Mock aliases load (loadAliases calls fetch)
global.fetch.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue({})
});

Expand All @@ -142,6 +145,7 @@ describe('Problem Solving Integration', () => {
})
});

// Call without syncAllSolved (normal operation, uses existing state)
const nextProblem = await problemLogic.computeNextProblem();

// Should return first unsolved problem (valid-anagram, since two-sum is solved)
Expand Down
Loading