From 36806d6f5ab3a8c9f5ebae4715d1643ae2a2a70e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 22 Sep 2025 19:29:42 -0400 Subject: [PATCH] feat: more tests --- spec/command_flow_spec.lua | 253 ++++++++++++++++++++++++++++ spec/error_boundaries_spec.lua | 294 +++++++++++++++++++++++++++++++++ spec/state_contract_spec.lua | 248 +++++++++++++++++++++++++++ 3 files changed, 795 insertions(+) create mode 100644 spec/command_flow_spec.lua create mode 100644 spec/error_boundaries_spec.lua create mode 100644 spec/state_contract_spec.lua diff --git a/spec/command_flow_spec.lua b/spec/command_flow_spec.lua new file mode 100644 index 0000000..f6b0ec7 --- /dev/null +++ b/spec/command_flow_spec.lua @@ -0,0 +1,253 @@ +describe('Command flow integration', function() + local cp + local state + local logged_messages + + before_each(function() + logged_messages = {} + local mock_logger = { + log = function(msg, level) + table.insert(logged_messages, { msg = msg, level = level }) + end, + set_config = function() end, + } + package.loaded['cp.log'] = mock_logger + + -- Mock external dependencies + package.loaded['cp.scrape'] = { + scrape_problem = function(ctx) + return { + success = true, + problem_id = ctx.problem_id, + test_cases = { + { input = '1 2', expected = '3' }, + { input = '3 4', expected = '7' }, + }, + test_count = 2, + } + end, + scrape_contest_metadata = function(platform, contest_id) + return { + success = true, + problems = { + { id = 'a' }, + { id = 'b' }, + { id = 'c' }, + }, + } + end, + scrape_problems_parallel = function() + return {} + end, + } + + local cache = require('cp.cache') + cache.load = function() end + cache.set_test_cases = function() end + cache.set_file_state = function() end + cache.get_file_state = function() + return nil + end + cache.get_contest_data = function(platform, contest_id) + if platform == 'codeforces' and contest_id == '1234' then + return { + problems = { + { id = 'a' }, + { id = 'b' }, + { id = 'c' }, + }, + } + end + return nil + end + cache.get_test_cases = function() + return { + { input = '1 2', expected = '3' }, + } + end + + -- Mock vim functions + if not vim.fn then + vim.fn = {} + end + vim.fn.expand = vim.fn.expand or function() + return '/tmp/test.cpp' + end + vim.fn.mkdir = vim.fn.mkdir or function() end + vim.fn.fnamemodify = vim.fn.fnamemodify or function(path) + return path + end + if not vim.api then + vim.api = {} + end + vim.api.nvim_get_current_buf = vim.api.nvim_get_current_buf or function() + return 1 + end + vim.api.nvim_buf_get_lines = vim.api.nvim_buf_get_lines + or function() + return { '' } + end + if not vim.cmd then + vim.cmd = {} + end + vim.cmd.e = function() end + vim.cmd.only = function() end + if not vim.system then + vim.system = function(cmd) + return { + wait = function() + return { code = 0 } + end, + } + end + end + + state = require('cp.state') + state.reset() + + cp = require('cp') + cp.setup({ + contests = { + codeforces = { + default_language = 'cpp', + cpp = { extension = 'cpp', test = { 'echo', 'test' } }, + }, + }, + scrapers = { 'codeforces' }, + }) + end) + + after_each(function() + package.loaded['cp.log'] = nil + package.loaded['cp.scrape'] = nil + if state then + state.reset() + end + end) + + it('should handle complete setup → run workflow', function() + -- 1. Setup problem + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'codeforces', '1234', 'a' } }) + end) + + -- 2. Verify state was set correctly + local context = cp.get_current_context() + assert.equals('codeforces', context.platform) + assert.equals('1234', context.contest_id) + assert.equals('a', context.problem_id) + + -- 3. Run panel - this is where the bug occurred + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'run' } }) + end) + + -- Should not have validation errors + local has_validation_error = false + for _, log_entry in ipairs(logged_messages) do + if log_entry.msg:match('expected string, got nil') then + has_validation_error = true + break + end + end + assert.is_false(has_validation_error) + end) + + it('should handle problem navigation workflow', function() + -- 1. Setup contest + cp.handle_command({ fargs = { 'codeforces', '1234', 'a' } }) + assert.equals('a', cp.get_current_context().problem_id) + + -- 2. Navigate to next problem + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'next' } }) + end) + assert.equals('b', cp.get_current_context().problem_id) + + -- 3. Navigate to previous problem + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'prev' } }) + end) + assert.equals('a', cp.get_current_context().problem_id) + + -- 4. Each step should be able to run panel + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'run' } }) + end) + end) + + it('should handle contest setup → problem switch workflow', function() + -- 1. Setup contest (not specific problem) + cp.handle_command({ fargs = { 'codeforces', '1234' } }) + local context = cp.get_current_context() + assert.equals('codeforces', context.platform) + assert.equals('1234', context.contest_id) + + -- 2. Switch to specific problem + cp.handle_command({ fargs = { 'codeforces', '1234', 'b' } }) + assert.equals('b', cp.get_current_context().problem_id) + + -- 3. Should be able to run + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'run' } }) + end) + end) + + it('should handle invalid commands gracefully without state corruption', function() + -- Setup valid state + cp.handle_command({ fargs = { 'codeforces', '1234', 'a' } }) + local original_context = cp.get_current_context() + + -- Try invalid command + cp.handle_command({ fargs = { 'invalid_platform', 'invalid_contest' } }) + + -- State should be unchanged + local context_after_invalid = cp.get_current_context() + assert.equals(original_context.platform, context_after_invalid.platform) + assert.equals(original_context.contest_id, context_after_invalid.contest_id) + assert.equals(original_context.problem_id, context_after_invalid.problem_id) + + -- Should still be able to run + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'run' } }) + end) + end) + + it('should handle commands with flags correctly', function() + -- Test language flags + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'codeforces', '1234', 'a', '--lang=cpp' } }) + end) + + -- Test debug flags + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'run', '--debug' } }) + end) + + -- Test combined flags + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'run', '--lang=cpp', '--debug' } }) + end) + end) + + it('should handle cache commands without affecting problem state', function() + -- Setup problem + cp.handle_command({ fargs = { 'codeforces', '1234', 'a' } }) + local original_context = cp.get_current_context() + + -- Run cache commands + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'cache', 'clear' } }) + end) + + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'cache', 'clear', 'codeforces' } }) + end) + + -- Problem state should be unchanged + local context_after_cache = cp.get_current_context() + assert.equals(original_context.platform, context_after_cache.platform) + assert.equals(original_context.contest_id, context_after_cache.contest_id) + assert.equals(original_context.problem_id, context_after_cache.problem_id) + end) +end) diff --git a/spec/error_boundaries_spec.lua b/spec/error_boundaries_spec.lua new file mode 100644 index 0000000..55815c2 --- /dev/null +++ b/spec/error_boundaries_spec.lua @@ -0,0 +1,294 @@ +describe('Error boundary handling', function() + local cp + local state + local logged_messages + + before_each(function() + logged_messages = {} + local mock_logger = { + log = function(msg, level) + table.insert(logged_messages, { msg = msg, level = level }) + end, + set_config = function() end, + } + package.loaded['cp.log'] = mock_logger + + -- Mock dependencies that could fail + package.loaded['cp.scrape'] = { + scrape_problem = function(ctx) + -- Sometimes fail to simulate network issues + if ctx.contest_id == 'fail_scrape' then + return { + success = false, + error = 'Network error', + } + end + return { + success = true, + problem_id = ctx.problem_id, + test_cases = { + { input = '1', expected = '2' }, + }, + test_count = 1, + } + end, + scrape_contest_metadata = function(platform, contest_id) + if contest_id == 'fail_metadata' then + return { + success = false, + error = 'Contest not found', + } + end + return { + success = true, + problems = { + { id = 'a' }, + { id = 'b' }, + }, + } + end, + scrape_problems_parallel = function() + return {} + end, + } + + local cache = require('cp.cache') + cache.load = function() end + cache.set_test_cases = function() end + cache.set_file_state = function() end + cache.get_file_state = function() + return nil + end + cache.get_contest_data = function() + return nil + end + cache.get_test_cases = function() + return {} + end + + -- Mock vim functions + if not vim.fn then + vim.fn = {} + end + vim.fn.expand = vim.fn.expand or function() + return '/tmp/test.cpp' + end + vim.fn.mkdir = vim.fn.mkdir or function() end + if not vim.api then + vim.api = {} + end + vim.api.nvim_get_current_buf = vim.api.nvim_get_current_buf or function() + return 1 + end + vim.api.nvim_buf_get_lines = vim.api.nvim_buf_get_lines + or function() + return { '' } + end + if not vim.cmd then + vim.cmd = {} + end + vim.cmd.e = function() end + vim.cmd.only = function() end + if not vim.system then + vim.system = function(cmd) + return { + wait = function() + return { code = 0 } + end, + } + end + end + + state = require('cp.state') + state.reset() + + cp = require('cp') + cp.setup({ + contests = { + codeforces = { + default_language = 'cpp', + cpp = { extension = 'cpp', test = { 'echo', 'test' } }, + }, + }, + scrapers = { 'codeforces' }, + }) + end) + + after_each(function() + package.loaded['cp.log'] = nil + package.loaded['cp.scrape'] = nil + if state then + state.reset() + end + end) + + it('should handle setup failures gracefully without breaking runner', function() + -- Try invalid platform + cp.handle_command({ fargs = { 'invalid_platform', '1234', 'a' } }) + + -- Should have logged error + local has_error = false + for _, log_entry in ipairs(logged_messages) do + if log_entry.level == vim.log.levels.ERROR then + has_error = true + break + end + end + assert.is_true(has_error, 'Should log error for invalid platform') + + -- State should remain clean + local context = cp.get_current_context() + assert.is_nil(context.platform) + + -- Runner should handle this gracefully + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'run' } }) -- Should log error, not crash + end) + end) + + it('should handle scraping failures without state corruption', function() + -- Setup should fail due to scraping failure + cp.handle_command({ fargs = { 'codeforces', 'fail_scrape', 'a' } }) + + -- Should have logged scraping error + local has_scrape_error = false + for _, log_entry in ipairs(logged_messages) do + if log_entry.msg and log_entry.msg:match('scraping failed') then + has_scrape_error = true + break + end + end + assert.is_true(has_scrape_error, 'Should log scraping failure') + + -- State should still be set (platform and contest) + local context = cp.get_current_context() + assert.equals('codeforces', context.platform) + assert.equals('fail_scrape', context.contest_id) + + -- But should handle run gracefully + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'run' } }) + end) + end) + + it('should handle missing contest data without crashing navigation', function() + -- Setup with valid platform but no contest data + state.set_platform('codeforces') + state.set_contest_id('nonexistent') + state.set_problem_id('a') + + -- Navigation should fail gracefully + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'next' } }) + end) + + -- Should log appropriate error + local has_nav_error = false + for _, log_entry in ipairs(logged_messages) do + if log_entry.msg and log_entry.msg:match('no contest metadata found') then + has_nav_error = true + break + end + end + assert.is_true(has_nav_error, 'Should log navigation error') + end) + + it('should handle validation errors without crashing', function() + -- This would previously cause validation errors + state.reset() -- All state is nil + + -- Commands should handle nil state gracefully + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'next' } }) + end) + + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'prev' } }) + end) + + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'run' } }) + end) + + -- Should have appropriate errors, not validation errors + local has_validation_error = false + local has_appropriate_errors = 0 + for _, log_entry in ipairs(logged_messages) do + if log_entry.msg and log_entry.msg:match('expected string, got nil') then + has_validation_error = true + elseif + log_entry.msg + and (log_entry.msg:match('no contest set') or log_entry.msg:match('No contest configured')) + then + has_appropriate_errors = has_appropriate_errors + 1 + end + end + + assert.is_false(has_validation_error, 'Should not have validation errors') + assert.is_true(has_appropriate_errors > 0, 'Should have user-facing errors') + end) + + it('should handle partial state gracefully', function() + -- Set only platform, not contest + state.set_platform('codeforces') + + -- Commands should handle partial state + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'run' } }) + end) + + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'next' } }) + end) + + -- Should get appropriate errors about missing contest + local missing_contest_errors = 0 + for _, log_entry in ipairs(logged_messages) do + if + log_entry.msg and (log_entry.msg:match('no contest') or log_entry.msg:match('No contest')) + then + missing_contest_errors = missing_contest_errors + 1 + end + end + assert.is_true(missing_contest_errors > 0, 'Should report missing contest') + end) + + it('should isolate command parsing errors from execution', function() + -- Test malformed commands + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'cache' } }) -- Missing subcommand + end) + + assert.has_no_errors(function() + cp.handle_command({ fargs = { '--lang' } }) -- Missing value + end) + + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'too', 'many', 'args', 'here', 'extra' } }) + end) + + -- All should result in error messages, not crashes + assert.is_true(#logged_messages > 0, 'Should have logged errors') + + local crash_count = 0 + for _, log_entry in ipairs(logged_messages) do + if log_entry.msg and log_entry.msg:match('stack traceback') then + crash_count = crash_count + 1 + end + end + assert.equals(0, crash_count, 'Should not have any crashes') + end) + + it('should handle module loading failures gracefully', function() + -- Test with missing optional dependencies + local original_picker_module = package.loaded['cp.commands.picker'] + package.loaded['cp.commands.picker'] = nil + + -- Pick command should handle missing module + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'pick' } }) + end) + + package.loaded['cp.commands.picker'] = original_picker_module + end) +end) diff --git a/spec/state_contract_spec.lua b/spec/state_contract_spec.lua new file mode 100644 index 0000000..71fe929 --- /dev/null +++ b/spec/state_contract_spec.lua @@ -0,0 +1,248 @@ +describe('State module contracts', function() + local cp + local state + local logged_messages + local original_scrape_problem + local original_scrape_contest_metadata + local original_cache_get_test_cases + + before_each(function() + logged_messages = {} + local mock_logger = { + log = function(msg, level) + table.insert(logged_messages, { msg = msg, level = level }) + end, + set_config = function() end, + } + package.loaded['cp.log'] = mock_logger + + -- Mock scraping to avoid network calls + original_scrape_problem = package.loaded['cp.scrape'] + package.loaded['cp.scrape'] = { + scrape_problem = function(ctx) + return { + success = true, + problem_id = ctx.problem_id, + test_cases = { + { input = 'test input', expected = 'test output' }, + }, + test_count = 1, + } + end, + scrape_contest_metadata = function(platform, contest_id) + return { + success = true, + problems = { + { id = 'a' }, + { id = 'b' }, + { id = 'c' }, + }, + } + end, + scrape_problems_parallel = function() + return {} + end, + } + + -- Mock cache to avoid file system + local cache = require('cp.cache') + original_cache_get_test_cases = cache.get_test_cases + cache.get_test_cases = function(platform, contest_id, problem_id) + -- Return some mock test cases + return { + { input = 'mock input', expected = 'mock output' }, + } + end + + -- Mock cache load/save to be no-ops + cache.load = function() end + cache.set_test_cases = function() end + cache.set_file_state = function() end + cache.get_file_state = function() + return nil + end + cache.get_contest_data = function() + return nil + end + + -- Mock vim functions that might not exist in test + if not vim.fn then + vim.fn = {} + end + vim.fn.expand = vim.fn.expand or function() + return '/tmp/test.cpp' + end + vim.fn.mkdir = vim.fn.mkdir or function() end + vim.fn.fnamemodify = vim.fn.fnamemodify or function(path) + return path + end + vim.fn.tempname = vim.fn.tempname or function() + return '/tmp/session' + end + if not vim.api then + vim.api = {} + end + vim.api.nvim_get_current_buf = vim.api.nvim_get_current_buf or function() + return 1 + end + vim.api.nvim_buf_get_lines = vim.api.nvim_buf_get_lines + or function() + return { '' } + end + if not vim.cmd then + vim.cmd = {} + end + vim.cmd.e = function() end + vim.cmd.only = function() end + vim.cmd.split = function() end + vim.cmd.vsplit = function() end + if not vim.system then + vim.system = function(cmd) + return { + wait = function() + return { code = 0 } + end, + } + end + end + + -- Reset state completely + state = require('cp.state') + state.reset() + + cp = require('cp') + cp.setup({ + contests = { + codeforces = { + default_language = 'cpp', + cpp = { extension = 'cpp', test = { 'echo', 'test' } }, + }, + }, + scrapers = { 'codeforces' }, + }) + end) + + after_each(function() + package.loaded['cp.log'] = nil + if original_scrape_problem then + package.loaded['cp.scrape'] = original_scrape_problem + end + if original_cache_get_test_cases then + local cache = require('cp.cache') + cache.get_test_cases = original_cache_get_test_cases + end + if state then + state.reset() + end + end) + + it('should enforce that all modules use state getters, not direct properties', function() + local state_module = require('cp.state') + + -- State module should expose getter functions + assert.equals('function', type(state_module.get_platform)) + assert.equals('function', type(state_module.get_contest_id)) + assert.equals('function', type(state_module.get_problem_id)) + + -- State module should NOT expose internal state properties directly + -- (This prevents the bug we just fixed) + assert.is_nil(state_module.platform) + assert.is_nil(state_module.contest_id) + assert.is_nil(state_module.problem_id) + end) + + it('should maintain state consistency between context and direct access', function() + -- Set up a problem + cp.handle_command({ fargs = { 'codeforces', '1234', 'a' } }) + + -- Get context through public API + local context = cp.get_current_context() + + -- Get values through state module directly + local direct_access = { + platform = state.get_platform(), + contest_id = state.get_contest_id(), + problem_id = state.get_problem_id(), + } + + -- These should be identical + assert.equals(context.platform, direct_access.platform) + assert.equals(context.contest_id, direct_access.contest_id) + assert.equals(context.problem_id, direct_access.problem_id) + end) + + it('should handle nil state values gracefully in all consumers', function() + -- Start with clean state (all nil) + state.reset() + + -- This should NOT crash with "expected string, got nil" + assert.has_no_errors(function() + cp.handle_command({ fargs = { 'run' } }) + end) + + -- Should log appropriate error, not validation error + local has_validation_error = false + local has_appropriate_error = false + for _, log_entry in ipairs(logged_messages) do + if log_entry.msg:match('expected string, got nil') then + has_validation_error = true + elseif log_entry.msg:match('No contest configured') then + has_appropriate_error = true + end + end + + assert.is_false(has_validation_error, 'Should not have validation errors') + assert.is_true(has_appropriate_error, 'Should have appropriate user-facing error') + end) + + it('should pass state module (not state data) to runner functions', function() + -- This is the core bug we fixed - runner expects state module, not state data + local run = require('cp.runner.run') + local problem = require('cp.problem') + + -- Set up proper state + state.set_platform('codeforces') + state.set_contest_id('1234') + state.set_problem_id('a') + + local ctx = problem.create_context('codeforces', '1234', 'a', { + contests = { codeforces = { cpp = { extension = 'cpp' } } }, + }) + + -- This should work - passing the state MODULE + assert.has_no_errors(function() + run.load_test_cases(ctx, state) + end) + + -- This would be the bug - passing state DATA instead of state MODULE + local fake_state_data = { + platform = 'codeforces', + contest_id = '1234', + problem_id = 'a', + } + + -- This should fail gracefully (function should check for get_* methods) + local success = pcall(function() + run.load_test_cases(ctx, fake_state_data) + end) + + -- The current implementation would crash because fake_state_data has no get_* methods + -- This test documents the expected behavior + assert.is_false(success, 'Should fail when passed wrong state type') + end) + + it('should handle state transitions correctly', function() + -- Test that state changes are reflected everywhere + + -- Initial state + cp.handle_command({ fargs = { 'codeforces', '1234', 'a' } }) + assert.equals('a', cp.get_current_context().problem_id) + + -- Navigate to next problem + cp.handle_command({ fargs = { 'codeforces', '1234', 'b' } }) + assert.equals('b', cp.get_current_context().problem_id) + + -- State should be consistent everywhere + assert.equals('b', state.get_problem_id()) + end) +end)