diff --git a/doc/cp.txt b/doc/cp.txt index 6b06dd9..e4fc5a7 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -290,6 +290,10 @@ Usage examples: > for multi-test case problems commonly found in contests. + AtCoder Heuristic Contests (AHC) are excluded + from the contest list as they don't have + standard sample test cases. + Codeforces ~ *cp-codeforces* URL format: https://codeforces.com/contest/1234/problem/A diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index d2ec4f2..90c2c2b 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -40,9 +40,9 @@ local cache_data = {} local loaded = false local CONTEST_LIST_TTL = { - cses = 7 * 24 * 60 * 60, -- 1 week - codeforces = 24 * 60 * 60, -- 1 day - atcoder = 24 * 60 * 60, -- 1 day + cses = 7 * 24 * 60 * 60, + codeforces = 24 * 60 * 60, + atcoder = 24 * 60 * 60, } ---@param contest_data ContestData @@ -89,9 +89,22 @@ function M.load() end function M.save() - vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p') + local ok, _ = pcall(vim.fn.mkdir, vim.fn.fnamemodify(cache_file, ':h'), 'p') + if not ok then + vim.schedule(function() + vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p') + end) + return + end + local encoded = vim.json.encode(cache_data) - vim.fn.writefile(vim.split(encoded, '\n'), cache_file) + local lines = vim.split(encoded, '\n') + local write_ok, _ = pcall(vim.fn.writefile, lines, cache_file) + if not write_ok then + vim.schedule(function() + vim.fn.writefile(lines, cache_file) + end) + end end ---@param platform string @@ -303,7 +316,7 @@ function M.set_contest_list(platform, contests) cache_data.contest_lists = {} end - local ttl = CONTEST_LIST_TTL[platform] or (24 * 60 * 60) -- Default 1 day + local ttl = CONTEST_LIST_TTL[platform] or (24 * 60 * 60) cache_data.contest_lists[platform] = { contests = contests, cached_at = os.time(), diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 0ef9c3a..632411e 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -154,7 +154,7 @@ function M.handle_command(opts) if cmd.type == 'contest_setup' then local setup = require('cp.setup') if setup.set_platform(cmd.platform) then - setup.setup_contest(cmd.contest, cmd.language) + setup.setup_contest(cmd.platform, cmd.contest, nil, cmd.language) end return end @@ -162,7 +162,7 @@ function M.handle_command(opts) if cmd.type == 'full_setup' then local setup = require('cp.setup') if setup.set_platform(cmd.platform) then - setup.handle_full_setup(cmd) + setup.setup_contest(cmd.platform, cmd.contest, cmd.problem, cmd.language) end return end diff --git a/lua/cp/log.lua b/lua/cp/log.lua index 7a26001..6a05316 100644 --- a/lua/cp/log.lua +++ b/lua/cp/log.lua @@ -3,8 +3,16 @@ local M = {} function M.log(msg, level, override) level = level or vim.log.levels.INFO if level >= vim.log.levels.WARN or override then - vim.notify(('[cp.nvim]: %s'):format(msg), level) + vim.schedule(function() + vim.notify(('[cp.nvim]: %s'):format(msg), level) + end) end end +function M.progress(msg) + vim.schedule(function() + vim.notify(('[cp.nvim]: %s'):format(msg), vim.log.levels.INFO) + end) +end + return M diff --git a/lua/cp/pickers/fzf_lua.lua b/lua/cp/pickers/fzf_lua.lua index cf3a47e..2e5095c 100644 --- a/lua/cp/pickers/fzf_lua.lua +++ b/lua/cp/pickers/fzf_lua.lua @@ -1,49 +1,8 @@ local picker_utils = require('cp.pickers') -local function problem_picker(platform, contest_id) - local constants = require('cp.constants') - local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform - local fzf = require('fzf-lua') - local problems = picker_utils.get_problems_for_contest(platform, contest_id) +local contest_picker, problem_picker - if #problems == 0 then - vim.notify( - ('No problems found for contest: %s %s'):format(platform_display_name, contest_id), - vim.log.levels.WARN - ) - return - end - - local entries = vim.tbl_map(function(problem) - return problem.display_name - end, problems) - - return fzf.fzf_exec(entries, { - prompt = ('Select Problem (%s %s)> '):format(platform_display_name, contest_id), - actions = { - ['default'] = function(selected) - if not selected or #selected == 0 then - return - end - - local selected_name = selected[1] - local problem = nil - for _, p in ipairs(problems) do - if p.display_name == selected_name then - problem = p - break - end - end - - if problem then - picker_utils.setup_problem(platform, contest_id, problem.id) - end - end, - }, - }) -end - -local function contest_picker(platform) +function contest_picker(platform) local constants = require('cp.constants') local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform local fzf = require('fzf-lua') @@ -94,6 +53,54 @@ local function contest_picker(platform) }) end +function problem_picker(platform, contest_id) + local constants = require('cp.constants') + local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform + local fzf = require('fzf-lua') + local problems = picker_utils.get_problems_for_contest(platform, contest_id) + + if #problems == 0 then + vim.notify( + ("Contest %s %s hasn't started yet or has no available problems"):format( + platform_display_name, + contest_id + ), + vim.log.levels.WARN + ) + contest_picker(platform) + return + end + + local entries = vim.tbl_map(function(problem) + return problem.display_name + end, problems) + + return fzf.fzf_exec(entries, { + prompt = ('Select Problem (%s %s)> '):format(platform_display_name, contest_id), + actions = { + ['default'] = function(selected) + if not selected or #selected == 0 then + return + end + + local selected_name = selected[1] + local problem = nil + for _, p in ipairs(problems) do + if p.display_name == selected_name then + problem = p + break + end + end + + if problem then + local cp = require('cp') + cp.handle_command({ fargs = { platform, contest_id, problem.id } }) + end + end, + }, + }) +end + local function platform_picker() local fzf = require('fzf-lua') local platforms = picker_utils.get_platforms() diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index b981b59..f8cac85 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -2,7 +2,6 @@ local M = {} local cache = require('cp.cache') local logger = require('cp.log') -local scrape = require('cp.scrape') local utils = require('cp.utils') ---@class cp.PlatformItem @@ -35,25 +34,19 @@ end ---@param platform string Platform identifier (e.g. "codeforces", "atcoder") ---@return cp.ContestItem[] local function get_contests_for_platform(platform) - local contests = {} - cache.load() local cached_contests = cache.get_contest_list(platform) if cached_contests then return cached_contests end - if not utils.setup_python_env() then - return contests - end - local constants = require('cp.constants') local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform - logger.log( - ('Scraping %s for contests, this may take a few seconds...'):format(platform_display_name), - vim.log.levels.INFO, - true - ) + logger.progress(('loading %s contests...'):format(platform_display_name)) + + if not utils.setup_python_env() then + return {} + end local plugin_path = utils.get_plugin_path() local cmd = { @@ -65,6 +58,9 @@ local function get_contests_for_platform(platform) 'scrapers.' .. platform, 'contests', } + + logger.progress(('running: %s'):format(table.concat(cmd, ' '))) + local result = vim .system(cmd, { cwd = plugin_path, @@ -73,20 +69,35 @@ local function get_contests_for_platform(platform) }) :wait() + logger.progress(('exit code: %d, stdout length: %d'):format(result.code, #(result.stdout or ''))) + if result.stderr and #result.stderr > 0 then + logger.progress(('stderr: %s'):format(result.stderr:sub(1, 200))) + end + if result.code ~= 0 then logger.log( - ('Failed to get contests for %s: %s'):format(platform, result.stderr or 'unknown error'), + ('Failed to load contests: %s'):format(result.stderr or 'unknown error'), vim.log.levels.ERROR ) - return contests + return {} end + logger.progress(('stdout preview: %s'):format(result.stdout:sub(1, 100))) + local ok, data = pcall(vim.json.decode, result.stdout) - if not ok or not data.success then - logger.log(('Failed to parse contest data for %s'):format(platform), vim.log.levels.ERROR) - return contests + if not ok then + logger.log(('JSON parse error: %s'):format(tostring(data)), vim.log.levels.ERROR) + return {} + end + if not data.success then + logger.log( + ('Scraper returned success=false: %s'):format(data.error or 'no error message'), + vim.log.levels.ERROR + ) + return {} end + local contests = {} for _, contest in ipairs(data.contests or {}) do table.insert(contests, { id = contest.id, @@ -96,10 +107,10 @@ local function get_contests_for_platform(platform) end cache.set_contest_list(platform, contests) + logger.progress(('loaded %d contests'):format(#contests)) return contests end ----Get list of problems for a specific contest ---@param platform string Platform identifier ---@param contest_id string Contest identifier ---@return cp.ProblemItem[] @@ -119,31 +130,60 @@ local function get_problems_for_contest(platform, contest_id) return problems end + if not utils.setup_python_env() then + return problems + end + local constants = require('cp.constants') local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform - logger.log( - ('Scraping %s %s for problems, this may take a few seconds...'):format( - platform_display_name, - contest_id - ), - vim.log.levels.INFO, - true - ) + logger.progress(('loading %s %s problems...'):format(platform_display_name, contest_id)) - local metadata_result = scrape.scrape_contest_metadata(platform, contest_id) - if not metadata_result.success then + local plugin_path = utils.get_plugin_path() + local cmd = { + 'uv', + 'run', + '--directory', + plugin_path, + '-m', + 'scrapers.' .. platform, + 'metadata', + contest_id, + } + + local result = vim + .system(cmd, { + cwd = plugin_path, + text = true, + timeout = 30000, + }) + :wait() + + if result.code ~= 0 then logger.log( - ('Failed to get problems for %s %s: %s'):format( - platform, - contest_id, - metadata_result.error or 'unknown error' - ), + ('Failed to scrape contest: %s'):format(result.stderr or 'unknown error'), vim.log.levels.ERROR ) return problems end - for _, problem in ipairs(metadata_result.problems or {}) do + local ok, data = pcall(vim.json.decode, result.stdout) + if not ok then + logger.log('Failed to parse contest data', vim.log.levels.ERROR) + return problems + end + if not data.success then + logger.log(data.error or 'Contest scraping failed', vim.log.levels.ERROR) + return problems + end + + if not data.problems or #data.problems == 0 then + logger.log('Contest has no problems available', vim.log.levels.WARN) + return problems + end + + cache.set_contest_data(platform, contest_id, data.problems) + + for _, problem in ipairs(data.problems) do table.insert(problems, { id = problem.id, name = problem.name, @@ -154,7 +194,6 @@ local function get_problems_for_contest(platform, contest_id) return problems end ----Set up a specific problem by calling the main CP handler ---@param platform string Platform identifier ---@param contest_id string Contest identifier ---@param problem_id string Problem identifier diff --git a/lua/cp/pickers/telescope.lua b/lua/cp/pickers/telescope.lua index 6f65c93..4c3188e 100644 --- a/lua/cp/pickers/telescope.lua +++ b/lua/cp/pickers/telescope.lua @@ -6,49 +6,9 @@ local actions = require('telescope.actions') local picker_utils = require('cp.pickers') -local function problem_picker(opts, platform, contest_id) - local constants = require('cp.constants') - local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform - local problems = picker_utils.get_problems_for_contest(platform, contest_id) +local contest_picker, problem_picker - if #problems == 0 then - vim.notify( - ('No problems found for contest: %s %s'):format(platform_display_name, contest_id), - vim.log.levels.WARN - ) - return - end - - pickers - .new(opts, { - prompt_title = ('Select Problem (%s %s)'):format(platform_display_name, contest_id), - finder = finders.new_table({ - results = problems, - entry_maker = function(entry) - return { - value = entry, - display = entry.display_name, - ordinal = entry.display_name, - } - end, - }), - sorter = conf.generic_sorter(opts), - attach_mappings = function(prompt_bufnr) - actions.select_default:replace(function() - local selection = action_state.get_selected_entry() - actions.close(prompt_bufnr) - - if selection then - picker_utils.setup_problem(platform, contest_id, selection.value.id) - end - end) - return true - end, - }) - :find() -end - -local function contest_picker(opts, platform) +function contest_picker(opts, platform) local constants = require('cp.constants') local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform local contests = picker_utils.get_contests_for_platform(platform) @@ -99,6 +59,53 @@ local function contest_picker(opts, platform) :find() end +function problem_picker(opts, platform, contest_id) + local constants = require('cp.constants') + local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform + local problems = picker_utils.get_problems_for_contest(platform, contest_id) + + if #problems == 0 then + vim.notify( + ("Contest %s %s hasn't started yet or has no available problems"):format( + platform_display_name, + contest_id + ), + vim.log.levels.WARN + ) + contest_picker(opts, platform) + return + end + + pickers + .new(opts, { + prompt_title = ('Select Problem (%s %s)'):format(platform_display_name, contest_id), + finder = finders.new_table({ + results = problems, + entry_maker = function(entry) + return { + value = entry, + display = entry.display_name, + ordinal = entry.display_name, + } + end, + }), + sorter = conf.generic_sorter(opts), + attach_mappings = function(prompt_bufnr) + actions.select_default:replace(function() + local selection = action_state.get_selected_entry() + actions.close(prompt_bufnr) + + if selection then + local cp = require('cp') + cp.handle_command({ fargs = { platform, contest_id, selection.value.id } }) + end + end) + return true + end, + }) + :find() +end + local function platform_picker(opts) opts = opts or {} diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index abe13e3..bff8a0f 100644 --- a/lua/cp/runner/run.lua +++ b/lua/cp/runner/run.lua @@ -84,17 +84,12 @@ local function parse_test_cases_from_cache(platform, contest_id, problem_id) end ---@param input_file string ----@param expected_file string ---@return TestCase[] -local function parse_test_cases_from_files(input_file, expected_file) - if vim.fn.filereadable(input_file) == 0 or vim.fn.filereadable(expected_file) == 0 then - return {} - end - +local function parse_test_cases_from_files(input_file, _) local base_name = vim.fn.fnamemodify(input_file, ':r') local test_cases = {} - local i = 1 + local i = 1 while true do local individual_input_file = base_name .. '.' .. i .. '.cpin' local individual_expected_file = base_name .. '.' .. i .. '.cpout' @@ -113,12 +108,6 @@ local function parse_test_cases_from_files(input_file, expected_file) end end - if #test_cases == 0 then - local input_content = table.concat(vim.fn.readfile(input_file), '\n') - local expected_content = table.concat(vim.fn.readfile(expected_file), '\n') - return { create_test_case(1, input_content, expected_content) } - end - return test_cases end diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua deleted file mode 100644 index f7a48c8..0000000 --- a/lua/cp/scrape.lua +++ /dev/null @@ -1,359 +0,0 @@ ----@class ScraperTestCase ----@field input string ----@field expected string - ----@class ScraperResult ----@field success boolean ----@field problem_id string ----@field url? string ----@field tests? ScraperTestCase[] ----@field timeout_ms? number ----@field memory_mb? number ----@field error? string - -local M = {} -local cache = require('cp.cache') -local problem = require('cp.problem') -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 - ----@param platform string ----@param contest_id string ----@return {success: boolean, problems?: table[], error?: string} -function M.scrape_contest_metadata(platform, contest_id) - vim.validate({ - platform = { platform, 'string' }, - contest_id = { contest_id, 'string' }, - }) - - cache.load() - - local cached_data = cache.get_contest_data(platform, contest_id) - if cached_data then - return { - success = true, - problems = cached_data.problems, - } - end - - if not check_internet_connectivity() then - return { - success = false, - error = 'No internet connection available', - } - end - - if not utils.setup_python_env() then - return { - success = false, - error = 'Python environment setup failed', - } - end - - local plugin_path = utils.get_plugin_path() - - local args = { - 'uv', - 'run', - '--directory', - plugin_path, - '-m', - 'scrapers.' .. platform, - 'metadata', - contest_id, - } - - local result = vim - .system(args, { - cwd = plugin_path, - text = true, - timeout = 30000, - }) - :wait() - - if result.code ~= 0 then - return { - success = false, - error = 'Failed to run metadata scraper: ' .. (result.stderr or 'Unknown error'), - } - end - - local ok, data = pcall(vim.json.decode, result.stdout) - if not ok then - return { - success = false, - error = 'Failed to parse metadata scraper output: ' .. tostring(data), - } - end - - if not data.success then - return data - end - - local problems_list = data.problems or {} - - cache.set_contest_data(platform, contest_id, problems_list) - return { - success = true, - problems = problems_list, - } -end - ----@param ctx ProblemContext ----@return {success: boolean, problem_id: string, test_count?: number, test_cases?: ScraperTestCase[], timeout_ms?: number, memory_mb?: number, url?: string, error?: string} -function M.scrape_problem(ctx) - vim.validate({ - ctx = { ctx, 'table' }, - }) - - vim.fn.mkdir('io', 'p') - - if vim.fn.filereadable(ctx.input_file) == 1 and vim.fn.filereadable(ctx.expected_file) == 1 then - local base_name = vim.fn.fnamemodify(ctx.input_file, ':r') - local test_cases = {} - local i = 1 - - while true do - local input_file = base_name .. '.' .. i .. '.cpin' - local expected_file = base_name .. '.' .. i .. '.cpout' - - if vim.fn.filereadable(input_file) == 1 and vim.fn.filereadable(expected_file) == 1 then - local input_content = table.concat(vim.fn.readfile(input_file), '\n') - local expected_content = table.concat(vim.fn.readfile(expected_file), '\n') - - table.insert(test_cases, { - index = i, - input = input_content, - output = expected_content, - }) - i = i + 1 - else - break - end - end - - return { - success = true, - problem_id = ctx.problem_name, - test_count = #test_cases, - test_cases = test_cases, - } - end - - if not check_internet_connectivity() then - return { - success = false, - problem_id = ctx.problem_name, - error = 'No internet connection available', - } - end - - if not utils.setup_python_env() then - return { - success = false, - problem_id = ctx.problem_name, - error = 'Python environment setup failed', - } - end - - local plugin_path = utils.get_plugin_path() - - local args = { - 'uv', - 'run', - '--directory', - plugin_path, - '-m', - 'scrapers.' .. ctx.contest, - 'tests', - ctx.contest_id, - ctx.problem_id, - } - - local result = vim - .system(args, { - cwd = plugin_path, - text = true, - timeout = 30000, - }) - :wait() - - if result.code ~= 0 then - return { - success = false, - problem_id = ctx.problem_name, - error = 'Failed to run tests scraper: ' .. (result.stderr or 'Unknown error'), - } - end - - local ok, data = pcall(vim.json.decode, result.stdout) - if not ok then - return { - success = false, - problem_id = ctx.problem_name, - error = 'Failed to parse tests scraper output: ' .. tostring(data), - } - end - - if not data.success then - return data - end - - if data.tests and #data.tests > 0 then - local base_name = vim.fn.fnamemodify(ctx.input_file, ':r') - - for i, test_case in ipairs(data.tests) do - local input_file = base_name .. '.' .. i .. '.cpin' - local expected_file = base_name .. '.' .. i .. '.cpout' - - local input_content = test_case.input:gsub('\r', '') - local expected_content = test_case.expected:gsub('\r', '') - - vim.fn.writefile(vim.split(input_content, '\n', true), input_file) - vim.fn.writefile(vim.split(expected_content, '\n', true), expected_file) - end - - 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( - ctx.contest, - ctx.contest_id, - ctx.problem_id, - cached_test_cases, - data.timeout_ms, - data.memory_mb - ) - end - - return { - success = true, - problem_id = ctx.problem_name, - 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 - ----@param platform string ----@param contest_id string ----@param problems table[] ----@param config table ----@return table[] -function M.scrape_problems_parallel(platform, contest_id, problems, config) - vim.validate({ - platform = { platform, 'string' }, - contest_id = { contest_id, 'string' }, - problems = { problems, 'table' }, - config = { config, 'table' }, - }) - - if not check_internet_connectivity() then - return {} - end - - if not utils.setup_python_env() then - return {} - end - - local plugin_path = utils.get_plugin_path() - local jobs = {} - - for _, prob in ipairs(problems) do - local args = { - 'uv', - 'run', - '--directory', - plugin_path, - '-m', - 'scrapers.' .. platform, - 'tests', - contest_id, - prob.id, - } - - local job = vim.system(args, { - cwd = plugin_path, - text = true, - timeout = 30000, - }) - - jobs[prob.id] = { - job = job, - problem = prob, - } - end - - local results = {} - for problem_id, job_data in pairs(jobs) do - local result = job_data.job:wait() - local scrape_result = { - success = false, - problem_id = problem_id, - error = 'Unknown error', - } - - if result.code == 0 then - local ok, data = pcall(vim.json.decode, result.stdout) - if ok and data.success then - scrape_result = data - - if data.tests and #data.tests > 0 then - local ctx = problem.create_context(platform, contest_id, problem_id, config) - local base_name = vim.fn.fnamemodify(ctx.input_file, ':r') - - for i, test_case in ipairs(data.tests) do - local input_file = base_name .. '.' .. i .. '.cpin' - local expected_file = base_name .. '.' .. i .. '.cpout' - - local input_content = test_case.input:gsub('\r', '') - local expected_content = test_case.expected:gsub('\r', '') - - vim.fn.writefile(vim.split(input_content, '\n', true), input_file) - vim.fn.writefile(vim.split(expected_content, '\n', true), expected_file) - end - - 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 - else - scrape_result.error = ok and data.error or 'Failed to parse scraper output' - end - else - scrape_result.error = 'Scraper execution failed: ' .. (result.stderr or 'Unknown error') - end - - results[problem_id] = scrape_result - end - - return results -end - -return M diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua new file mode 100644 index 0000000..4f70930 --- /dev/null +++ b/lua/cp/scraper.lua @@ -0,0 +1,131 @@ +local M = {} +local cache = require('cp.cache') +local utils = require('cp.utils') + +local function run_scraper(platform, subcommand, args, callback) + 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 cmd = { + 'uv', + 'run', + '--directory', + plugin_path, + '-m', + 'scrapers.' .. platform, + subcommand, + } + + for _, arg in ipairs(args or {}) do + table.insert(cmd, arg) + end + + vim.system(cmd, { + cwd = plugin_path, + text = true, + timeout = 30000, + }, function(result) + if result.code ~= 0 then + callback({ + success = false, + error = 'Scraper failed: ' .. (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 scraper output: ' .. tostring(data), + }) + return + end + + callback(data) + end) +end + +function M.scrape_contest_metadata(platform, contest_id, callback) + cache.load() + + local cached = cache.get_contest_data(platform, contest_id) + if cached then + callback({ success = true, problems = cached.problems }) + return + end + + run_scraper(platform, 'metadata', { contest_id }, function(result) + if result.success and result.problems then + cache.set_contest_data(platform, contest_id, result.problems) + end + callback(result) + end) +end + +function M.scrape_contest_list(platform, callback) + cache.load() + + local cached = cache.get_contest_list(platform) + if cached then + callback({ success = true, contests = cached }) + return + end + + run_scraper(platform, 'contests', {}, function(result) + if result.success and result.contests then + cache.set_contest_list(platform, result.contests) + end + callback(result) + end) +end + +function M.scrape_problem_tests(platform, contest_id, problem_id, callback) + run_scraper(platform, 'tests', { contest_id, problem_id }, function(result) + if result.success and result.tests then + vim.schedule(function() + local mkdir_ok = pcall(vim.fn.mkdir, 'io', 'p') + if mkdir_ok then + local config = require('cp.config') + local base_name = config.default_filename(contest_id, problem_id) + + for i, test_case in ipairs(result.tests) do + local input_file = 'io/' .. base_name .. '.' .. i .. '.cpin' + local expected_file = 'io/' .. base_name .. '.' .. i .. '.cpout' + + local input_content = test_case.input:gsub('\r', '') + local expected_content = test_case.expected:gsub('\r', '') + + pcall(vim.fn.writefile, vim.split(input_content, '\n', true), input_file) + pcall(vim.fn.writefile, vim.split(expected_content, '\n', true), expected_file) + end + end + end) + + local cached_tests = {} + for i, test_case in ipairs(result.tests) do + table.insert(cached_tests, { + index = i, + input = test_case.input, + expected = test_case.expected, + }) + end + + cache.set_test_cases( + platform, + contest_id, + problem_id, + cached_tests, + result.timeout_ms, + result.memory_mb + ) + end + + callback(result) + end) +end + +return M diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua new file mode 100644 index 0000000..4992543 --- /dev/null +++ b/lua/cp/setup.lua @@ -0,0 +1,251 @@ +local M = {} + +local cache = require('cp.cache') +local config_module = require('cp.config') +local logger = require('cp.log') +local problem = require('cp.problem') +local scraper = require('cp.scraper') +local state = require('cp.state') + +local constants = require('cp.constants') +local platforms = constants.PLATFORMS + +function M.set_platform(platform) + if not vim.tbl_contains(platforms, platform) then + logger.log( + ('unknown platform: %s. supported: %s'):format(platform, table.concat(platforms, ', ')), + vim.log.levels.ERROR + ) + return false + end + + if state.get_platform() == platform then + logger.log(('platform already set to %s'):format(platform)) + else + state.set_platform(platform) + logger.log(('platform set to %s'):format(platform)) + end + + return true +end + +function M.setup_contest(platform, contest_id, problem_id, language) + if not state.get_platform() then + logger.log('no platform set', vim.log.levels.ERROR) + return + end + + local config = config_module.get_config() + + if not vim.tbl_contains(config.scrapers, platform) then + logger.log('scraping disabled for ' .. platform, vim.log.levels.WARN) + return + end + + logger.progress(('fetching contest %s %s...'):format(platform, contest_id)) + + scraper.scrape_contest_metadata(platform, contest_id, function(result) + if not result.success then + logger.log( + 'failed to load contest metadata: ' .. (result.error or 'unknown error'), + vim.log.levels.ERROR + ) + return + end + + local problems = result.problems + if not problems or #problems == 0 then + logger.log('no problems found in contest', vim.log.levels.ERROR) + return + end + + logger.progress(('found %d problems'):format(#problems)) + + state.set_contest_id(contest_id) + local target_problem = problem_id or problems[1].id + + if problem_id then + local problem_exists = false + for _, prob in ipairs(problems) do + if prob.id == problem_id then + problem_exists = true + break + end + end + if not problem_exists then + logger.log( + ('invalid problem %s for contest %s'):format(problem_id, contest_id), + vim.log.levels.ERROR + ) + return + end + end + + M.setup_problem(contest_id, target_problem, language) + + M.scrape_remaining_problems(platform, contest_id, problems) + end) +end + +function M.setup_problem(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 platform = state.get_platform() or '' + + logger.progress(('setting up problem %s%s...'):format(contest_id, problem_id or '')) + + local ctx = problem.create_context(platform, contest_id, problem_id, config, language) + + local cached_tests = cache.get_test_cases(platform, contest_id, problem_id) + if cached_tests then + state.set_test_cases(cached_tests) + logger.log(('using cached test cases (%d)'):format(#cached_tests)) + elseif vim.tbl_contains(config.scrapers, platform) then + logger.progress('loading test cases...') + + scraper.scrape_problem_tests(platform, contest_id, problem_id, function(result) + if result.success then + logger.log(('loaded %d test cases for %s'):format(#(result.tests or {}), problem_id)) + if state.get_problem_id() == problem_id then + state.set_test_cases(result.tests) + end + else + logger.log( + 'failed to load tests: ' .. (result.error or 'unknown error'), + vim.log.levels.ERROR + ) + if state.get_problem_id() == problem_id then + state.set_test_cases({}) + end + end + end) + else + logger.log(('scraping disabled for %s'):format(platform)) + state.set_test_cases({}) + end + + state.set_contest_id(contest_id) + state.set_problem_id(problem_id) + state.set_run_panel_active(false) + + vim.schedule(function() + local ok, err = pcall(function() + vim.cmd.only({ mods = { silent = true } }) + + 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 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(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(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'), platform, contest_id, problem_id, language) + + logger.progress(('ready - problem %s'):format(ctx.problem_name)) + end) + + if not ok then + logger.log(('setup error: %s'):format(err), vim.log.levels.ERROR) + end + end) +end + +function M.scrape_remaining_problems(platform, contest_id, problems) + cache.load() + 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.progress(('caching %d remaining problems...'):format(#missing_problems)) + + for _, prob in ipairs(missing_problems) do + scraper.scrape_problem_tests(platform, contest_id, prob.id, function(result) + if result.success then + logger.log(('background: scraped problem %s'):format(prob.id)) + end + end) + end +end + +function M.navigate_problem(direction, language) + local platform = state.get_platform() + local contest_id = state.get_contest_id() + local current_problem_id = state.get_problem_id() + + if not platform or not contest_id or not current_problem_id then + logger.log('no contest context', vim.log.levels.ERROR) + return + end + + cache.load() + local contest_data = cache.get_contest_data(platform, contest_id) + if not contest_data or not contest_data.problems then + logger.log('no contest data available', vim.log.levels.ERROR) + return + end + + local problems = contest_data.problems + local current_index = nil + for i, prob in ipairs(problems) do + if prob.id == current_problem_id then + current_index = i + break + end + end + + if not current_index then + logger.log('current problem not found in contest', vim.log.levels.ERROR) + return + end + + local new_index = current_index + direction + if new_index < 1 or new_index > #problems then + logger.log('no more problems in that direction', vim.log.levels.WARN) + return + end + + local new_problem = problems[new_index] + M.setup_problem(contest_id, new_problem.id, language) +end + +return M diff --git a/lua/cp/setup/contest.lua b/lua/cp/setup/contest.lua deleted file mode 100644 index 7649330..0000000 --- a/lua/cp/setup/contest.lua +++ /dev/null @@ -1,43 +0,0 @@ -local M = {} - -local logger = require('cp.log') -local scrape = require('cp.scrape') -local state = require('cp.state') - -function M.scrape_missing_problems(contest_id, missing_problems, config) - vim.fn.mkdir('io', 'p') - - logger.log(('scraping %d uncached problems...'):format(#missing_problems)) - - local results = scrape.scrape_problems_parallel( - state.get_platform() or '', - contest_id, - missing_problems, - config - ) - - local success_count = 0 - local failed_problems = {} - for problem_id, result in pairs(results) do - if result.success then - success_count = success_count + 1 - else - table.insert(failed_problems, problem_id) - end - end - - if #failed_problems > 0 then - logger.log( - ('scraping complete: %d/%d successful, failed: %s'):format( - success_count, - #missing_problems, - table.concat(failed_problems, ', ') - ), - vim.log.levels.WARN - ) - else - logger.log(('scraping complete: %d/%d successful'):format(success_count, #missing_problems)) - end -end - -return M diff --git a/lua/cp/setup/init.lua b/lua/cp/setup/init.lua deleted file mode 100644 index f654e5c..0000000 --- a/lua/cp/setup/init.lua +++ /dev/null @@ -1,260 +0,0 @@ -local M = {} - -local cache = require('cp.cache') -local config_module = require('cp.config') -local logger = require('cp.log') -local problem = require('cp.problem') -local scrape = require('cp.scrape') -local state = require('cp.state') - -local constants = require('cp.constants') -local platforms = constants.PLATFORMS - -function M.set_platform(platform) - if not vim.tbl_contains(platforms, platform) then - logger.log( - ('unknown platform. Available: [%s]'):format(table.concat(platforms, ', ')), - vim.log.levels.ERROR - ) - return false - end - - state.set_platform(platform) - vim.system({ 'mkdir', '-p', 'build', 'io' }):wait() - return true -end - -function M.setup_problem(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) - - if vim.tbl_contains(config.scrapers, state.get_platform() or '') then - cache.load() - local existing_contest_data = cache.get_contest_data(state.get_platform() or '', contest_id) - - if not existing_contest_data then - local metadata_result = scrape.scrape_contest_metadata(state.get_platform() or '', contest_id) - if not metadata_result.success then - logger.log( - 'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'), - vim.log.levels.WARN - ) - end - end - end - - 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 - local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[state.get_platform() or ''] - or (state.get_platform() or '') - logger.log( - ('Scraping %s %s %s for test cases, this may take a few seconds...'):format( - platform_display_name, - contest_id, - problem_id - ), - vim.log.levels.INFO, - true - ) - - local scrape_result = scrape.scrape_problem(ctx) - - if not scrape_result.success then - logger.log( - 'scraping failed: ' .. (scrape_result.error or 'unknown error'), - vim.log.levels.ERROR - ) - return - end - - 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) - - if scrape_result.test_cases then - cache.set_test_cases( - state.get_platform() or '', - contest_id, - problem_id, - scrape_result.test_cases - ) - 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 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)) -end - -function M.setup_contest(contest_id, language) - if not state.get_platform() then - logger.log('no platform set', vim.log.levels.ERROR) - return false - end - - local config = config_module.get_config() - - if not vim.tbl_contains(config.scrapers, state.get_platform() or '') then - logger.log('scraping disabled for ' .. (state.get_platform() or ''), vim.log.levels.WARN) - return false - end - - logger.log(('setting up contest %s %s'):format(state.get_platform() or '', contest_id)) - - local metadata_result = scrape.scrape_contest_metadata(state.get_platform() or '', contest_id) - if not metadata_result.success then - logger.log( - 'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'), - vim.log.levels.ERROR - ) - return false - end - - local problems = metadata_result.problems - if not problems or #problems == 0 then - logger.log('no problems found in contest', vim.log.levels.ERROR) - return false - end - - logger.log(('found %d problems, checking cache...'):format(#problems)) - - cache.load() - local missing_problems = {} - for _, prob in ipairs(problems) do - local cached_tests = cache.get_test_cases(state.get_platform() or '', contest_id, prob.id) - if not cached_tests then - table.insert(missing_problems, prob) - end - end - - if #missing_problems > 0 then - local contest_scraper = require('cp.setup.contest') - contest_scraper.scrape_missing_problems(contest_id, missing_problems, config) - else - logger.log('all problems already cached') - end - - state.set_contest_id(contest_id) - M.setup_problem(contest_id, problems[1].id, language) - - return true -end - -function M.navigate_problem(delta, language) - if not state.get_platform() or not state.get_contest_id() then - logger.log('no contest set. run :CP first', vim.log.levels.ERROR) - return - end - - local navigation = require('cp.setup.navigation') - navigation.navigate_problem(delta, language) -end - -function M.handle_full_setup(cmd) - state.set_contest_id(cmd.contest) - local problem_ids = {} - local has_metadata = false - local config = config_module.get_config() - - if vim.tbl_contains(config.scrapers, cmd.platform) then - local metadata_result = scrape.scrape_contest_metadata(cmd.platform, cmd.contest) - if not metadata_result.success then - logger.log( - 'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'), - vim.log.levels.ERROR - ) - return - end - - logger.log( - ('loaded %d problems for %s %s'):format(#metadata_result.problems, cmd.platform, cmd.contest), - vim.log.levels.INFO, - true - ) - problem_ids = vim.tbl_map(function(prob) - return prob.id - end, metadata_result.problems) - has_metadata = true - else - cache.load() - local contest_data = cache.get_contest_data(cmd.platform or '', cmd.contest) - if contest_data and contest_data.problems then - problem_ids = vim.tbl_map(function(prob) - return prob.id - end, contest_data.problems) - has_metadata = true - end - end - - if has_metadata and 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 - ) - return - end - - M.setup_problem(cmd.contest, cmd.problem, cmd.language) -end - -return M diff --git a/lua/cp/setup/navigation.lua b/lua/cp/setup/navigation.lua deleted file mode 100644 index bab857b..0000000 --- a/lua/cp/setup/navigation.lua +++ /dev/null @@ -1,64 +0,0 @@ -local M = {} - -local cache = require('cp.cache') -local logger = require('cp.log') -local state = require('cp.state') - -local function get_current_problem() - local filename = vim.fn.expand('%:t:r') - if filename == '' then - logger.log('no file open', vim.log.levels.ERROR) - return nil - end - return filename -end - -function M.navigate_problem(delta, language) - cache.load() - local contest_data = - cache.get_contest_data(state.get_platform() or '', state.get_contest_id() or '') - if not contest_data or not contest_data.problems then - logger.log( - 'no contest metadata found. set up a problem first to cache contest data', - vim.log.levels.ERROR - ) - return - end - - local problems = contest_data.problems - local current_problem_id = state.get_problem_id() - - if not current_problem_id then - logger.log('no current problem set', vim.log.levels.ERROR) - return - end - - local current_index = nil - for i, prob in ipairs(problems) do - if prob.id == current_problem_id then - current_index = i - break - end - end - - if not current_index then - logger.log('current problem not found in contest', vim.log.levels.ERROR) - return - end - - local new_index = current_index + delta - - if new_index < 1 or new_index > #problems then - local msg = delta > 0 and 'at last problem' or 'at first problem' - logger.log(msg, vim.log.levels.WARN) - return - end - - local new_problem = problems[new_index] - local setup = require('cp.setup') - setup.setup_problem(state.get_contest_id() or '', new_problem.id, language) -end - -M.get_current_problem = get_current_problem - -return M diff --git a/lua/cp/ui/panel.lua b/lua/cp/ui/panel.lua index 5f3345d..1ca5551 100644 --- a/lua/cp/ui/panel.lua +++ b/lua/cp/ui/panel.lua @@ -11,12 +11,11 @@ local current_diff_layout = nil local current_mode = nil local function get_current_problem() - local setup_nav = require('cp.setup.navigation') - return setup_nav.get_current_problem() + return state.get_problem_id() end function M.toggle_run_panel(is_debug) - if state.run_panel_active then + if state.is_run_panel_active() then if current_diff_layout then current_diff_layout.cleanup() current_diff_layout = nil @@ -46,15 +45,23 @@ function M.toggle_run_panel(is_debug) return end - local config = config_module.get_config() - local ctx = problem.create_context( - state.get_platform() or '', - state.get_contest_id() or '', - state.get_problem_id(), - config + local platform = state.get_platform() + local contest_id = state.get_contest_id() + + logger.log( + ('run panel: platform=%s, contest=%s, problem=%s'):format( + platform or 'nil', + contest_id or 'nil', + problem_id or 'nil' + ) ) + + local config = config_module.get_config() + local ctx = problem.create_context(platform or '', contest_id or '', problem_id, config) local run = require('cp.runner.run') + logger.log(('run panel: checking test cases for %s'):format(ctx.input_file)) + if not run.load_test_cases(ctx, state) then logger.log('no test cases found', vim.log.levels.WARN) return @@ -193,7 +200,7 @@ function M.toggle_run_panel(is_debug) vim.api.nvim_set_current_win(test_windows.tab_win) - state.run_panel_active = true + state.set_run_panel_active(true) state.test_buffers = test_buffers state.test_windows = test_windows local test_state = run.get_run_panel_state() diff --git a/scrapers/__init__.py b/scrapers/__init__.py index f0cfd45..6140dce 100644 --- a/scrapers/__init__.py +++ b/scrapers/__init__.py @@ -1,15 +1,45 @@ -from .atcoder import AtCoderScraper -from .base import BaseScraper, ScraperConfig -from .codeforces import CodeforcesScraper -from .cses import CSESScraper -from .models import ( - ContestListResult, - ContestSummary, - MetadataResult, - ProblemSummary, - TestCase, - TestsResult, -) +# Lazy imports to avoid module loading conflicts when running scrapers with -m +def __getattr__(name): + if name == "AtCoderScraper": + from .atcoder import AtCoderScraper + + return AtCoderScraper + elif name == "BaseScraper": + from .base import BaseScraper + + return BaseScraper + elif name == "ScraperConfig": + from .base import ScraperConfig + + return ScraperConfig + elif name == "CodeforcesScraper": + from .codeforces import CodeforcesScraper + + return CodeforcesScraper + elif name == "CSESScraper": + from .cses import CSESScraper + + return CSESScraper + elif name in [ + "ContestListResult", + "ContestSummary", + "MetadataResult", + "ProblemSummary", + "TestCase", + "TestsResult", + ]: + from .models import ( + ContestListResult, # noqa: F401 + ContestSummary, # noqa: F401 + MetadataResult, # noqa: F401 + ProblemSummary, # noqa: F401 + TestCase, # noqa: F401 + TestsResult, # noqa: F401 + ) + + return locals()[name] + raise AttributeError(f"module 'scrapers' has no attribute '{name}'") + __all__ = [ "AtCoderScraper", diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 20cc3d3..cd72613 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -272,7 +272,11 @@ def scrape_contests() -> list[ContestSummary]: r"[\uff01-\uff5e]", lambda m: chr(ord(m.group()) - 0xFEE0), name ) - contests.append(ContestSummary(id=contest_id, name=name, display_name=name)) + # Skip AtCoder Heuristic Contests (AHC) as they don't have standard sample tests + if not contest_id.startswith("ahc"): + contests.append( + ContestSummary(id=contest_id, name=name, display_name=name) + ) return contests diff --git a/spec/cache_spec.lua b/spec/cache_spec.lua index a72946a..2f5053a 100644 --- a/spec/cache_spec.lua +++ b/spec/cache_spec.lua @@ -4,6 +4,19 @@ describe('cp.cache', function() before_each(function() spec_helper.setup() + + local mock_file_content = '{}' + vim.fn.filereadable = function() + return 1 + end + vim.fn.readfile = function() + return { mock_file_content } + end + vim.fn.writefile = function(lines) + mock_file_content = table.concat(lines, '\n') + end + vim.fn.mkdir = function() end + cache = require('cp.cache') cache.load() end) diff --git a/spec/command_parsing_spec.lua b/spec/command_parsing_spec.lua index 693f2b2..775b5dc 100644 --- a/spec/command_parsing_spec.lua +++ b/spec/command_parsing_spec.lua @@ -12,6 +12,87 @@ describe('cp command parsing', function() } package.loaded['cp.log'] = mock_logger + local mock_setup = { + set_platform = function() + return true + end, + setup_contest = function() end, + navigate_problem = function() end, + setup_problem = function() end, + scrape_remaining_problems = function() end, + } + package.loaded['cp.setup'] = mock_setup + + local mock_state = { + get_platform = function() + return 'atcoder' + end, + get_contest_id = function() + return 'abc123' + end, + get_problem_id = function() + return 'a' + end, + is_run_panel_active = function() + return false + end, + set_platform = function() end, + set_contest_id = function() end, + set_problem_id = function() end, + set_run_panel_active = function() end, + } + package.loaded['cp.state'] = mock_state + + local mock_ui_panel = { + toggle_run_panel = function() end, + } + package.loaded['cp.ui.panel'] = mock_ui_panel + + local mock_cache = { + load = function() end, + get_contest_data = function() + return { + problems = { + { id = 'a', name = 'Problem A' }, + { id = 'b', name = 'Problem B' }, + }, + } + end, + } + package.loaded['cp.cache'] = mock_cache + + local mock_restore = { + restore_from_current_file = function() + logged_messages[#logged_messages + 1] = + { msg = 'No file is currently open', level = vim.log.levels.ERROR } + end, + } + package.loaded['cp.restore'] = mock_restore + + local mock_picker = { + handle_pick_action = function() end, + } + package.loaded['cp.commands.picker'] = mock_picker + + local mock_cache_commands = { + handle_cache_command = function(cmd) + if cmd.subcommand == 'clear' then + if cmd.platform then + local constants = require('cp.constants') + if vim.tbl_contains(constants.PLATFORMS, cmd.platform) then + logged_messages[#logged_messages + 1] = { msg = 'cleared cache for ' .. cmd.platform } + else + logged_messages[#logged_messages + 1] = + { msg = 'unknown platform: ' .. cmd.platform, level = vim.log.levels.ERROR } + end + else + logged_messages[#logged_messages + 1] = { msg = 'cleared all cache' } + end + end + end, + } + package.loaded['cp.commands.cache'] = mock_cache_commands + cp = require('cp') cp.setup({ contests = { @@ -29,6 +110,15 @@ describe('cp command parsing', function() after_each(function() package.loaded['cp.log'] = nil + package.loaded['cp.setup'] = nil + package.loaded['cp.state'] = nil + package.loaded['cp.ui.panel'] = nil + package.loaded['cp.cache'] = nil + package.loaded['cp.restore'] = nil + package.loaded['cp.commands.picker'] = nil + package.loaded['cp.commands.cache'] = nil + package.loaded['cp'] = nil + package.loaded['cp.commands.init'] = nil end) describe('empty arguments', function() @@ -401,13 +491,13 @@ describe('cp command parsing', function() if num_args == 2 then local candidates = {} - local cp_mod = require('cp') - local context = cp_mod.get_current_context() - if context.platform and context.contest_id then + local state = require('cp.state') + if state.get_platform() and state.get_contest_id() then vim.list_extend(candidates, actions) local cache = require('cp.cache') cache.load() - local contest_data = cache.get_contest_data(context.platform, context.contest_id) + local contest_data = + cache.get_contest_data(state.get_platform(), state.get_contest_id()) if contest_data and contest_data.problems then for _, problem in ipairs(contest_data.problems) do table.insert(candidates, problem.id) @@ -450,9 +540,12 @@ describe('cp command parsing', function() return {} end - package.loaded['cp'] = { - get_current_context = function() - return { platform = nil, contest_id = nil } + package.loaded['cp.state'] = { + get_platform = function() + return nil + end, + get_contest_id = function() + return nil end, } @@ -521,9 +614,12 @@ describe('cp command parsing', function() end) it('completes all actions and problems when contest context exists', function() - package.loaded['cp'] = { - get_current_context = function() - return { platform = 'atcoder', contest_id = 'abc350' } + package.loaded['cp.state'] = { + get_platform = function() + return 'atcoder' + end, + get_contest_id = function() + return 'abc350' end, } package.loaded['cp.cache'] = { diff --git a/spec/error_boundaries_spec.lua b/spec/error_boundaries_spec.lua index 9c711fb..f17fa83 100644 --- a/spec/error_boundaries_spec.lua +++ b/spec/error_boundaries_spec.lua @@ -9,50 +9,52 @@ describe('Error boundary handling', function() log = function(msg, level) table.insert(logged_messages, { msg = msg, level = level }) end, + progress = function(msg) + table.insert(logged_messages, { msg = msg, level = vim.log.levels.INFO }) + end, set_config = function() end, } package.loaded['cp.log'] = mock_logger - package.loaded['cp.scrape'] = { - scrape_problem = function(ctx) - if ctx.contest_id == 'fail_scrape' then - return { + package.loaded['cp.scraper'] = { + scrape_problem_tests = function(_, contest_id, problem_id, callback) + if contest_id == 'fail_scrape' then + callback({ success = false, error = 'Network error', - } + }) + return end - return { + callback({ success = true, - problem_id = ctx.problem_id, - test_cases = { + problem_id = problem_id, + tests = { { input = '1', expected = '2' }, }, - test_count = 1, - } + }) end, - scrape_contest_metadata = function(_, contest_id) + scrape_contest_metadata = function(_, contest_id, callback) if contest_id == 'fail_scrape' then - return { + callback({ success = false, error = 'Network error', - } + }) + return end if contest_id == 'fail_metadata' then - return { + callback({ success = false, error = 'Contest not found', - } + }) + return end - return { + callback({ success = true, problems = { { id = 'a' }, { id = 'b' }, }, - } - end, - scrape_problems_parallel = function() - return {} + }) end, } @@ -119,7 +121,7 @@ describe('Error boundary handling', function() after_each(function() package.loaded['cp.log'] = nil - package.loaded['cp.scrape'] = nil + package.loaded['cp.scraper'] = nil if state then state.reset() end @@ -128,6 +130,8 @@ describe('Error boundary handling', function() it('should handle scraping failures without state corruption', function() cp.handle_command({ fargs = { 'codeforces', 'fail_scrape', 'a' } }) + vim.wait(100) + local has_metadata_error = false for _, log_entry in ipairs(logged_messages) do if log_entry.msg and log_entry.msg:match('failed to load contest metadata') then @@ -137,9 +141,7 @@ describe('Error boundary handling', function() end assert.is_true(has_metadata_error, 'Should log contest metadata failure') - local context = cp.get_current_context() - assert.equals('codeforces', context.platform) - assert.equals('fail_scrape', context.contest_id) + assert.equals('codeforces', state.get_platform()) assert.has_no_errors(function() cp.handle_command({ fargs = { 'run' } }) @@ -157,7 +159,7 @@ describe('Error boundary handling', function() 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 + if log_entry.msg and log_entry.msg:match('no contest data available') then has_nav_error = true break end diff --git a/spec/panel_spec.lua b/spec/panel_spec.lua index ff24e16..b15ea84 100644 --- a/spec/panel_spec.lua +++ b/spec/panel_spec.lua @@ -10,6 +10,34 @@ describe('Panel integration', function() state = require('cp.state') state.reset() + local mock_async_setup = { + setup_contest_async = function() end, + handle_full_setup_async = function(cmd) + state.set_platform(cmd.platform) + state.set_contest_id(cmd.contest) + state.set_problem_id(cmd.problem) + end, + setup_problem_async = function() end, + } + package.loaded['cp.async.setup'] = mock_async_setup + + local mock_setup = { + set_platform = function(platform) + state.set_platform(platform) + return true + end, + setup_contest = function(platform, contest, problem, _) + state.set_platform(platform) + state.set_contest_id(contest) + if problem then + state.set_problem_id(problem) + end + end, + setup_problem = function() end, + navigate_problem = function() end, + } + package.loaded['cp.setup'] = mock_setup + cp = require('cp') cp.setup({ contests = { @@ -32,10 +60,9 @@ describe('Panel integration', function() it('should handle run command with properly set contest context', function() cp.handle_command({ fargs = { 'codeforces', '2146', 'b' } }) - local context = cp.get_current_context() - assert.equals('codeforces', context.platform) - assert.equals('2146', context.contest_id) - assert.equals('b', context.problem_id) + assert.equals('codeforces', state.get_platform()) + assert.equals('2146', state.get_contest_id()) + assert.equals('b', state.get_problem_id()) assert.has_no_errors(function() cp.handle_command({ fargs = { 'run' } }) diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua index 6fd5a81..e9bb5e2 100644 --- a/spec/picker_spec.lua +++ b/spec/picker_spec.lua @@ -141,22 +141,37 @@ describe('cp.picker', function() it('falls back to scraping when cache miss', function() local cache = require('cp.cache') + local utils = require('cp.utils') cache.load = function() end cache.get_contest_data = function(_, _) return nil end + cache.set_contest_data = function() end - package.loaded['cp.scrape'] = { - scrape_contest_metadata = function(_, _) - return { - success = true, - problems = { - { id = 'x', name = 'Problem X' }, - }, - } - end, - } + utils.setup_python_env = function() + return true + end + utils.get_plugin_path = function() + return '/tmp' + end + + -- Mock vim.system to return success with problems + vim.system = function() + return { + wait = function() + return { + code = 0, + stdout = vim.json.encode({ + success = true, + problems = { + { id = 'x', name = 'Problem X' }, + }, + }), + } + end, + } + end picker = spec_helper.fresh_require('cp.pickers', { 'cp.pickers.init' }) @@ -168,16 +183,28 @@ describe('cp.picker', function() it('returns empty list when scraping fails', function() local cache = require('cp.cache') - local scrape = require('cp.scrape') + local utils = require('cp.utils') cache.load = function() end cache.get_contest_data = function(_, _) return nil end - scrape.scrape_contest_metadata = function(_, _) + + utils.setup_python_env = function() + return true + end + utils.get_plugin_path = function() + return '/tmp' + end + + vim.system = function() return { - success = false, - error = 'test error', + wait = function() + return { + code = 1, + stderr = 'Scraping failed', + } + 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..0e02f87 100644 --- a/spec/spec_helper.lua +++ b/spec/spec_helper.lua @@ -6,6 +6,9 @@ local mock_logger = { log = function(msg, level) table.insert(M.logged_messages, { msg = msg, level = level }) end, + progress = function(msg) + table.insert(M.logged_messages, { msg = msg, level = vim.log.levels.INFO }) + end, set_config = function() end, } @@ -35,10 +38,14 @@ local function setup_vim_mocks() 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 + vim.cmd = { + only = function() end, + e = function() end, + split = function() end, + vsplit = function() end, + startinsert = function() end, + stopinsert = function() end, + } if not vim.system then vim.system = function(_) return { @@ -103,6 +110,61 @@ function M.mock_scraper_success() } end +function M.mock_async_scraper_success() + package.loaded['cp.async.scraper'] = { + scrape_contest_metadata_async = function(_, _, callback) + vim.schedule(function() + callback({ + success = true, + problems = { + { id = 'a' }, + { id = 'b' }, + { id = 'c' }, + }, + }) + end) + end, + scrape_problem_async = function(_, _, 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(_, _, callback) + vim.schedule(function() + callback({ + success = false, + error = 'mock network error', + }) + end) + end, + scrape_problem_async = function(_, _, 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 +197,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 diff --git a/tests/scrapers/test_atcoder.py b/tests/scrapers/test_atcoder.py index dcde406..dc8b591 100644 --- a/tests/scrapers/test_atcoder.py +++ b/tests/scrapers/test_atcoder.py @@ -129,3 +129,71 @@ def test_scrape_contests_network_error(mocker): result = scrape_contests() assert result == [] + + +def test_scrape_contests_filters_ahc(mocker): + def mock_get_side_effect(url, **kwargs): + if url == "https://atcoder.jp/contests/archive": + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_response.text = """ + +
    +
  • 1
  • +
+ + """ + return mock_response + elif "page=1" in url: + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_response.text = """ + + + + + + + + + + + + + + + + + + + + + +
2025-01-15 21:00:00+0900AtCoder Beginner Contest 35001:40 - 1999
2025-01-14 21:00:00+0900AtCoder Heuristic Contest 04405:00-
2025-01-13 21:00:00+0900AtCoder Regular Contest 17002:001000 - 2799
+ """ + return mock_response + else: + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_response.text = "" + return mock_response + + mocker.patch("scrapers.atcoder.requests.get", side_effect=mock_get_side_effect) + + result = scrape_contests() + + assert len(result) == 2 + assert result[0] == ContestSummary( + id="abc350", + name="AtCoder Beginner Contest 350", + display_name="AtCoder Beginner Contest 350", + ) + assert result[1] == ContestSummary( + id="arc170", + name="AtCoder Regular Contest 170", + display_name="AtCoder Regular Contest 170", + ) + + # Ensure ahc044 is filtered out + contest_ids = [contest.id for contest in result] + assert "ahc044" not in contest_ids diff --git a/tests/scrapers/test_interface_compliance.py b/tests/scrapers/test_interface_compliance.py index e81375b..ab07ff2 100644 --- a/tests/scrapers/test_interface_compliance.py +++ b/tests/scrapers/test_interface_compliance.py @@ -1,4 +1,3 @@ -import inspect from unittest.mock import Mock import pytest @@ -8,9 +7,9 @@ from scrapers.base import BaseScraper from scrapers.models import ContestListResult, MetadataResult, TestsResult SCRAPERS = [ - cls - for name, cls in inspect.getmembers(scrapers, inspect.isclass) - if issubclass(cls, BaseScraper) and cls != BaseScraper + scrapers.AtCoderScraper, + scrapers.CodeforcesScraper, + scrapers.CSESScraper, ]