feat: async scraper
This commit is contained in:
parent
53562eb6a8
commit
a32fd396d3
12 changed files with 1527 additions and 474 deletions
25
lua/cp/async/init.lua
Normal file
25
lua/cp/async/init.lua
Normal file
|
|
@ -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
|
||||
44
lua/cp/async/jobs.lua
Normal file
44
lua/cp/async/jobs.lua
Normal file
|
|
@ -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
|
||||
202
lua/cp/async/scraper.lua
Normal file
202
lua/cp/async/scraper.lua
Normal file
|
|
@ -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
|
||||
271
lua/cp/async/setup.lua
Normal file
271
lua/cp/async/setup.lua
Normal file
|
|
@ -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 <platform> <contest> 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<c-space><esc>'):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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
50
spec/async_init_spec.lua
Normal file
50
spec/async_init_spec.lua
Normal file
|
|
@ -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)
|
||||
288
spec/async_integration_spec.lua
Normal file
288
spec/async_integration_spec.lua
Normal file
|
|
@ -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)
|
||||
111
spec/async_jobs_spec.lua
Normal file
111
spec/async_jobs_spec.lua
Normal file
|
|
@ -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)
|
||||
185
spec/async_scraper_spec.lua
Normal file
185
spec/async_scraper_spec.lua
Normal file
|
|
@ -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)
|
||||
286
spec/async_setup_spec.lua
Normal file
286
spec/async_setup_spec.lua
Normal file
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue