From d862df9104ecba9df0dc844bda5cec361f3ae55c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 24 Sep 2025 19:47:00 -0400 Subject: [PATCH 1/4] fix: only display configured platforms in pickers --- lua/cp/pickers/init.lua | 31 +++++++++++++++---------------- lua/cp/setup.lua | 2 +- lua/cp/ui/panel.lua | 19 +++++-------------- scrapers/__init__.py | 1 - 4 files changed, 21 insertions(+), 32 deletions(-) diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index 77c0685..096edfa 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -1,6 +1,7 @@ local M = {} local cache = require('cp.cache') +local config = require('cp.config').get_config() local logger = require('cp.log') local utils = require('cp.utils') @@ -18,26 +19,28 @@ local utils = require('cp.utils') ---@field name string Problem name (e.g. "Two Permutations", "Painting Walls") ---@field display_name string Formatted display name for picker ----Get list of available competitive programming platforms ---@return cp.PlatformItem[] local function get_platforms() local constants = require('cp.constants') - return vim.tbl_map(function(platform) - return { - id = platform, - display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform, - } - end, constants.PLATFORMS) + local result = {} + + for _, platform in ipairs(constants.PLATFORMS) do + if config.contests[platform] then + table.insert(result, { + id = platform, + display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform, + }) + end + end + + return result end ---Get list of contests for a specific platform ---@param platform string Platform identifier (e.g. "codeforces", "atcoder") ---@return cp.ContestItem[] local function get_contests_for_platform(platform) - local constants = require('cp.constants') - local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform - - logger.log(('loading %s contests...'):format(platform_display_name), vim.log.levels.INFO, true) + logger.log('loading contests...', vim.log.levels.INFO, true) cache.load() local cached_contests = cache.get_contest_list(platform) @@ -131,11 +134,7 @@ local function get_problems_for_contest(platform, contest_id) return problems end - logger.log( - ('loading %s %s problems...'):format(platform_display_name, contest_id), - vim.log.levels.INFO, - true - ) + logger.log('loading contest problems...', vim.log.levels.INFO, true) if not utils.setup_python_env() then return problems diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 48ebed8..21dba2c 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -136,7 +136,7 @@ function M.setup_problem(contest_id, problem_id, language) local source_file = state.get_source_file(language) if not source_file then - error('Failed to generate source file path') + return end vim.cmd.e(source_file) local source_buf = vim.api.nvim_get_current_buf() diff --git a/lua/cp/ui/panel.lua b/lua/cp/ui/panel.lua index 9fe2a9f..c851a04 100644 --- a/lua/cp/ui/panel.lua +++ b/lua/cp/ui/panel.lua @@ -9,10 +9,6 @@ local state = require('cp.state') local current_diff_layout = nil local current_mode = nil -local function get_current_problem() - return state.get_problem_id() -end - function M.toggle_run_panel(is_debug) if state.is_run_panel_active() then if current_diff_layout then @@ -39,7 +35,7 @@ function M.toggle_run_panel(is_debug) return end - local problem_id = get_current_problem() + local problem_id = state.get_problem_id() if not problem_id then return end @@ -49,9 +45,9 @@ function M.toggle_run_panel(is_debug) logger.log( ('run panel: platform=%s, contest=%s, problem=%s'):format( - platform or 'nil', - contest_id or 'nil', - problem_id or 'nil' + tostring(platform), + tostring(contest_id), + tostring(problem_id) ) ) @@ -124,12 +120,7 @@ function M.toggle_run_panel(is_debug) return end - test_state.current_index = test_state.current_index + delta - if test_state.current_index < 1 then - test_state.current_index = #test_state.test_cases - elseif test_state.current_index > #test_state.test_cases then - test_state.current_index = 1 - end + test_state.current_index = (test_state.current_index + delta) % #test_state.test_cases refresh_run_panel() end diff --git a/scrapers/__init__.py b/scrapers/__init__.py index 6140dce..01e594c 100644 --- a/scrapers/__init__.py +++ b/scrapers/__init__.py @@ -1,4 +1,3 @@ -# Lazy imports to avoid module loading conflicts when running scrapers with -m def __getattr__(name): if name == "AtCoderScraper": from .atcoder import AtCoderScraper From b70f38626eec9c485139b1d7d2eba44b96d5562d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 24 Sep 2025 20:04:29 -0400 Subject: [PATCH 2/4] cleanup --- scrapers/__init__.py | 58 +------ scrapers/cses.py | 0 tests/scrapers/test_interface_compliance.py | 167 -------------------- 3 files changed, 4 insertions(+), 221 deletions(-) mode change 100755 => 100644 scrapers/cses.py delete mode 100644 tests/scrapers/test_interface_compliance.py diff --git a/scrapers/__init__.py b/scrapers/__init__.py index 01e594c..4749123 100644 --- a/scrapers/__init__.py +++ b/scrapers/__init__.py @@ -1,55 +1,5 @@ -def __getattr__(name): - if name == "AtCoderScraper": - from .atcoder import AtCoderScraper +from .atcoder import AtCoderScraper +from .codeforces import CodeforcesScraper +from .cses import CSESScraper - 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", - "BaseScraper", - "CodeforcesScraper", - "CSESScraper", - "ScraperConfig", - "ContestListResult", - "ContestSummary", - "MetadataResult", - "ProblemSummary", - "TestCase", - "TestsResult", -] +__all__ = ["CodeforcesScraper", "CSESScraper", "AtCoderScraper"] diff --git a/scrapers/cses.py b/scrapers/cses.py old mode 100755 new mode 100644 diff --git a/tests/scrapers/test_interface_compliance.py b/tests/scrapers/test_interface_compliance.py deleted file mode 100644 index ab07ff2..0000000 --- a/tests/scrapers/test_interface_compliance.py +++ /dev/null @@ -1,167 +0,0 @@ -from unittest.mock import Mock - -import pytest - -import scrapers -from scrapers.base import BaseScraper -from scrapers.models import ContestListResult, MetadataResult, TestsResult - -SCRAPERS = [ - scrapers.AtCoderScraper, - scrapers.CodeforcesScraper, - scrapers.CSESScraper, -] - - -class TestScraperInterfaceCompliance: - @pytest.mark.parametrize("scraper_class", SCRAPERS) - def test_implements_base_interface(self, scraper_class): - scraper = scraper_class() - - assert isinstance(scraper, BaseScraper) - assert hasattr(scraper, "platform_name") - assert hasattr(scraper, "scrape_contest_metadata") - assert hasattr(scraper, "scrape_problem_tests") - assert hasattr(scraper, "scrape_contest_list") - - @pytest.mark.parametrize("scraper_class", SCRAPERS) - def test_platform_name_is_string(self, scraper_class): - scraper = scraper_class() - platform_name = scraper.platform_name - - assert isinstance(platform_name, str) - assert len(platform_name) > 0 - assert platform_name.islower() # Convention: lowercase platform names - - @pytest.mark.parametrize("scraper_class", SCRAPERS) - def test_metadata_method_signature(self, scraper_class, mocker): - scraper = scraper_class() - - # Mock the underlying HTTP calls to avoid network requests - if scraper.platform_name == "codeforces": - mock_scraper = Mock() - mock_response = Mock() - mock_response.text = "A. Test" - mock_scraper.get.return_value = mock_response - mocker.patch( - "scrapers.codeforces.cloudscraper.create_scraper", - return_value=mock_scraper, - ) - - result = scraper.scrape_contest_metadata("test_contest") - - assert isinstance(result, MetadataResult) - assert hasattr(result, "success") - assert hasattr(result, "error") - assert hasattr(result, "problems") - assert hasattr(result, "contest_id") - assert isinstance(result.success, bool) - assert isinstance(result.error, str) - - @pytest.mark.parametrize("scraper_class", SCRAPERS) - def test_problem_tests_method_signature(self, scraper_class, mocker): - scraper = scraper_class() - - if scraper.platform_name == "codeforces": - mock_scraper = Mock() - mock_response = Mock() - mock_response.text = """ -
Time limit: 1 seconds
-
Memory limit: 256 megabytes
-
3
-
6
- """ - mock_scraper.get.return_value = mock_response - mocker.patch( - "scrapers.codeforces.cloudscraper.create_scraper", - return_value=mock_scraper, - ) - - result = scraper.scrape_problem_tests("test_contest", "A") - - assert isinstance(result, TestsResult) - assert hasattr(result, "success") - assert hasattr(result, "error") - assert hasattr(result, "tests") - assert hasattr(result, "problem_id") - assert hasattr(result, "url") - assert hasattr(result, "timeout_ms") - assert hasattr(result, "memory_mb") - assert isinstance(result.success, bool) - assert isinstance(result.error, str) - - @pytest.mark.parametrize("scraper_class", SCRAPERS) - def test_contest_list_method_signature(self, scraper_class, mocker): - scraper = scraper_class() - - if scraper.platform_name == "codeforces": - mock_scraper = Mock() - mock_response = Mock() - mock_response.json.return_value = { - "status": "OK", - "result": [{"id": 1900, "name": "Test Contest"}], - } - mock_scraper.get.return_value = mock_response - mocker.patch( - "scrapers.codeforces.cloudscraper.create_scraper", - return_value=mock_scraper, - ) - - result = scraper.scrape_contest_list() - - assert isinstance(result, ContestListResult) - assert hasattr(result, "success") - assert hasattr(result, "error") - assert hasattr(result, "contests") - assert isinstance(result.success, bool) - assert isinstance(result.error, str) - - @pytest.mark.parametrize("scraper_class", SCRAPERS) - def test_error_message_format(self, scraper_class, mocker): - scraper = scraper_class() - platform_name = scraper.platform_name - - # Force an error by mocking HTTP failure - if scraper.platform_name == "codeforces": - mock_scraper = Mock() - mock_scraper.get.side_effect = Exception("Network error") - mocker.patch( - "scrapers.codeforces.cloudscraper.create_scraper", - return_value=mock_scraper, - ) - elif scraper.platform_name == "atcoder": - mocker.patch( - "scrapers.atcoder.requests.get", side_effect=Exception("Network error") - ) - elif scraper.platform_name == "cses": - mocker.patch( - "scrapers.cses.make_request", side_effect=Exception("Network error") - ) - - # Test metadata error format - result = scraper.scrape_contest_metadata("test") - assert not result.success - assert result.error.startswith(f"{platform_name}: ") - - # Test problem tests error format - result = scraper.scrape_problem_tests("test", "A") - assert not result.success - assert result.error.startswith(f"{platform_name}: ") - - # Test contest list error format - result = scraper.scrape_contest_list() - assert not result.success - assert result.error.startswith(f"{platform_name}: ") - - @pytest.mark.parametrize("scraper_class", SCRAPERS) - def test_scraper_instantiation(self, scraper_class): - scraper1 = scraper_class() - assert isinstance(scraper1, BaseScraper) - assert scraper1.config is not None - - from scrapers.base import ScraperConfig - - custom_config = ScraperConfig(timeout_seconds=60) - scraper2 = scraper_class(custom_config) - assert isinstance(scraper2, BaseScraper) - assert scraper2.config.timeout_seconds == 60 From a24ac2314c94744bb5a814fee2c77bd3ef2964ca Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 24 Sep 2025 20:08:23 -0400 Subject: [PATCH 3/4] remove picker spec --- lua/cp/pickers/init.lua | 2 - spec/picker_spec.lua | 241 ---------------------------------------- 2 files changed, 243 deletions(-) delete mode 100644 spec/picker_spec.lua diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index 096edfa..2380b74 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -117,8 +117,6 @@ end ---@param contest_id string Contest identifier ---@return cp.ProblemItem[] local function get_problems_for_contest(platform, contest_id) - local constants = require('cp.constants') - local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform local problems = {} cache.load() diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua deleted file mode 100644 index e9bb5e2..0000000 --- a/spec/picker_spec.lua +++ /dev/null @@ -1,241 +0,0 @@ -describe('cp.picker', function() - local picker - local spec_helper = require('spec.spec_helper') - - before_each(function() - spec_helper.setup() - picker = require('cp.pickers') - end) - - after_each(function() - spec_helper.teardown() - end) - - describe('get_platforms', function() - it('returns platform list with display names', function() - local platforms = picker.get_platforms() - - assert.is_table(platforms) - assert.is_true(#platforms > 0) - - for _, platform in ipairs(platforms) do - assert.is_string(platform.id) - assert.is_string(platform.display_name) - assert.is_not_nil(platform.display_name:match('^%u')) - end - end) - - it('includes expected platforms with correct display names', function() - local platforms = picker.get_platforms() - local platform_map = {} - for _, p in ipairs(platforms) do - platform_map[p.id] = p.display_name - end - - assert.equals('CodeForces', platform_map['codeforces']) - assert.equals('AtCoder', platform_map['atcoder']) - assert.equals('CSES', platform_map['cses']) - end) - end) - - describe('get_contests_for_platform', function() - it('returns empty list when scraper fails', function() - vim.system = function(_, _) - return { - wait = function() - return { code = 1, stderr = 'test error' } - end, - } - end - - local contests = picker.get_contests_for_platform('test_platform') - assert.is_table(contests) - assert.equals(0, #contests) - end) - - it('returns empty list when JSON is invalid', function() - vim.system = function(_, _) - return { - wait = function() - return { code = 0, stdout = 'invalid json' } - end, - } - end - - local contests = picker.get_contests_for_platform('test_platform') - assert.is_table(contests) - assert.equals(0, #contests) - end) - - it('returns contest list when scraper succeeds', function() - local cache = require('cp.cache') - local utils = require('cp.utils') - - cache.load = function() end - cache.get_contest_list = function() - return nil - end - cache.set_contest_list = function() end - - utils.setup_python_env = function() - return true - end - utils.get_plugin_path = function() - return '/test/path' - end - - vim.system = function(_, _) - return { - wait = function() - return { - code = 0, - stdout = vim.json.encode({ - success = true, - contests = { - { - id = 'abc123', - name = 'AtCoder Beginner Contest 123', - display_name = 'Beginner Contest 123 (ABC)', - }, - { - id = '1951', - name = 'Educational Round 168', - display_name = 'Educational Round 168', - }, - }, - }), - } - end, - } - end - - local contests = picker.get_contests_for_platform('test_platform') - assert.is_table(contests) - assert.equals(2, #contests) - assert.equals('abc123', contests[1].id) - assert.equals('AtCoder Beginner Contest 123', contests[1].name) - assert.equals('Beginner Contest 123 (ABC)', contests[1].display_name) - end) - end) - - describe('get_problems_for_contest', function() - it('returns problems from cache when available', function() - local cache = require('cp.cache') - cache.load = function() end - cache.get_contest_data = function(_, _) - return { - problems = { - { id = 'a', name = 'Problem A' }, - { id = 'b', name = 'Problem B' }, - }, - } - end - - local problems = picker.get_problems_for_contest('test_platform', 'test_contest') - assert.is_table(problems) - assert.equals(2, #problems) - assert.equals('a', problems[1].id) - assert.equals('Problem A', problems[1].name) - assert.equals('Problem A', problems[1].display_name) - end) - - 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 - - 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' }) - - local problems = picker.get_problems_for_contest('test_platform', 'test_contest') - assert.is_table(problems) - assert.equals(1, #problems) - assert.equals('x', problems[1].id) - end) - - it('returns empty list when scraping fails', function() - local cache = require('cp.cache') - local utils = require('cp.utils') - - cache.load = function() end - cache.get_contest_data = function(_, _) - return nil - end - - utils.setup_python_env = function() - return true - end - utils.get_plugin_path = function() - return '/tmp' - end - - vim.system = function() - return { - wait = function() - return { - code = 1, - stderr = 'Scraping failed', - } - end, - } - end - - picker = spec_helper.fresh_require('cp.pickers', { 'cp.pickers.init' }) - - local problems = picker.get_problems_for_contest('test_platform', 'test_contest') - assert.is_table(problems) - assert.equals(0, #problems) - end) - end) - - describe('setup_problem', function() - it('calls cp.handle_command with correct arguments', function() - local cp = require('cp') - local called_with = nil - - cp.handle_command = function(opts) - called_with = opts - end - - picker.setup_problem('codeforces', '1951', 'a') - - vim.wait(100, function() - return called_with ~= nil - end) - - assert.is_table(called_with) - assert.is_table(called_with.fargs) - assert.equals('codeforces', called_with.fargs[1]) - assert.equals('1951', called_with.fargs[2]) - assert.equals('a', called_with.fargs[3]) - end) - end) -end) From 7c337d6b33e1165efeffe709abbc4e230e61c50d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 24 Sep 2025 20:09:36 -0400 Subject: [PATCH 4/4] fix --- spec/picker_spec.lua | 214 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 spec/picker_spec.lua diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua new file mode 100644 index 0000000..eeee9d7 --- /dev/null +++ b/spec/picker_spec.lua @@ -0,0 +1,214 @@ +describe('cp.picker', function() + local picker + local spec_helper = require('spec.spec_helper') + + before_each(function() + spec_helper.setup() + picker = require('cp.pickers') + end) + + after_each(function() + spec_helper.teardown() + end) + + describe('get_contests_for_platform', function() + it('returns empty list when scraper fails', function() + vim.system = function(_, _) + return { + wait = function() + return { code = 1, stderr = 'test error' } + end, + } + end + + local contests = picker.get_contests_for_platform('test_platform') + assert.is_table(contests) + assert.equals(0, #contests) + end) + + it('returns empty list when JSON is invalid', function() + vim.system = function(_, _) + return { + wait = function() + return { code = 0, stdout = 'invalid json' } + end, + } + end + + local contests = picker.get_contests_for_platform('test_platform') + assert.is_table(contests) + assert.equals(0, #contests) + end) + + it('returns contest list when scraper succeeds', function() + local cache = require('cp.cache') + local utils = require('cp.utils') + + cache.load = function() end + cache.get_contest_list = function() + return nil + end + cache.set_contest_list = function() end + + utils.setup_python_env = function() + return true + end + utils.get_plugin_path = function() + return '/test/path' + end + + vim.system = function(_, _) + return { + wait = function() + return { + code = 0, + stdout = vim.json.encode({ + success = true, + contests = { + { + id = 'abc123', + name = 'AtCoder Beginner Contest 123', + display_name = 'Beginner Contest 123 (ABC)', + }, + { + id = '1951', + name = 'Educational Round 168', + display_name = 'Educational Round 168', + }, + }, + }), + } + end, + } + end + + local contests = picker.get_contests_for_platform('test_platform') + assert.is_table(contests) + assert.equals(2, #contests) + assert.equals('abc123', contests[1].id) + assert.equals('AtCoder Beginner Contest 123', contests[1].name) + assert.equals('Beginner Contest 123 (ABC)', contests[1].display_name) + end) + end) + + describe('get_problems_for_contest', function() + it('returns problems from cache when available', function() + local cache = require('cp.cache') + cache.load = function() end + cache.get_contest_data = function(_, _) + return { + problems = { + { id = 'a', name = 'Problem A' }, + { id = 'b', name = 'Problem B' }, + }, + } + end + + local problems = picker.get_problems_for_contest('test_platform', 'test_contest') + assert.is_table(problems) + assert.equals(2, #problems) + assert.equals('a', problems[1].id) + assert.equals('Problem A', problems[1].name) + assert.equals('Problem A', problems[1].display_name) + end) + + 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 + + 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' }) + + local problems = picker.get_problems_for_contest('test_platform', 'test_contest') + assert.is_table(problems) + assert.equals(1, #problems) + assert.equals('x', problems[1].id) + end) + + it('returns empty list when scraping fails', function() + local cache = require('cp.cache') + local utils = require('cp.utils') + + cache.load = function() end + cache.get_contest_data = function(_, _) + return nil + end + + utils.setup_python_env = function() + return true + end + utils.get_plugin_path = function() + return '/tmp' + end + + vim.system = function() + return { + wait = function() + return { + code = 1, + stderr = 'Scraping failed', + } + end, + } + end + + picker = spec_helper.fresh_require('cp.pickers', { 'cp.pickers.init' }) + + local problems = picker.get_problems_for_contest('test_platform', 'test_contest') + assert.is_table(problems) + assert.equals(0, #problems) + end) + end) + + describe('setup_problem', function() + it('calls cp.handle_command with correct arguments', function() + local cp = require('cp') + local called_with = nil + + cp.handle_command = function(opts) + called_with = opts + end + + picker.setup_problem('codeforces', '1951', 'a') + + vim.wait(100, function() + return called_with ~= nil + end) + + assert.is_table(called_with) + assert.is_table(called_with.fargs) + assert.equals('codeforces', called_with.fargs[1]) + assert.equals('1951', called_with.fargs[2]) + assert.equals('a', called_with.fargs[3]) + end) + end) +end)