From a32fd396d3a2e2576053038bb56293acdc69e38c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 22 Sep 2025 22:59:57 -0400 Subject: [PATCH] feat: async scraper --- lua/cp/async/init.lua | 25 ++ lua/cp/async/jobs.lua | 44 +++ lua/cp/async/scraper.lua | 202 ++++++++++++++ lua/cp/async/setup.lua | 271 ++++++++++++++++++ lua/cp/commands/init.lua | 10 +- spec/async_init_spec.lua | 50 ++++ spec/async_integration_spec.lua | 288 +++++++++++++++++++ spec/async_jobs_spec.lua | 111 ++++++++ spec/async_scraper_spec.lua | 185 +++++++++++++ spec/async_setup_spec.lua | 286 +++++++++++++++++++ spec/scraper_spec.lua | 470 -------------------------------- spec/spec_helper.lua | 59 ++++ 12 files changed, 1527 insertions(+), 474 deletions(-) create mode 100644 lua/cp/async/init.lua create mode 100644 lua/cp/async/jobs.lua create mode 100644 lua/cp/async/scraper.lua create mode 100644 lua/cp/async/setup.lua create mode 100644 spec/async_init_spec.lua create mode 100644 spec/async_integration_spec.lua create mode 100644 spec/async_jobs_spec.lua create mode 100644 spec/async_scraper_spec.lua create mode 100644 spec/async_setup_spec.lua delete mode 100644 spec/scraper_spec.lua diff --git a/lua/cp/async/init.lua b/lua/cp/async/init.lua new file mode 100644 index 0000000..eac155f --- /dev/null +++ b/lua/cp/async/init.lua @@ -0,0 +1,25 @@ +local M = {} + +local active_operation = nil + +function M.start_contest_operation(operation_name) + if active_operation then + error( + ("Contest operation '%s' already active, cannot start '%s'"):format( + active_operation, + operation_name + ) + ) + end + active_operation = operation_name +end + +function M.finish_contest_operation() + active_operation = nil +end + +function M.get_active_operation() + return active_operation +end + +return M diff --git a/lua/cp/async/jobs.lua b/lua/cp/async/jobs.lua new file mode 100644 index 0000000..17abb35 --- /dev/null +++ b/lua/cp/async/jobs.lua @@ -0,0 +1,44 @@ +local M = {} + +local current_jobs = {} + +function M.start_job(job_id, args, opts, callback) + opts = opts or {} + + if current_jobs[job_id] then + current_jobs[job_id]:kill(9) + current_jobs[job_id] = nil + end + + local job = vim.system(args, opts, function(result) + current_jobs[job_id] = nil + callback(result) + end) + + current_jobs[job_id] = job + return job +end + +function M.kill_job(job_id) + if current_jobs[job_id] then + current_jobs[job_id]:kill(9) + current_jobs[job_id] = nil + end +end + +function M.kill_all_jobs() + for job_id, job in pairs(current_jobs) do + job:kill(9) + end + current_jobs = {} +end + +function M.get_active_jobs() + local active = {} + for job_id, _ in pairs(current_jobs) do + table.insert(active, job_id) + end + return active +end + +return M diff --git a/lua/cp/async/scraper.lua b/lua/cp/async/scraper.lua new file mode 100644 index 0000000..ff6789f --- /dev/null +++ b/lua/cp/async/scraper.lua @@ -0,0 +1,202 @@ +local M = {} +local cache = require('cp.cache') +local jobs = require('cp.async.jobs') +local utils = require('cp.utils') + +local function check_internet_connectivity() + local result = vim.system({ 'ping', '-c', '5', '-W', '3', '8.8.8.8' }, { text = true }):wait() + return result.code == 0 +end + +function M.scrape_contest_metadata_async(platform, contest_id, callback) + vim.validate({ + platform = { platform, 'string' }, + contest_id = { contest_id, 'string' }, + callback = { callback, 'function' }, + }) + + cache.load() + + local cached_data = cache.get_contest_data(platform, contest_id) + if cached_data then + callback({ + success = true, + problems = cached_data.problems, + }) + return + end + + if not check_internet_connectivity() then + callback({ + success = false, + error = 'No internet connection available', + }) + return + end + + if not utils.setup_python_env() then + callback({ + success = false, + error = 'Python environment setup failed', + }) + return + end + + local plugin_path = utils.get_plugin_path() + + local args = { + 'uv', + 'run', + '--directory', + plugin_path, + '-m', + 'scrapers.' .. platform, + 'metadata', + contest_id, + } + + local job_id = 'contest_metadata_' .. platform .. '_' .. contest_id + + jobs.start_job(job_id, args, { + cwd = plugin_path, + text = true, + timeout = 30000, + }, function(result) + if result.code ~= 0 then + callback({ + success = false, + error = 'Failed to run metadata scraper: ' .. (result.stderr or 'Unknown error'), + }) + return + end + + local ok, data = pcall(vim.json.decode, result.stdout) + if not ok then + callback({ + success = false, + error = 'Failed to parse metadata scraper output: ' .. tostring(data), + }) + return + end + + if not data.success then + callback(data) + return + end + + local problems_list = data.problems or {} + cache.set_contest_data(platform, contest_id, problems_list) + + callback({ + success = true, + problems = problems_list, + }) + end) +end + +function M.scrape_problem_async(platform, contest_id, problem_id, callback) + vim.validate({ + platform = { platform, 'string' }, + contest_id = { contest_id, 'string' }, + problem_id = { problem_id, 'string' }, + callback = { callback, 'function' }, + }) + + if not check_internet_connectivity() then + callback({ + success = false, + problem_id = problem_id, + error = 'No internet connection available', + }) + return + end + + if not utils.setup_python_env() then + callback({ + success = false, + problem_id = problem_id, + error = 'Python environment setup failed', + }) + return + end + + local plugin_path = utils.get_plugin_path() + + local args = { + 'uv', + 'run', + '--directory', + plugin_path, + '-m', + 'scrapers.' .. platform, + 'tests', + contest_id, + problem_id, + } + + local job_id = 'problem_tests_' .. platform .. '_' .. contest_id .. '_' .. problem_id + + jobs.start_job(job_id, args, { + cwd = plugin_path, + text = true, + timeout = 30000, + }, function(result) + if result.code ~= 0 then + callback({ + success = false, + problem_id = problem_id, + error = 'Failed to run tests scraper: ' .. (result.stderr or 'Unknown error'), + }) + return + end + + local ok, data = pcall(vim.json.decode, result.stdout) + if not ok then + callback({ + success = false, + problem_id = problem_id, + error = 'Failed to parse tests scraper output: ' .. tostring(data), + }) + return + end + + if not data.success then + callback(data) + return + end + + if data.tests and #data.tests > 0 then + vim.fn.mkdir('io', 'p') + + local cached_test_cases = {} + for i, test_case in ipairs(data.tests) do + table.insert(cached_test_cases, { + index = i, + input = test_case.input, + expected = test_case.expected, + }) + end + + cache.set_test_cases( + platform, + contest_id, + problem_id, + cached_test_cases, + data.timeout_ms, + data.memory_mb + ) + end + + callback({ + success = true, + problem_id = problem_id, + test_count = data.tests and #data.tests or 0, + test_cases = data.tests, + timeout_ms = data.timeout_ms, + memory_mb = data.memory_mb, + url = data.url, + }) + end) +end + +return M diff --git a/lua/cp/async/setup.lua b/lua/cp/async/setup.lua new file mode 100644 index 0000000..5ff19ae --- /dev/null +++ b/lua/cp/async/setup.lua @@ -0,0 +1,271 @@ +local M = {} + +local async = require('cp.async') +local async_scraper = require('cp.async.scraper') +local cache = require('cp.cache') +local config_module = require('cp.config') +local logger = require('cp.log') +local problem = require('cp.problem') +local state = require('cp.state') + +function M.setup_contest_async(contest_id, language) + if not state.get_platform() then + logger.log('no platform set', vim.log.levels.ERROR) + return + end + + async.start_contest_operation('contest_setup') + + local config = config_module.get_config() + local platform = state.get_platform() or '' + + if not vim.tbl_contains(config.scrapers, platform) then + logger.log('scraping disabled for ' .. platform, vim.log.levels.WARN) + async.finish_contest_operation() + return + end + + logger.log(('setting up contest %s %s'):format(platform, contest_id)) + + async_scraper.scrape_contest_metadata_async(platform, contest_id, function(metadata_result) + if not metadata_result.success then + logger.log( + 'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'), + vim.log.levels.ERROR + ) + async.finish_contest_operation() + return + end + + local problems = metadata_result.problems + if not problems or #problems == 0 then + logger.log('no problems found in contest', vim.log.levels.ERROR) + async.finish_contest_operation() + return + end + + logger.log(('found %d problems'):format(#problems)) + + state.set_contest_id(contest_id) + M.setup_problem_async(contest_id, problems[1].id, language) + + M.start_background_problem_scraping(contest_id, problems, config) + end) +end + +function M.setup_problem_async(contest_id, problem_id, language) + if not state.get_platform() then + logger.log('no platform set. run :CP first', vim.log.levels.ERROR) + return + end + + local config = config_module.get_config() + local problem_name = contest_id .. (problem_id or '') + logger.log(('setting up problem: %s'):format(problem_name)) + + local ctx = + problem.create_context(state.get_platform() or '', contest_id, problem_id, config, language) + + local cached_test_cases = cache.get_test_cases(state.get_platform() or '', contest_id, problem_id) + if cached_test_cases then + state.set_test_cases(cached_test_cases) + logger.log(('using cached test cases (%d)'):format(#cached_test_cases)) + elseif vim.tbl_contains(config.scrapers, state.get_platform() or '') then + logger.log('test cases not cached, will scrape in background...') + state.set_test_cases(nil) + + async_scraper.scrape_problem_async( + state.get_platform() or '', + contest_id, + problem_id, + function(scrape_result) + if scrape_result.success then + local test_count = scrape_result.test_count or 0 + logger.log( + ('scraped %d test case(s) for %s'):format(test_count, scrape_result.problem_id) + ) + state.set_test_cases(scrape_result.test_cases) + else + logger.log( + 'scraping failed: ' .. (scrape_result.error or 'unknown error'), + vim.log.levels.ERROR + ) + end + end + ) + else + logger.log(('scraping disabled for %s'):format(state.get_platform() or '')) + state.set_test_cases(nil) + end + + vim.cmd('silent only') + state.set_run_panel_active(false) + state.set_contest_id(contest_id) + state.set_problem_id(problem_id) + + vim.cmd.e(ctx.source_file) + local source_buf = vim.api.nvim_get_current_buf() + + if vim.api.nvim_buf_get_lines(source_buf, 0, -1, true)[1] == '' then + local constants = require('cp.constants') + local has_luasnip, luasnip = pcall(require, 'luasnip') + if has_luasnip then + local filetype = vim.api.nvim_get_option_value('filetype', { buf = source_buf }) + local language_name = constants.filetype_to_language[filetype] + local canonical_language = constants.canonical_filetypes[language_name] or language_name + local prefixed_trigger = ('cp.nvim/%s.%s'):format(state.get_platform(), canonical_language) + + vim.api.nvim_buf_set_lines(0, 0, -1, false, { prefixed_trigger }) + vim.api.nvim_win_set_cursor(0, { 1, #prefixed_trigger }) + vim.cmd.startinsert({ bang = true }) + + vim.schedule(function() + if luasnip.expandable() then + luasnip.expand() + else + vim.api.nvim_buf_set_lines(0, 0, 1, false, { '' }) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + end + vim.cmd.stopinsert() + end) + else + vim.api.nvim_input(('i%s'):format(state.get_platform())) + end + end + + if config.hooks and config.hooks.setup_code then + config.hooks.setup_code(ctx) + end + + cache.set_file_state( + vim.fn.expand('%:p'), + state.get_platform() or '', + contest_id, + problem_id, + language + ) + + logger.log(('switched to problem %s'):format(ctx.problem_name)) + async.finish_contest_operation() +end + +function M.start_background_problem_scraping(contest_id, problems, config) + cache.load() + local platform = state.get_platform() or '' + local missing_problems = {} + + for _, prob in ipairs(problems) do + local cached_tests = cache.get_test_cases(platform, contest_id, prob.id) + if not cached_tests then + table.insert(missing_problems, prob) + end + end + + if #missing_problems == 0 then + logger.log('all problems already cached') + return + end + + logger.log(('scraping %d uncached problems in background...'):format(#missing_problems)) + + local success_count = 0 + local failed_problems = {} + local total_problems = #missing_problems + + for _, prob in ipairs(missing_problems) do + async_scraper.scrape_problem_async(platform, contest_id, prob.id, function(result) + if result.success then + success_count = success_count + 1 + else + table.insert(failed_problems, prob.id) + end + + local completed = success_count + #failed_problems + if completed == total_problems then + if #failed_problems > 0 then + logger.log( + ('background scraping complete: %d/%d successful, failed: %s'):format( + success_count, + total_problems, + table.concat(failed_problems, ', ') + ), + vim.log.levels.WARN + ) + else + logger.log( + ('background scraping complete: %d/%d successful'):format(success_count, total_problems) + ) + end + end + end) + end +end + +function M.handle_full_setup_async(cmd) + async.start_contest_operation('full_setup') + + state.set_contest_id(cmd.contest) + local config = config_module.get_config() + + if vim.tbl_contains(config.scrapers, cmd.platform) then + async_scraper.scrape_contest_metadata_async(cmd.platform, cmd.contest, function(metadata_result) + if not metadata_result.success then + logger.log( + 'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'), + vim.log.levels.ERROR + ) + async.finish_contest_operation() + return + end + + logger.log( + ('loaded %d problems for %s %s'):format( + #metadata_result.problems, + cmd.platform, + cmd.contest + ), + vim.log.levels.INFO, + true + ) + + local problem_ids = vim.tbl_map(function(prob) + return prob.id + end, metadata_result.problems) + + if not vim.tbl_contains(problem_ids, cmd.problem) then + logger.log( + ("Invalid problem '%s' for contest %s %s"):format(cmd.problem, cmd.platform, cmd.contest), + vim.log.levels.ERROR + ) + async.finish_contest_operation() + return + end + + M.setup_problem_async(cmd.contest, cmd.problem, cmd.language) + end) + else + cache.load() + local contest_data = cache.get_contest_data(cmd.platform or '', cmd.contest) + if contest_data and contest_data.problems then + local problem_ids = vim.tbl_map(function(prob) + return prob.id + end, contest_data.problems) + + if not vim.tbl_contains(problem_ids, cmd.problem) then + logger.log( + ("Invalid problem '%s' for contest %s %s"):format(cmd.problem, cmd.platform, cmd.contest), + vim.log.levels.ERROR + ) + async.finish_contest_operation() + return + end + + M.setup_problem_async(cmd.contest, cmd.problem, cmd.language) + else + logger.log('no contest data available', vim.log.levels.ERROR) + async.finish_contest_operation() + end + end +end + +return M diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 0ef9c3a..8b733e0 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -153,23 +153,25 @@ function M.handle_command(opts) if cmd.type == 'contest_setup' then local setup = require('cp.setup') + local async_setup = require('cp.async.setup') if setup.set_platform(cmd.platform) then - setup.setup_contest(cmd.contest, cmd.language) + async_setup.setup_contest_async(cmd.contest, cmd.language) end return end if cmd.type == 'full_setup' then local setup = require('cp.setup') + local async_setup = require('cp.async.setup') if setup.set_platform(cmd.platform) then - setup.handle_full_setup(cmd) + async_setup.handle_full_setup_async(cmd) end return end if cmd.type == 'problem_switch' then - local setup = require('cp.setup') - setup.setup_problem(state.get_contest_id() or '', cmd.problem, cmd.language) + local async_setup = require('cp.async.setup') + async_setup.setup_problem_async(state.get_contest_id() or '', cmd.problem, cmd.language) return end end diff --git a/spec/async_init_spec.lua b/spec/async_init_spec.lua new file mode 100644 index 0000000..58e15bf --- /dev/null +++ b/spec/async_init_spec.lua @@ -0,0 +1,50 @@ +describe('cp.async.init', function() + local async + local spec_helper = require('spec.spec_helper') + + before_each(function() + spec_helper.setup() + async = spec_helper.fresh_require('cp.async.init') + end) + + after_each(function() + spec_helper.teardown() + end) + + describe('contest operation guard', function() + it('allows starting operation when none active', function() + assert.has_no_errors(function() + async.start_contest_operation('test_operation') + end) + assert.equals('test_operation', async.get_active_operation()) + end) + + it('throws error when starting operation while one is active', function() + async.start_contest_operation('first_operation') + + assert.has_error(function() + async.start_contest_operation('second_operation') + end, "Contest operation 'first_operation' already active, cannot start 'second_operation'") + end) + + it('allows starting operation after finishing previous one', function() + async.start_contest_operation('first_operation') + async.finish_contest_operation() + + assert.has_no_errors(function() + async.start_contest_operation('second_operation') + end) + assert.equals('second_operation', async.get_active_operation()) + end) + + it('correctly reports active operation status', function() + assert.is_nil(async.get_active_operation()) + + async.start_contest_operation('test_operation') + assert.equals('test_operation', async.get_active_operation()) + + async.finish_contest_operation() + assert.is_nil(async.get_active_operation()) + end) + end) +end) diff --git a/spec/async_integration_spec.lua b/spec/async_integration_spec.lua new file mode 100644 index 0000000..b789a58 --- /dev/null +++ b/spec/async_integration_spec.lua @@ -0,0 +1,288 @@ +describe('async integration', function() + local cp + local spec_helper = require('spec.spec_helper') + 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 + + spec_helper.mock_async_scraper_success() + + local mock_async = { + start_contest_operation = function() end, + finish_contest_operation = function() end, + get_active_operation = function() + return nil + end, + } + + local mock_state = { + get_platform = function() + return 'atcoder' + end, + get_contest_id = function() + return 'abc123' + end, + set_platform = function() end, + set_contest_id = function() end, + set_problem_id = function() end, + set_test_cases = function() end, + set_run_panel_active = function() end, + } + + local mock_config = { + setup = function() + return {} + end, + get_config = function() + return { + scrapers = { 'atcoder', 'codeforces' }, + hooks = nil, + } + end, + } + + local mock_cache = { + load = function() end, + get_contest_data = function() + return nil + end, + get_test_cases = function() + return nil + end, + set_file_state = function() end, + } + + local mock_problem = { + create_context = function() + return { + source_file = '/test/source.cpp', + problem_name = 'abc123a', + } + end, + } + + local mock_setup = { + set_platform = function() + return true + end, + } + + vim.cmd = { e = function() end, only = function() end } + vim.api.nvim_get_current_buf = function() + return 1 + end + vim.api.nvim_buf_get_lines = function() + return { '' } + end + vim.fn.expand = function() + return '/test/file.cpp' + end + + package.loaded['cp.async'] = mock_async + package.loaded['cp.state'] = mock_state + package.loaded['cp.config'] = mock_config + package.loaded['cp.cache'] = mock_cache + package.loaded['cp.problem'] = mock_problem + package.loaded['cp.setup'] = mock_setup + + cp = spec_helper.fresh_require('cp') + cp.setup({}) + end) + + after_each(function() + spec_helper.teardown() + logged_messages = {} + end) + + describe('command routing', function() + it('contest_setup command uses async setup', function() + local opts = { fargs = { 'atcoder', 'abc123' } } + + assert.has_no_errors(function() + cp.handle_command(opts) + end) + end) + + it('full_setup command uses async setup', function() + local opts = { fargs = { 'atcoder', 'abc123', 'a' } } + + assert.has_no_errors(function() + cp.handle_command(opts) + end) + end) + + it('problem_switch uses async setup', function() + local mock_state = require('cp.state') + mock_state.get_contest_id = function() + return 'abc123' + end + + local opts = { fargs = { 'a' } } + + assert.has_no_errors(function() + cp.handle_command(opts) + end) + end) + end) + + describe('end-to-end workflow', function() + it('handles complete contest setup workflow', function() + local setup_completed = false + local mock_async_setup = { + setup_contest_async = function(contest_id, language) + assert.equals('abc123', contest_id) + assert.is_nil(language) + setup_completed = true + end, + } + package.loaded['cp.async.setup'] = mock_async_setup + + local opts = { fargs = { 'atcoder', 'abc123' } } + cp.handle_command(opts) + + assert.is_true(setup_completed) + end) + + it('handles problem switching within contest', function() + local mock_state = require('cp.state') + mock_state.get_contest_id = function() + return 'abc123' + end + + local problem_setup_called = false + local mock_async_setup = { + setup_problem_async = function(contest_id, problem_id, language) + assert.equals('abc123', contest_id) + assert.equals('b', problem_id) + problem_setup_called = true + end, + } + package.loaded['cp.async.setup'] = mock_async_setup + + local opts = { fargs = { 'b' } } + cp.handle_command(opts) + + assert.is_true(problem_setup_called) + end) + + it('handles language flags correctly', function() + local language_passed = nil + local mock_async_setup = { + setup_contest_async = function(contest_id, language) + language_passed = language + end, + } + package.loaded['cp.async.setup'] = mock_async_setup + + local opts = { fargs = { 'atcoder', 'abc123', '--lang=python' } } + cp.handle_command(opts) + + assert.equals('python', language_passed) + end) + + it('handles scraping failures gracefully', function() + spec_helper.mock_async_scraper_failure() + + local opts = { fargs = { 'atcoder', 'abc123' } } + + assert.has_no_errors(function() + cp.handle_command(opts) + end) + end) + end) + + describe('error handling', function() + it('handles invalid platform gracefully', function() + local opts = { fargs = { 'invalid_platform', 'abc123' } } + + cp.handle_command(opts) + + local error_logged = false + for _, log_entry in ipairs(logged_messages) do + if log_entry.level == vim.log.levels.ERROR then + error_logged = true + break + end + end + assert.is_true(error_logged) + end) + + it('handles platform setup failure', function() + local mock_setup = require('cp.setup') + mock_setup.set_platform = function() + return false + end + + local opts = { fargs = { 'atcoder', 'abc123' } } + + assert.has_no_errors(function() + cp.handle_command(opts) + end) + end) + + it('handles empty contest context for problem switch', function() + local mock_state = require('cp.state') + mock_state.get_contest_id = function() + return nil + end + + local opts = { fargs = { 'a' } } + + cp.handle_command(opts) + + local error_logged = false + for _, log_entry in ipairs(logged_messages) do + if log_entry.level == vim.log.levels.ERROR then + error_logged = true + break + end + end + assert.is_true(error_logged) + end) + end) + + describe('callback behavior', function() + it('maintains execution context in callbacks', function() + local callback_executed = false + + local mock_scraper = { + scrape_contest_metadata_async = function(platform, contest_id, callback) + vim.schedule(function() + callback({ success = true, problems = { { id = 'a' } } }) + callback_executed = true + end) + end, + } + package.loaded['cp.async.scraper'] = mock_scraper + + local opts = { fargs = { 'atcoder', 'abc123' } } + cp.handle_command(opts) + + assert.is_true(callback_executed) + end) + + it('handles multiple rapid commands', function() + local command_count = 0 + local mock_async_setup = { + setup_contest_async = function() + command_count = command_count + 1 + end, + } + package.loaded['cp.async.setup'] = mock_async_setup + + cp.handle_command({ fargs = { 'atcoder', 'abc123' } }) + cp.handle_command({ fargs = { 'atcoder', 'abc124' } }) + cp.handle_command({ fargs = { 'atcoder', 'abc125' } }) + + assert.equals(3, command_count) + end) + end) +end) diff --git a/spec/async_jobs_spec.lua b/spec/async_jobs_spec.lua new file mode 100644 index 0000000..9c16d46 --- /dev/null +++ b/spec/async_jobs_spec.lua @@ -0,0 +1,111 @@ +describe('cp.async.jobs', function() + local jobs + local spec_helper = require('spec.spec_helper') + local mock_jobs = {} + + before_each(function() + spec_helper.setup() + mock_jobs = {} + + vim.system = function(args, opts, callback) + local job = { + kill = function() end, + args = args, + opts = opts, + callback = callback, + } + mock_jobs[#mock_jobs + 1] = job + return job + end + + jobs = spec_helper.fresh_require('cp.async.jobs') + end) + + after_each(function() + spec_helper.teardown() + mock_jobs = {} + end) + + describe('job management', function() + it('starts job with unique ID', function() + local callback = function() end + local args = { 'test', 'command' } + local opts = { cwd = '/test' } + + local job = jobs.start_job('test_job', args, opts, callback) + + assert.is_not_nil(job) + assert.equals(1, #mock_jobs) + assert.same(args, mock_jobs[1].args) + assert.same(opts, mock_jobs[1].opts) + assert.equals(callback, mock_jobs[1].callback) + end) + + it('kills existing job when starting new job with same ID', function() + local killed = false + vim.system = function(args, opts, callback) + return { + kill = function() + killed = true + end, + args = args, + opts = opts, + callback = callback, + } + end + + jobs.start_job('same_id', { 'first' }, {}, function() end) + jobs.start_job('same_id', { 'second' }, {}, function() end) + + assert.is_true(killed) + end) + + it('kills specific job by ID', function() + local killed = false + vim.system = function() + return { + kill = function() + killed = true + end, + } + end + + jobs.start_job('target_job', { 'test' }, {}, function() end) + jobs.kill_job('target_job') + + assert.is_true(killed) + end) + + it('kills all active jobs', function() + local kill_count = 0 + vim.system = function() + return { + kill = function() + kill_count = kill_count + 1 + end, + } + end + + jobs.start_job('job1', { 'test1' }, {}, function() end) + jobs.start_job('job2', { 'test2' }, {}, function() end) + jobs.kill_all_jobs() + + assert.equals(2, kill_count) + end) + + it('tracks active job IDs correctly', function() + jobs.start_job('job1', { 'test1' }, {}, function() end) + jobs.start_job('job2', { 'test2' }, {}, function() end) + + local active_jobs = jobs.get_active_jobs() + assert.equals(2, #active_jobs) + assert.is_true(vim.tbl_contains(active_jobs, 'job1')) + assert.is_true(vim.tbl_contains(active_jobs, 'job2')) + + jobs.kill_job('job1') + active_jobs = jobs.get_active_jobs() + assert.equals(1, #active_jobs) + assert.is_true(vim.tbl_contains(active_jobs, 'job2')) + end) + end) +end) diff --git a/spec/async_scraper_spec.lua b/spec/async_scraper_spec.lua new file mode 100644 index 0000000..e6806ee --- /dev/null +++ b/spec/async_scraper_spec.lua @@ -0,0 +1,185 @@ +describe('cp.async.scraper', function() + local scraper + local spec_helper = require('spec.spec_helper') + local mock_cache, mock_utils + local callback_results = {} + + before_each(function() + spec_helper.setup() + callback_results = {} + + mock_cache = { + load = function() end, + get_contest_data = function() + return nil + end, + set_contest_data = function() end, + set_test_cases = function() end, + } + + mock_utils = { + setup_python_env = function() + return true + end, + get_plugin_path = function() + return '/test/plugin' + end, + } + + vim.system = function(cmd, opts, callback) + local result = { code = 0, stdout = '{}', stderr = '' } + if cmd[1] == 'ping' then + result = { code = 0 } + elseif cmd[1] == 'uv' and vim.tbl_contains(cmd, 'metadata') then + result.stdout = '{"success": true, "problems": [{"id": "a", "name": "Test Problem"}]}' + elseif cmd[1] == 'uv' and vim.tbl_contains(cmd, 'tests') then + result.stdout = + '{"success": true, "tests": [{"input": "1 2", "expected": "3"}], "timeout_ms": 2000, "memory_mb": 256.0, "url": "https://example.com"}' + end + callback(result) + end + + vim.fn.mkdir = function() end + + package.loaded['cp.cache'] = mock_cache + package.loaded['cp.utils'] = mock_utils + scraper = spec_helper.fresh_require('cp.async.scraper') + end) + + after_each(function() + spec_helper.teardown() + end) + + describe('scrape_contest_metadata_async', function() + it('returns cached data immediately if available', function() + mock_cache.get_contest_data = function() + return { problems = { { id = 'cached', name = 'Cached Problem' } } } + end + + scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_true(callback_results[1].success) + assert.equals('Cached Problem', callback_results[1].problems[1].name) + end) + + it('calls callback with success result after scraping', function() + scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_true(callback_results[1].success) + assert.equals(1, #callback_results[1].problems) + assert.equals('Test Problem', callback_results[1].problems[1].name) + end) + + it('calls callback with error on network failure', function() + vim.system = function(cmd, opts, callback) + if cmd[1] == 'ping' then + callback({ code = 1 }) + end + end + + scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_false(callback_results[1].success) + assert.equals('No internet connection available', callback_results[1].error) + end) + + it('calls callback with error on python env failure', function() + mock_utils.setup_python_env = function() + return false + end + + scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_false(callback_results[1].success) + assert.equals('Python environment setup failed', callback_results[1].error) + end) + + it('calls callback with error on subprocess failure', function() + vim.system = function(cmd, opts, callback) + if cmd[1] == 'ping' then + callback({ code = 0 }) + else + callback({ code = 1, stderr = 'execution failed' }) + end + end + + scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_false(callback_results[1].success) + assert.is_not_nil(callback_results[1].error:match('Failed to run metadata scraper')) + end) + + it('calls callback with error on invalid JSON', function() + vim.system = function(cmd, opts, callback) + if cmd[1] == 'ping' then + callback({ code = 0 }) + else + callback({ code = 0, stdout = 'invalid json' }) + end + end + + scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_false(callback_results[1].success) + assert.is_not_nil(callback_results[1].error:match('Failed to parse metadata scraper output')) + end) + end) + + describe('scrape_problem_async', function() + it('calls callback with success after scraping tests', function() + scraper.scrape_problem_async('atcoder', 'abc123', 'a', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_true(callback_results[1].success) + assert.equals('a', callback_results[1].problem_id) + assert.equals(1, callback_results[1].test_count) + end) + + it('handles network failure gracefully', function() + vim.system = function(cmd, opts, callback) + if cmd[1] == 'ping' then + callback({ code = 1 }) + end + end + + scraper.scrape_problem_async('atcoder', 'abc123', 'a', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_false(callback_results[1].success) + assert.equals('a', callback_results[1].problem_id) + assert.equals('No internet connection available', callback_results[1].error) + end) + + it('validates input parameters', function() + assert.has_error(function() + scraper.scrape_contest_metadata_async(nil, 'abc123', function() end) + end) + + assert.has_error(function() + scraper.scrape_problem_async('atcoder', nil, 'a', function() end) + end) + end) + end) +end) diff --git a/spec/async_setup_spec.lua b/spec/async_setup_spec.lua new file mode 100644 index 0000000..1b2d96b --- /dev/null +++ b/spec/async_setup_spec.lua @@ -0,0 +1,286 @@ +describe('cp.async.setup', function() + local setup + local spec_helper = require('spec.spec_helper') + local mock_async, mock_scraper, mock_state + local callback_calls = {} + + before_each(function() + spec_helper.setup() + callback_calls = {} + + mock_async = { + start_contest_operation = function() end, + finish_contest_operation = function() end, + } + + mock_scraper = { + scrape_contest_metadata_async = function(platform, contest_id, callback) + callback({ + success = true, + problems = { + { id = 'a', name = 'Problem A' }, + { id = 'b', name = 'Problem B' }, + }, + }) + end, + scrape_problem_async = function(platform, contest_id, problem_id, callback) + callback({ + success = true, + problem_id = problem_id, + test_cases = { { input = '1', expected = '1' } }, + test_count = 1, + }) + end, + } + + mock_state = { + get_platform = function() + return 'atcoder' + end, + get_contest_id = function() + return 'abc123' + end, + set_contest_id = function() end, + set_problem_id = function() end, + set_test_cases = function() end, + set_run_panel_active = function() end, + } + + local mock_config = { + get_config = function() + return { + scrapers = { 'atcoder', 'codeforces' }, + hooks = nil, + } + end, + } + + local mock_cache = { + load = function() end, + get_test_cases = function() + return nil + end, + set_file_state = function() end, + } + + local mock_problem = { + create_context = function() + return { + source_file = '/test/source.cpp', + problem_name = 'abc123a', + } + end, + } + + vim.cmd = { e = function() end, only = function() end } + vim.api.nvim_get_current_buf = function() + return 1 + end + vim.api.nvim_buf_get_lines = function() + return { '' } + end + vim.fn.expand = function() + return '/test/file.cpp' + end + + package.loaded['cp.async'] = mock_async + package.loaded['cp.async.scraper'] = mock_scraper + package.loaded['cp.state'] = mock_state + package.loaded['cp.config'] = mock_config + package.loaded['cp.cache'] = mock_cache + package.loaded['cp.problem'] = mock_problem + + setup = spec_helper.fresh_require('cp.async.setup') + end) + + after_each(function() + spec_helper.teardown() + end) + + describe('setup_contest_async', function() + it('guards against multiple simultaneous operations', function() + local started = false + mock_async.start_contest_operation = function() + started = true + end + + setup.setup_contest_async('abc123', 'cpp') + + assert.is_true(started) + end) + + it('handles metadata scraping success', function() + local finished = false + mock_async.finish_contest_operation = function() + finished = true + end + + setup.setup_contest_async('abc123', 'cpp') + + assert.is_true(finished) + end) + + it('handles metadata scraping failure gracefully', function() + mock_scraper.scrape_contest_metadata_async = function(platform, contest_id, callback) + callback({ + success = false, + error = 'network error', + }) + end + + local finished = false + mock_async.finish_contest_operation = function() + finished = true + end + + setup.setup_contest_async('abc123', 'cpp') + + assert.is_true(finished) + end) + + it('handles disabled scraping platform', function() + mock_state.get_platform = function() + return 'disabled_platform' + end + + assert.has_no_errors(function() + setup.setup_contest_async('abc123', 'cpp') + end) + end) + end) + + describe('setup_problem_async', function() + it('opens buffer immediately', function() + local buffer_opened = false + vim.cmd.e = function() + buffer_opened = true + end + + setup.setup_problem_async('abc123', 'a', 'cpp') + + assert.is_true(buffer_opened) + end) + + it('uses cached test cases if available', function() + local cached_cases = { { input = 'cached', expected = 'result' } } + local mock_cache = require('cp.cache') + mock_cache.get_test_cases = function() + return cached_cases + end + + local set_test_cases_called = false + mock_state.set_test_cases = function(cases) + assert.same(cached_cases, cases) + set_test_cases_called = true + end + + setup.setup_problem_async('abc123', 'a', 'cpp') + + assert.is_true(set_test_cases_called) + end) + + it('starts background test scraping if not cached', function() + local scraping_started = false + mock_scraper.scrape_problem_async = function(platform, contest_id, problem_id, callback) + scraping_started = true + callback({ success = true, problem_id = problem_id, test_cases = {} }) + end + + setup.setup_problem_async('abc123', 'a', 'cpp') + + assert.is_true(scraping_started) + end) + + it('finishes contest operation on completion', function() + local finished = false + mock_async.finish_contest_operation = function() + finished = true + end + + setup.setup_problem_async('abc123', 'a', 'cpp') + + assert.is_true(finished) + end) + end) + + describe('handle_full_setup_async', function() + it('validates problem exists in contest', function() + mock_scraper.scrape_contest_metadata_async = function(platform, contest_id, callback) + callback({ + success = true, + problems = { { id = 'a' }, { id = 'b' } }, + }) + end + + local cmd = { platform = 'atcoder', contest = 'abc123', problem = 'c' } + + local finished = false + mock_async.finish_contest_operation = function() + finished = true + end + + setup.handle_full_setup_async(cmd) + + assert.is_true(finished) + end) + + it('proceeds with valid problem', function() + mock_scraper.scrape_contest_metadata_async = function(platform, contest_id, callback) + callback({ + success = true, + problems = { { id = 'a' }, { id = 'b' } }, + }) + end + + local cmd = { platform = 'atcoder', contest = 'abc123', problem = 'a' } + + assert.has_no_errors(function() + setup.handle_full_setup_async(cmd) + end) + end) + end) + + describe('background problem scraping', function() + it('scrapes uncached problems in background', function() + local problems = { { id = 'a' }, { id = 'b' }, { id = 'c' } } + local scraping_calls = {} + + mock_scraper.scrape_problem_async = function(platform, contest_id, problem_id, callback) + scraping_calls[#scraping_calls + 1] = problem_id + callback({ success = true, problem_id = problem_id }) + end + + local mock_cache = require('cp.cache') + mock_cache.get_test_cases = function() + return nil + end + + setup.start_background_problem_scraping('abc123', problems, { scrapers = { 'atcoder' } }) + + assert.equals(3, #scraping_calls) + assert.is_true(vim.tbl_contains(scraping_calls, 'a')) + assert.is_true(vim.tbl_contains(scraping_calls, 'b')) + assert.is_true(vim.tbl_contains(scraping_calls, 'c')) + end) + + it('skips already cached problems', function() + local problems = { { id = 'a' }, { id = 'b' } } + local scraping_calls = {} + + mock_scraper.scrape_problem_async = function(platform, contest_id, problem_id, callback) + scraping_calls[#scraping_calls + 1] = problem_id + callback({ success = true, problem_id = problem_id }) + end + + local mock_cache = require('cp.cache') + mock_cache.get_test_cases = function(platform, contest_id, problem_id) + return problem_id == 'a' and { { input = '1', expected = '1' } } or nil + end + + setup.start_background_problem_scraping('abc123', problems, { scrapers = { 'atcoder' } }) + + assert.equals(1, #scraping_calls) + assert.equals('b', scraping_calls[1]) + end) + end) +end) diff --git a/spec/scraper_spec.lua b/spec/scraper_spec.lua deleted file mode 100644 index c81f8e2..0000000 --- a/spec/scraper_spec.lua +++ /dev/null @@ -1,470 +0,0 @@ -describe('cp.scrape', function() - local scrape - local mock_cache - local mock_utils - local mock_system_calls - local temp_files - local spec_helper = require('spec.spec_helper') - - before_each(function() - spec_helper.setup() - temp_files = {} - mock_system_calls = {} - - mock_cache = { - load = function() end, - get_contest_data = function() - return nil - end, - set_contest_data = function() end, - set_test_cases = function() end, - } - - mock_utils = { - setup_python_env = function() - return true - end, - get_plugin_path = function() - return '/test/plugin/path' - end, - } - - vim.system = function(cmd, opts) - table.insert(mock_system_calls, { cmd = cmd, opts = opts }) - - local result = { code = 0, stdout = '{}', stderr = '' } - - if cmd[1] == 'ping' then - result = { code = 0 } - elseif cmd[1] == 'uv' and cmd[2] == 'sync' then - result = { code = 0, stdout = '', stderr = '' } - elseif cmd[1] == 'uv' and cmd[2] == 'run' then - if vim.tbl_contains(cmd, 'metadata') then - result.stdout = '{"success": true, "problems": [{"id": "a", "name": "Test Problem"}]}' - elseif vim.tbl_contains(cmd, 'tests') then - result.stdout = - '{"success": true, "tests": [{"input": "1 2", "expected": "3"}], "url": "https://example.com", "timeout_ms": 2000, "memory_mb": 256.0}' - end - end - - return { - wait = function() - return result - end, - } - end - - package.loaded['cp.cache'] = mock_cache - package.loaded['cp.utils'] = mock_utils - scrape = spec_helper.fresh_require('cp.scrape') - - local original_fn = vim.fn - vim.fn = vim.tbl_extend('force', vim.fn, { - executable = function(cmd) - if cmd == 'uv' then - return 1 - end - return original_fn.executable(cmd) - end, - isdirectory = function(path) - if path:match('%.venv$') then - return 1 - end - return original_fn.isdirectory(path) - end, - filereadable = function(path) - if temp_files[path] then - return 1 - end - return 0 - end, - readfile = function(path) - return temp_files[path] or {} - end, - writefile = function(lines, path) - temp_files[path] = lines - end, - mkdir = function() end, - fnamemodify = function(path, modifier) - if modifier == ':r' then - return path:gsub('%..*$', '') - end - return original_fn.fnamemodify(path, modifier) - end, - }) - end) - - after_each(function() - package.loaded['cp.cache'] = nil - vim.system = vim.system_original or vim.system - spec_helper.teardown() - temp_files = {} - end) - - describe('cache integration', function() - it('returns cached data when available', function() - mock_cache.get_contest_data = function(platform, contest_id) - if platform == 'atcoder' and contest_id == 'abc123' then - return { problems = { { id = 'a', name = 'Cached Problem' } } } - end - return nil - end - - local result = scrape.scrape_contest_metadata('atcoder', 'abc123') - - assert.is_true(result.success) - assert.equals(1, #result.problems) - assert.equals('Cached Problem', result.problems[1].name) - assert.equals(0, #mock_system_calls) - end) - - it('stores scraped data in cache after successful scrape', function() - local stored_data = nil - mock_cache.set_contest_data = function(platform, contest_id, problems) - stored_data = { platform = platform, contest_id = contest_id, problems = problems } - end - - scrape = spec_helper.fresh_require('cp.scrape') - - local result = scrape.scrape_contest_metadata('atcoder', 'abc123') - - assert.is_true(result.success) - assert.is_not_nil(stored_data) - assert.equals('atcoder', stored_data.platform) - assert.equals('abc123', stored_data.contest_id) - assert.equals(1, #stored_data.problems) - end) - end) - - describe('system dependency checks', function() - it('handles missing uv executable', function() - local cache = require('cp.cache') - local utils = require('cp.utils') - - cache.load = function() end - cache.get_contest_data = function() - return nil - end - - vim.fn.executable = function(cmd) - return cmd == 'uv' and 0 or 1 - end - - utils.setup_python_env = function() - return vim.fn.executable('uv') == 1 - end - - vim.system = function(cmd) - if cmd[1] == 'ping' then - return { - wait = function() - return { code = 0 } - end, - } - end - return { - wait = function() - return { code = 0 } - end, - } - end - - local result = scrape.scrape_contest_metadata('atcoder', 'abc123') - - assert.is_false(result.success) - assert.is_not_nil(result.error) - end) - - it('handles python environment setup failure', function() - local cache = require('cp.cache') - - cache.load = function() end - cache.get_contest_data = function() - return nil - end - - mock_utils.setup_python_env = function() - return false - end - - vim.system = function(cmd) - if cmd[1] == 'ping' then - return { - wait = function() - return { code = 0 } - end, - } - end - return { - wait = function() - return { code = 0 } - end, - } - end - - local result = scrape.scrape_contest_metadata('atcoder', 'abc123') - - assert.is_false(result.success) - assert.equals('Python environment setup failed', result.error) - end) - - it('handles network connectivity issues', function() - vim.system = function(cmd) - if cmd[1] == 'ping' then - return { - wait = function() - return { code = 1 } - end, - } - end - return { - wait = function() - return { code = 0 } - end, - } - end - - local result = scrape.scrape_contest_metadata('atcoder', 'abc123') - - assert.is_false(result.success) - assert.equals('No internet connection available', result.error) - end) - end) - - describe('subprocess execution', function() - it('constructs correct command for atcoder metadata', function() - scrape.scrape_contest_metadata('atcoder', 'abc123') - - local metadata_call = nil - for _, call in ipairs(mock_system_calls) do - if vim.tbl_contains(call.cmd, 'metadata') then - metadata_call = call - break - end - end - - assert.is_not_nil(metadata_call) - assert.equals('uv', metadata_call.cmd[1]) - assert.equals('run', metadata_call.cmd[2]) - assert.is_true(vim.tbl_contains(metadata_call.cmd, 'metadata')) - assert.is_true(vim.tbl_contains(metadata_call.cmd, 'abc123')) - end) - - it('constructs correct command for cses metadata', function() - scrape.scrape_contest_metadata('cses', 'sorting_and_searching') - - local metadata_call = nil - for _, call in ipairs(mock_system_calls) do - if vim.tbl_contains(call.cmd, 'metadata') then - metadata_call = call - break - end - end - - assert.is_not_nil(metadata_call) - assert.equals('uv', metadata_call.cmd[1]) - assert.is_true(vim.tbl_contains(metadata_call.cmd, 'metadata')) - assert.is_true(vim.tbl_contains(metadata_call.cmd, 'sorting_and_searching')) - end) - - it('handles subprocess execution failure', function() - vim.system = function(cmd) - if cmd[1] == 'ping' then - return { - wait = function() - return { code = 0 } - end, - } - elseif cmd[1] == 'uv' and vim.tbl_contains(cmd, 'metadata') then - return { - wait = function() - return { code = 1, stderr = 'execution failed' } - end, - } - end - return { - wait = function() - return { code = 0 } - end, - } - end - - local result = scrape.scrape_contest_metadata('atcoder', 'abc123') - - assert.is_false(result.success) - assert.is_not_nil(result.error:match('Failed to run metadata scraper')) - assert.is_not_nil(result.error:match('execution failed')) - end) - end) - - describe('json parsing', function() - it('handles invalid json output', function() - vim.system = function(cmd) - if cmd[1] == 'ping' then - return { - wait = function() - return { code = 0 } - end, - } - elseif cmd[1] == 'uv' and vim.tbl_contains(cmd, 'metadata') then - return { - wait = function() - return { code = 0, stdout = 'invalid json' } - end, - } - end - return { - wait = function() - return { code = 0 } - end, - } - end - - local result = scrape.scrape_contest_metadata('atcoder', 'abc123') - - assert.is_false(result.success) - assert.is_not_nil(result.error:match('Failed to parse metadata scraper output')) - end) - - it('handles scraper-reported failures', function() - vim.system = function(cmd) - if cmd[1] == 'ping' then - return { - wait = function() - return { code = 0 } - end, - } - elseif cmd[1] == 'uv' and vim.tbl_contains(cmd, 'metadata') then - return { - wait = function() - return { - code = 0, - stdout = '{"success": false, "error": "contest not found"}', - } - end, - } - end - return { - wait = function() - return { code = 0 } - end, - } - end - - local result = scrape.scrape_contest_metadata('atcoder', 'abc123') - - assert.is_false(result.success) - assert.equals('contest not found', result.error) - end) - end) - - describe('problem scraping', function() - local test_context - - before_each(function() - test_context = { - contest = 'atcoder', - contest_id = 'abc123', - problem_id = 'a', - problem_name = 'abc123a', - input_file = 'io/abc123a.cpin', - expected_file = 'io/abc123a.expected', - } - end) - - it('uses existing files when available', function() - temp_files['io/abc123a.cpin'] = { '1 2' } - temp_files['io/abc123a.expected'] = { '3' } - temp_files['io/abc123a.1.cpin'] = { '4 5' } - temp_files['io/abc123a.1.cpout'] = { '9' } - - local result = scrape.scrape_problem(test_context) - - assert.is_true(result.success) - assert.equals('abc123a', result.problem_id) - assert.equals(1, result.test_count) - assert.equals(0, #mock_system_calls) - end) - - it('scrapes and writes test case files', function() - local result = scrape.scrape_problem(test_context) - - assert.is_true(result.success) - assert.equals('abc123a', result.problem_id) - assert.equals(1, result.test_count) - assert.is_not_nil(temp_files['io/abc123a.1.cpin']) - assert.is_not_nil(temp_files['io/abc123a.1.cpout']) - assert.equals('1 2', table.concat(temp_files['io/abc123a.1.cpin'], '\n')) - assert.equals('3', table.concat(temp_files['io/abc123a.1.cpout'], '\n')) - end) - - it('constructs correct command for atcoder problem tests', function() - scrape.scrape_problem(test_context) - - local tests_call = nil - for _, call in ipairs(mock_system_calls) do - if vim.tbl_contains(call.cmd, 'tests') then - tests_call = call - break - end - end - - assert.is_not_nil(tests_call) - assert.is_true(vim.tbl_contains(tests_call.cmd, 'tests')) - assert.is_true(vim.tbl_contains(tests_call.cmd, 'abc123')) - assert.is_true(vim.tbl_contains(tests_call.cmd, 'a')) - end) - - it('constructs correct command for cses problem tests', function() - test_context.contest = 'cses' - test_context.contest_id = 'sorting_and_searching' - test_context.problem_id = '1001' - - scrape.scrape_problem(test_context) - - local tests_call = nil - for _, call in ipairs(mock_system_calls) do - if vim.tbl_contains(call.cmd, 'tests') then - tests_call = call - break - end - end - - assert.is_not_nil(tests_call) - assert.is_true(vim.tbl_contains(tests_call.cmd, 'tests')) - assert.is_true(vim.tbl_contains(tests_call.cmd, '1001')) - assert.is_true(vim.tbl_contains(tests_call.cmd, 'sorting_and_searching')) - end) - end) - - describe('error scenarios', function() - it('validates input parameters', function() - assert.has_error(function() - scrape.scrape_contest_metadata(nil, 'abc123') - end) - - assert.has_error(function() - scrape.scrape_contest_metadata('atcoder', nil) - end) - end) - - it('handles file system errors gracefully', function() - vim.fn.mkdir = function() - error('permission denied') - end - - local ctx = { - contest = 'atcoder', - contest_id = 'abc123', - problem_id = 'a', - problem_name = 'abc123a', - input_file = 'io/abc123a.cpin', - expected_file = 'io/abc123a.expected', - } - - assert.has_error(function() - scrape.scrape_problem(ctx) - end) - end) - end) -end) diff --git a/spec/spec_helper.lua b/spec/spec_helper.lua index 6f87157..ecdbb3e 100644 --- a/spec/spec_helper.lua +++ b/spec/spec_helper.lua @@ -103,6 +103,61 @@ function M.mock_scraper_success() } end +function M.mock_async_scraper_success() + package.loaded['cp.async.scraper'] = { + scrape_contest_metadata_async = function(platform, contest_id, callback) + vim.schedule(function() + callback({ + success = true, + problems = { + { id = 'a' }, + { id = 'b' }, + { id = 'c' }, + }, + }) + end) + end, + scrape_problem_async = function(platform, contest_id, problem_id, callback) + vim.schedule(function() + callback({ + success = true, + problem_id = problem_id, + test_cases = { + { input = '1 2', expected = '3' }, + { input = '3 4', expected = '7' }, + }, + test_count = 2, + timeout_ms = 2000, + memory_mb = 256.0, + url = 'https://example.com', + }) + end) + end, + } +end + +function M.mock_async_scraper_failure() + package.loaded['cp.async.scraper'] = { + scrape_contest_metadata_async = function(platform, contest_id, callback) + vim.schedule(function() + callback({ + success = false, + error = 'mock network error', + }) + end) + end, + scrape_problem_async = function(platform, contest_id, problem_id, callback) + vim.schedule(function() + callback({ + success = false, + problem_id = problem_id, + error = 'mock scraping failed', + }) + end) + end, + } +end + function M.has_error_logged() for _, log_entry in ipairs(M.logged_messages) do if log_entry.level == vim.log.levels.ERROR then @@ -135,6 +190,10 @@ end function M.teardown() package.loaded['cp.log'] = nil package.loaded['cp.scrape'] = nil + package.loaded['cp.async.scraper'] = nil + package.loaded['cp.async.jobs'] = nil + package.loaded['cp.async.setup'] = nil + package.loaded['cp.async'] = nil M.logged_messages = {} end