diff --git a/README.md b/README.md index af4eeac..fa776d4 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Features -------- - Browser UI with preview for exercises - Deterministic navigation: next, skip, previous -- Code runners for Python (and Rust if enabled) +- Extensible engine registry: interpreted and compiled runners - Theory questions with answer checking - Results window and solution viewer - LLM-powered exercise generation (see Tools below) @@ -62,16 +62,15 @@ Requirements - Neovim 0.10+ - MunifTanjim/nui.nvim - kkharji/sqlite.lua -- python3 (for Python exercises) -- cargo (optional, for Rust exercises) +- Engine executables for each enabled engine (run `:checkhealth code-practice`) Quick Start ----------- 1. Open browser: `:CP` (or `cp`) 2. Navigate with `j`/`k`, open with `Enter` 3. Write your solution in the buffer -4. Run tests: `r` (or `:CPRun`) -5. Move on: `n` (or `:CPNext`) +4. Run tests: `Ctrl-t` (or `:CPRun`) +5. Move on: `Ctrl-n` (or `:CPNext`) Commands -------- @@ -120,18 +119,19 @@ Global Keymaps Exercise Buffer Keymaps ----------------------- -These are active only inside exercise buffers (set via `keymaps.exercise` config): - -| Key | Action | -|----------------|---------------------------------| -| `r` | Run tests | -| `h` | Show hints | -| `s` | View solution (split) | -| `d` | Show description | -| `n` | Next exercise | -| `p` | Previous exercise | -| `k` | Skip exercise | -| `m` | Open browser (menu) | +Active in normal mode inside exercise buffers. All use Ctrl shortcuts for +single-chord access (configurable via `keymaps.exercise`): + +| Key | Action | +|-----------|---------------------------------| +| `Ctrl-t` | Run tests | +| `Ctrl-n` | Next exercise | +| `Ctrl-p` | Previous exercise | +| `Ctrl-k` | Skip exercise | +| `Ctrl-i` | Show hints | +| `Ctrl-l` | View solution (split) | +| `Ctrl-d` | Show description | +| `Ctrl-b` | Open browser | Tools ----- diff --git a/doc/code-practice.txt b/doc/code-practice.txt index 66b8307..6361831 100644 --- a/doc/code-practice.txt +++ b/doc/code-practice.txt @@ -68,8 +68,8 @@ After installing, populate the exercise database with |:CPImport| or 1. Open the browser |:CP| or `cp` 2. Navigate with `j`/`k` Press `Enter` to open an exercise 3. Write your solution Edit the buffer -4. Run tests |:CPRun| or `r` -5. Move on |:CPNext| or `n` +4. Run tests |:CPRun| or `Ctrl-t` +5. Move on |:CPNext| or `Ctrl-n` ============================================================================== 4. COMMANDS *code-practice-commands* @@ -85,8 +85,8 @@ All commands support tab completion. Type `:CP` to explore. *:CPRun* :CPRun Run tests for the current exercise. For theory - exercises, expects a line like `Answer: 2` in the - buffer. + exercises, reads the answer from the `Answer:` line + in the buffer (press 1-4 to select). *:CPNext* :CPNext Open the next unsolved exercise. Skips exercises @@ -161,17 +161,19 @@ Active inside the browser window opened by |:CP|. EXERCISE BUFFER KEYMAPS ~ *code-practice-keymaps-exercise* -Active inside exercise buffers (set via `keymaps.exercise` in config). +Active inside exercise buffers (normal mode only). All exercise keymaps +use Ctrl shortcuts for single-chord access. Keymaps are configurable +via `keymaps.exercise` in config. Key Action ~ - `r` Run tests (|:CPRun|) - `h` Show hints (|:CPHint|) - `s` View solution (|:CPSolution|) - `d` Show description (|:CPDesc|) - `n` Next exercise (|:CPNext|) - `p` Previous exercise (|:CPPrev|) - `k` Skip exercise (|:CPSkip|) - `m` Open browser (|:CP|) + `Ctrl-t` Run tests (|:CPRun|) + `Ctrl-n` Next exercise (|:CPNext|) + `Ctrl-p` Previous exercise (|:CPPrev|) + `Ctrl-k` Skip exercise (|:CPSkip|) + `Ctrl-i` Show hints (|:CPHint|) + `Ctrl-l` View solution (|:CPSolution|) + `Ctrl-d` Show description (|:CPDesc|) + `Ctrl-b` Open browser (|:CP|) GLOBAL KEYMAPS ~ *code-practice-keymaps-global* @@ -227,14 +229,14 @@ Options are merged with |vim.tbl_deep_extend()|. close = "q", }, exercise = { - run_tests = "r", - show_hint = "h", - view_solution = "s", - show_description = "d", - next_exercise = "n", - prev_exercise = "p", - skip_exercise = "k", - open_browser = "m", + run_tests = "", + show_hint = "", + view_solution = "", + show_description = "", + next_exercise = "", + prev_exercise = "", + skip_exercise = "", + open_browser = "", }, }, }) diff --git a/lua/code-practice/browser.lua b/lua/code-practice/browser.lua index 75fe1e3..5266b76 100644 --- a/lua/code-practice/browser.lua +++ b/lua/code-practice/browser.lua @@ -70,9 +70,13 @@ function browser.render_exercise_list() table.insert(lines, " No exercises found.") end + local km = config.get("keymaps.browser") or {} table.insert(lines, "") table.insert(lines, " " .. string.rep("─", 30)) - table.insert(lines, " j/k:nav Enter:open ?:help q:close a:all") + table.insert( + lines, + " j/k:nav Enter:open ?:help " .. (km.close or "q") .. ":close " .. (km.filter_all or "a") .. ":all" + ) return lines end @@ -244,10 +248,10 @@ function browser.setup_keymaps() map("", "lua require('code-practice.browser').open_selected()") end map("o", "lua require('code-practice.browser').open_selected()") - map("e", "lua require('code-practice.browser').filter_by_difficulty('easy')") - map("m", "lua require('code-practice.browser').filter_by_difficulty('medium')") - map("h", "lua require('code-practice.browser').filter_by_difficulty('hard')") - map("a", "lua require('code-practice.browser').clear_filters()") + map(keymaps.filter_easy or "e", "lua require('code-practice.browser').filter_by_difficulty('easy')") + map(keymaps.filter_medium or "m", "lua require('code-practice.browser').filter_by_difficulty('medium')") + map(keymaps.filter_hard or "h", "lua require('code-practice.browser').filter_by_difficulty('hard')") + map(keymaps.filter_all or "a", "lua require('code-practice.browser').clear_filters()") for _, name in ipairs(engines.list()) do local eng = engines.get(name) @@ -256,8 +260,11 @@ function browser.setup_keymaps() end end - map("q", "lua require('code-practice.browser').close()") - map("", "lua require('code-practice.browser').close()") + local close_key = keymaps.close or "q" + map(close_key, "lua require('code-practice.browser').close()") + if close_key ~= "" then + map("", "lua require('code-practice.browser').close()") + end map("?", "lua require('code-practice.help').show()") end @@ -298,6 +305,9 @@ end function browser.refresh() fetch_exercises() + if state.selected_index > #state.exercises then + state.selected_index = math.max(1, #state.exercises) + end update_display() end @@ -376,8 +386,6 @@ function browser.close() end function browser.open() - state.selected_index = 1 - state.current_filter = { difficulty = nil, engine = nil, search = "" } browser.create_popup() end diff --git a/lua/code-practice/config.lua b/lua/code-practice/config.lua index ce6474f..b032af9 100644 --- a/lua/code-practice/config.lua +++ b/lua/code-practice/config.lua @@ -46,14 +46,14 @@ M.defaults = { close = "q", }, exercise = { - run_tests = "r", - show_hint = "h", - view_solution = "s", - show_description = "d", - next_exercise = "n", - prev_exercise = "p", - skip_exercise = "k", - open_browser = "m", + run_tests = "", + show_hint = "", + view_solution = "", + show_description = "", + next_exercise = "", + prev_exercise = "", + skip_exercise = "", + open_browser = "", }, }, } diff --git a/lua/code-practice/help.lua b/lua/code-practice/help.lua index b35ffc4..58cdc64 100644 --- a/lua/code-practice/help.lua +++ b/lua/code-practice/help.lua @@ -7,6 +7,7 @@ end local config = require("code-practice.config") local engines = require("code-practice.engines") +local popup_util = require("code-practice.popup") local help = {} @@ -113,7 +114,7 @@ function help.show() "", " See :help code-practice for full documentation", "", - " Press q or to close", + " Press q, , or to close", "", } for _, el in ipairs(exercise_lines) do @@ -131,15 +132,11 @@ function help.show() end end - local function close() + popup_util.map_close(popup.bufnr, function() if popup and popup.winid and vim.api.nvim_win_is_valid(popup.winid) then popup:unmount() end - end - - vim.keymap.set({ "n", "i" }, "q", close, { buffer = popup.bufnr, silent = true, nowait = true }) - vim.keymap.set({ "n", "i" }, "", close, { buffer = popup.bufnr, silent = true, nowait = true }) - vim.keymap.set({ "n", "i" }, "", close, { buffer = popup.bufnr, silent = true, nowait = true }) + end) end return help diff --git a/lua/code-practice/init.lua b/lua/code-practice/init.lua index d967c15..6c3d182 100644 --- a/lua/code-practice/init.lua +++ b/lua/code-practice/init.lua @@ -162,23 +162,15 @@ function code_practice.run_tests() if engine_name == "theory" then local answer = nil - local has_answer_line = false for _, line in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) do - if line:match("^[Aa]nswer:") then - has_answer_line = true - answer = line:match("^[Aa]nswer:%s*(%d+)") - if answer then - break - end + answer = line:match("^Answer:%s*(%d+)") + if answer then + break end end if not answer then - if has_answer_line then - utils.notify("Please set 'Answer: ' before running tests", "error") - else - utils.notify("Missing answer. Add a line like 'Answer: 2'", "error") - end + utils.notify("Select an answer first (press 1-4)", "error") return end @@ -243,26 +235,25 @@ end function code_practice.show_stats() local stats = manager.get_stats() - local msg = string.format( - [[ -Code Practice Statistics -======================== -Total Exercises: %d -Solved: %d - -By Difficulty: - Easy: %d - Medium: %d - Hard: %d -]], - stats.total or 0, - stats.solved or 0, - stats.by_difficulty and stats.by_difficulty.easy or 0, - stats.by_difficulty and stats.by_difficulty.medium or 0, - stats.by_difficulty and stats.by_difficulty.hard or 0 - ) - - vim.api.nvim_echo({ { msg, "Normal" } }, true, {}) + local lines = { + "", + " Total Exercises: " .. (stats.total or 0), + " Solved: " .. (stats.solved or 0), + "", + " By Difficulty:", + " Easy: " .. (stats.by_difficulty and stats.by_difficulty.easy or 0), + " Medium: " .. (stats.by_difficulty and stats.by_difficulty.medium or 0), + " Hard: " .. (stats.by_difficulty and stats.by_difficulty.hard or 0), + "", + } + + local bufnr, winid = popup.open_float({ width = 0.3, height = 0.3, title = " Statistics " }) + popup.set_lines(bufnr, lines) + popup.map_close(bufnr, function() + if winid and vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_close(winid, true) + end + end) end function code_practice.get_current_exercise_id() @@ -288,12 +279,19 @@ function code_practice.show_hints() return end - local msg = "Hints:\n" + local lines = { "" } for i, hint in ipairs(hints) do - msg = msg .. string.format("%d. %s\n", i, hint) + table.insert(lines, string.format(" %d. %s", i, hint)) + table.insert(lines, "") end - vim.api.nvim_echo({ { msg, "Normal" } }, true, {}) + local bufnr, winid = popup.open_float({ width = 0.5, height = 0.4, title = " Hints " }) + popup.set_lines(bufnr, lines) + popup.map_close(bufnr, function() + if winid and vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_close(winid, true) + end + end) end function code_practice.show_solution() diff --git a/lua/code-practice/manager.lua b/lua/code-practice/manager.lua index 31cc530..6885b8e 100644 --- a/lua/code-practice/manager.lua +++ b/lua/code-practice/manager.lua @@ -1,4 +1,5 @@ -- Code Practice - Exercise Manager Module +local config = require("code-practice.config") local db = require("code-practice.db") local engines = require("code-practice.engines") local utils = require("code-practice.utils") @@ -41,18 +42,22 @@ function manager.open_exercise(id) local bufname = string.format("code-practice://exercise/%d", id) local bufnr = vim.fn.bufnr(bufname) local is_new_buf = bufnr == -1 + local needs_content = is_new_buf if is_new_buf then bufnr = vim.api.nvim_create_buf(true, false) vim.api.nvim_buf_set_name(bufnr, bufname) + elseif not vim.api.nvim_buf_is_loaded(bufnr) then + needs_content = true end local filetype = engines.filetype(exercise.engine) vim.bo[bufnr].buftype = "nofile" + vim.bo[bufnr].bufhidden = "hide" vim.bo[bufnr].filetype = filetype vim.bo[bufnr].swapfile = false - if is_new_buf then + if needs_content then vim.bo[bufnr].modifiable = true vim.bo[bufnr].readonly = false @@ -89,6 +94,9 @@ function manager.open_exercise(id) for _, opt in ipairs(theory_options) do add_meta(string.format("%d. %s", opt.option_number, opt.option_text)) end + add_meta("") + local run_key = (config.get("keymaps.exercise") or {}).run_tests or "" + add_meta("Press 1-" .. #theory_options .. " to select your answer, then " .. run_key .. " to run tests.") end end @@ -129,6 +137,30 @@ function manager.open_exercise(id) vim.b[bufnr].code_practice_exercise_id = id vim.b[bufnr].code_practice_engine = exercise.engine + if exercise.engine == "theory" then + local opts_by_num = {} + for _, opt in ipairs(exercise.options or {}) do + opts_by_num[opt.option_number] = opt.option_text + end + + for num, text in pairs(opts_by_num) do + vim.keymap.set("n", tostring(num), function() + local line_count = vim.api.nvim_buf_line_count(bufnr) + for i = 0, line_count - 1 do + local line = vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] + if line and line:match("^Answer:") then + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, i, i + 1, false, { + string.format("Answer: %d [%s]", num, text), + }) + utils.notify(string.format("Selected option %d: %s", num, text), "info") + return + end + end + end, { buffer = bufnr, noremap = true, nowait = true }) + end + end + local current_win = vim.api.nvim_get_current_win() local function is_floating(win) local ok, cfg = pcall(vim.api.nvim_win_get_config, win) diff --git a/lua/code-practice/results.lua b/lua/code-practice/results.lua index 7848f7b..706a6ca 100644 --- a/lua/code-practice/results.lua +++ b/lua/code-practice/results.lua @@ -1,4 +1,5 @@ -- Code Practice - Results Display Module +local config = require("code-practice.config") local popup = require("code-practice.popup") local results = {} @@ -43,7 +44,9 @@ function results.show(result, on_next) end end - if result.passed then + if result.correct_option then + push(result.passed and "✓ Correct!" or "✗ Incorrect") + elseif result.passed then push("✓ All tests passed!") else push("✗ Some tests failed") @@ -82,9 +85,11 @@ function results.show(result, on_next) push("No detailed results available.") end + local next_key = (config.get("keymaps.exercise") or {}).next_exercise or "" + push("") if on_next then - push("Press n for next exercise | q, , or to close") + push("Press " .. next_key .. " for next exercise | q, , or to close") else push("Press q, , or to close") end @@ -96,7 +101,7 @@ function results.show(result, on_next) popup.map_close(bufnr, results.close) if on_next then - vim.keymap.set("n", "n", on_next, { buffer = bufnr, silent = true, nowait = true }) + vim.keymap.set("n", next_key, on_next, { buffer = bufnr, silent = true, nowait = true }) end if result.passed then diff --git a/test/example_exercises.json b/test/example_exercises.json index 39ae18c..001c8e6 100644 --- a/test/example_exercises.json +++ b/test/example_exercises.json @@ -991,7 +991,7 @@ "Count total iterations of the innermost operation.", "Outer loop runs n times. Inner loop runs n/2 times per outer iteration." ], - "solution": "", + "solution": "The algorithm has an outer loop running n times and an inner loop running n/2 times per outer iteration, resulting in n × (n/2) = n²/2 total operations. Since constant factors are ignored in Big O notation, this simplifies to O(n²). Option 1 (O(n)) is too fast, as the nested loops cause quadratic growth. Option 2 (O(n log n)) would require the inner loop to scale logarithmically, which it does not. Option 4 (O(n³)) overestimates the complexity, as there are only two nested loops, not three.", "starter_code": "", "created_at": "2026-02-23 14:02:15", "updated_at": "2026-02-23 14:02:15", @@ -1034,7 +1034,7 @@ "Recall the hierarchy: constant < logarithmic < polynomial < exponential < factorial.", "Consider comparing logarithmic, linear, n log n, and exponential functions." ], - "solution": "", + "solution": "Option 4, O(2ⁿ), grows asymptotically the fastest because exponential functions like 2ⁿ dominate polynomial and logarithmic functions as n → ∞. O(log n) grows slower than any polynomial, O(n) is linear, and O(n log n) is slightly faster than linear but still polynomial—none of which can keep up with the rapid growth of an exponential function.", "starter_code": "", "created_at": "2026-02-23 14:02:15", "updated_at": "2026-02-23 14:02:15", @@ -1077,7 +1077,7 @@ "Consider the maximum depth of the recursion call stack.", "Each recursive call adds a frame to the stack until reaching the base cases." ], - "solution": "", + "solution": "The space complexity is O(n) because the recursion depth reaches n in the worst case (e.g., when computing fib(n), the call stack grows linearly as it goes down to fib(0)), and each recursive call adds a frame to the call stack. Although the time complexity is O(2ⁿ), space complexity depends on maximum stack depth, not total calls. O(1) is incorrect because space usage isn’t constant—it grows with input size. O(log n) would apply to some optimized or tail-recursive versions, but not the naive recursive version.", "starter_code": "", "created_at": "2026-02-23 14:02:15", "updated_at": "2026-02-23 14:02:15", @@ -1120,7 +1120,7 @@ "Although occasional pushes trigger resizing (O(n)), most are O(1).", "Use the accounting method or aggregate analysis to find average cost per operation." ], - "solution": "", + "solution": "The amortized time complexity of a single push operation in a dynamically resizing array-based stack is O(1) because, although occasional resizes take O(n) time when the array doubles, these expensive operations are rare and their cost is spread out over many cheap pushes using the accounting or aggregate method of amortized analysis. Option 2 (O(n)) is the worst-case time for a single push during a resize, not the amortized time. Option 3 (O(log n)) and Option 4 (O(n log n)) do not reflect the actual behavior of geometric resizing strategies like doubling.", "starter_code": "", "created_at": "2026-02-23 14:02:15", "updated_at": "2026-02-23 14:02:15", @@ -1163,7 +1163,7 @@ "Big-Theta requires both upper and lower bounds that match asymptotically.", "Ignore lower-order terms and constant coefficients." ], - "solution": "", + "solution": "The correct answer is Θ(n²) because in Big-Theta notation, we focus on the dominant term as n grows large, and 3n² dominates the lower-order terms 5n and 2. Option 1 (Θ(n)) is too small since n² grows faster than n; Option 3 (Θ(n³)) is too large because n² grows slower than n³; Option 4 (Θ(log n)) is even smaller and incorrect for a quadratic function.", "starter_code": "", "created_at": "2026-02-23 14:02:15", "updated_at": "2026-02-23 14:02:15", @@ -1205,7 +1205,7 @@ "Linear structures store elements in a sequential manner.", "Arrays and linked lists are examples of linear structures." ], - "solution": "", + "solution": "Stack is a linear data structure because its elements are arranged in a sequential order where insertion and deletion occur at only one end (top), following the LIFO principle. Binary Tree, Graph, and Heap are non-linear: Binary Trees and Graphs have hierarchical or network-like connections between nodes, while Heaps are a specialized tree-based structure (usually implemented as a complete binary tree).", "starter_code": "", "created_at": "2026-02-23 14:02:22", "updated_at": "2026-02-23 14:02:22", @@ -1247,7 +1247,7 @@ "Arrays store elements in contiguous memory locations.", "Index-based access does not require traversal." ], - "solution": "", + "solution": "Accessing an element by index in an array is O(1) because arrays store elements in contiguous memory locations, allowing direct calculation of the element's address using the base address and index (base + index × element_size), which takes constant time. Options O(n), O(log n), and O(n log n) describe complexities for operations like linear search, binary search, or sorting—not direct index-based access.", "starter_code": "", "created_at": "2026-02-23 14:02:22", "updated_at": "2026-02-23 14:02:22", @@ -1289,7 +1289,7 @@ "Think about the order in which elements are added and removed.", "It's the same principle as stacking plates." ], - "solution": "", + "solution": "Stacks follow the Last In, First Out (LIFO) principle, meaning the last element added is the first one removed. Option 1 (FIFO) describes queues, not stacks. Option 3 (PIPO) is not a standard data structure principle, and Option 4 (Random Access) applies to structures like arrays, where elements can be accessed directly by index.", "starter_code": "", "created_at": "2026-02-23 14:02:22", "updated_at": "2026-02-23 14:02:22", @@ -1331,7 +1331,7 @@ "One method stores multiple items in the same bucket.", "Another uses probing to find the next available slot." ], - "solution": "", + "solution": "Quadratic probing is a common open addressing technique for resolving hash collisions by probing positions in a quadratic sequence when a collision occurs. Merge sort, binary search, and depth-first search are unrelated algorithms—merge sort is a sorting algorithm, binary search is for searching sorted arrays, and depth-first search is a graph traversal method.", "starter_code": "", "created_at": "2026-02-23 14:02:22", "updated_at": "2026-02-23 14:02:22", @@ -1373,7 +1373,7 @@ "One part stores data.", "The other part points to the next element." ], - "solution": "", + "solution": "Option 3 is correct because a node in a singly linked list consists of two parts: the data (or value) it holds and a pointer to the next node in the sequence. Option 1 is incorrect because \"Parent Pointer\" is not used in singly linked lists (it appears in trees or doubly linked lists). Option 2 is misleading—while some implementations store key-value pairs, the fundamental structure is data and next pointer, and \"key\" isn't a required component. Option 4 is wrong because a previous pointer is characteristic of a doubly linked list, not a singly linked list.", "starter_code": "", "created_at": "2026-02-23 14:02:22", "updated_at": "2026-02-23 14:02:22", diff --git a/test/test_flow.lua b/test/test_flow.lua index 6bedf95..8b7155b 100644 --- a/test/test_flow.lua +++ b/test/test_flow.lua @@ -641,6 +641,163 @@ test("Engine registry: helpers return defaults for unknown engine", function() assert_eq(engines.get("nonexistent"), nil, "get returns nil") end) +-- 36. Theory UI: keymap selects correct answer, run_tests passes +test("Theory UI: keymap correct answer passes", function() + local db = require("code-practice.db") + local cp = require("code-practice.init") + local theory = db.get_all_exercises({ engine = "theory" }) + if #theory == 0 then + skip("no theory exercises in seed data") + end + + local ex_id = theory[1].id + local opts = db.get_theory_options(ex_id) + local correct_num + for _, o in ipairs(opts) do + if o.is_correct == 1 then + correct_num = o.option_number + break + end + end + if not correct_num then + skip("no correct option marked for theory exercise " .. ex_id) + end + + cp.open_exercise(ex_id) + vim.api.nvim_feedkeys(tostring(correct_num), "x", false) + + local bufnr = vim.api.nvim_get_current_buf() + local found_answer + for _, line in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) do + found_answer = line:match("^Answer:%s*(%d+)") + if found_answer then + break + end + end + assert_eq(found_answer, tostring(correct_num), "keymap should set answer in buffer") + + local done = false + local original_show = require("code-practice.results").show + local captured_result + require("code-practice.results").show = function(result, _) + captured_result = result + done = true + end + + cp.run_tests() + + vim.wait(5000, function() + return done + end, 50) + + require("code-practice.results").show = original_show + + assert_truthy(captured_result, "result nil") + assert_truthy(captured_result.passed, "correct theory answer via keymap should pass") +end) + +-- 37. Theory UI: keymap selects wrong answer, run_tests fails +test("Theory UI: keymap wrong answer fails", function() + local db = require("code-practice.db") + local cp = require("code-practice.init") + local theory = db.get_all_exercises({ engine = "theory" }) + if #theory == 0 then + skip("no theory exercises in seed data") + end + + local ex_id = theory[1].id + local opts = db.get_theory_options(ex_id) + local correct_num + for _, o in ipairs(opts) do + if o.is_correct == 1 then + correct_num = o.option_number + break + end + end + if not correct_num then + skip("no correct option marked for theory exercise " .. ex_id) + end + + local wrong = correct_num == 1 and 2 or 1 + + cp.open_exercise(ex_id) + vim.api.nvim_feedkeys(tostring(wrong), "x", false) + + local bufnr = vim.api.nvim_get_current_buf() + local found_answer + for _, line in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) do + found_answer = line:match("^Answer:%s*(%d+)") + if found_answer then + break + end + end + assert_eq(found_answer, tostring(wrong), "keymap should set wrong answer in buffer") + + local done = false + local original_show = require("code-practice.results").show + local captured_result + require("code-practice.results").show = function(result, _) + captured_result = result + done = true + end + + cp.run_tests() + + vim.wait(5000, function() + return done + end, 50) + + require("code-practice.results").show = original_show + + assert_truthy(captured_result, "result nil") + assert_truthy(not captured_result.passed, "wrong theory answer via keymap should fail") +end) + +-- 38. Importer: theory options have correct is_correct values after import +test("Importer: only correct theory option has is_correct=1", function() + local importer = require("code-practice.importer") + local db_mod = require("code-practice.db") + + local plugin_root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h") + local fixture = plugin_root .. "/test/example_exercises.json" + + local counts, err = importer.import(fixture, { replace = true }) + assert_truthy(counts, "import returned nil: " .. tostring(err)) + + local conn = db_mod.connect() + local rows = conn:eval("SELECT exercise_id, COUNT(*) as cnt FROM theory_options WHERE is_correct = 1 GROUP BY exercise_id") + if not rows or (not rows[1] and next(rows) == nil) then + return + end + if rows[1] == nil and next(rows) ~= nil then + rows = { rows } + end + for _, row in ipairs(rows) do + assert_eq(row.cnt, 1, "exercise " .. row.exercise_id .. " should have exactly 1 correct option, got " .. row.cnt) + end +end) + +-- 39. Reopening an unloaded exercise buffer repopulates content +test("Reopen unloaded exercise: buffer content is restored", function() + local cp = require("code-practice.init") + + local buf1 = cp.open_exercise(1) + assert_truthy(buf1, "open exercise 1") + + local lines_before = vim.api.nvim_buf_get_lines(buf1, 0, -1, false) + assert_truthy(table.concat(lines_before, "\n"):find("Exercise:"), "buffer should have content") + + cp.open_exercise(2) + vim.cmd("bunload " .. buf1) + assert_truthy(not vim.api.nvim_buf_is_loaded(buf1), "buffer should be unloaded after bunload") + + local buf1_again = cp.open_exercise(1) + assert_truthy(buf1_again, "reopen exercise 1") + local lines_after = vim.api.nvim_buf_get_lines(buf1_again, 0, -1, false) + local content = table.concat(lines_after, "\n") + assert_truthy(content:find("Exercise:"), "unloaded buffer should be repopulated with content") +end) + -- Summary io.write("\n" .. string.rep("=", 44) .. "\n") io.write(string.format(" Results: %d passed, %d failed, %d skipped\n", passed, failed, skipped))