feat: more tests

This commit is contained in:
Barrett Ruth 2025-09-22 19:29:42 -04:00
parent 3bf94cf979
commit 36806d6f5a
3 changed files with 795 additions and 0 deletions

253
spec/command_flow_spec.lua Normal file
View file

@ -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)

View file

@ -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)

View file

@ -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)