diff --git a/tests/integration/selection_held_spec.lua b/tests/integration/selection_held_spec.lua index 80da4b9a7..f1118463d 100644 --- a/tests/integration/selection_held_spec.lua +++ b/tests/integration/selection_held_spec.lua @@ -22,13 +22,40 @@ describe("a held selection with the real plugin", function() vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), "xt", false) end - -- Lets the real timers run: the debounce is 100 ms. + -- Lets the real timers run for at least ms. Use it only where the time is the point of the step. local function wait(ms) vim.wait(ms, function() return false end, 10) end + -- Waits until the plugin has finished what it started: no debounce timer is pending, no demotion timer is pending + -- (unless keep_demotion), and every vim.schedule callback that was queued before the call has run (the check after + -- an edit is one). A fixed wait() cannot say that. When the main loop stalls for 300 ms under load, vim.wait returns + -- in the iteration that fires the timers, before their vim.schedule_wrap callbacks run, and a debounce that fires + -- late arms a demotion that is still pending. The step after the wait then reads a plugin that is not done: the + -- debounce has not read the cursor, the demotion has not fired, or it fires after the step has renamed the buffer. + local function settle(keep_demotion) + local drained = false + vim.schedule(function() + drained = true + end) + local done = vim.wait(10000, function() + return drained + and selection.state.debounce_timer == nil + and (keep_demotion or selection.state.demotion_timer == nil) + end, 10) + assert.is_true(done, "the plugin still has a timer pending") + end + + -- For a spec that expects nothing to be sent: settle() ends as soon as no timer is pending, so a send that a later + -- timer makes would pass. Wait 300 ms for it (the old fixed waits gave it that), then settle() again: a timer that + -- fires in the last iteration of the wait has not run its callback yet. + local function wait_for_late_send() + wait(300) + settle() + end + local function start(terminal_config, delay_ms) claudecode.setup({ auto_start = false, @@ -67,12 +94,17 @@ describe("a held selection with the real plugin", function() vim.cmd("edit " .. vim.fn.fnameescape(path)) end - -- Select alpha (the first word) and release it with Esc: Claude holds it. - local function select_and_release() + -- Select alpha (the first word) and release it with Esc: Claude holds it. The plugin reads the selection when the + -- debounce fires, and arms the demotion when it reads the release. keep_demotion leaves that demotion pending, for a + -- spec that needs it pending (a visual_demotion_delay_ms of 1000). + local function select_and_release(keep_demotion) press("ve") - wait(150) + settle(true) press("") - wait(300) + settle(keep_demotion) + if keep_demotion then + assert.is_not_nil(selection.state.demotion_timer, "the demotion is not pending") + end assert.are.equal("alpha", selection.get_latest_selection().text) end @@ -102,13 +134,13 @@ describe("a held selection with the real plugin", function() it("keeps the selection when the Claude terminal first opens (delay " .. delay_ms .. ")", function() start({ provider = "native", show_native_term_exit_tip = false }, delay_ms) open_file("alpha beta gamma") - select_and_release() + select_and_release(delay_ms > 500) -- a demotion that late is still pending when the terminal opens require("claudecode.terminal").open() vim.cmd("stopinsert") - wait(300) + settle() vim.cmd("wincmd p") - wait(600 + delay_ms) -- the debounce, then the demotion + settle() -- the debounce, then the demotion assert.are.equal("alpha", selection.get_latest_selection().text) end) @@ -122,14 +154,14 @@ describe("a held selection with the real plugin", function() local term_buf = vim.api.nvim_get_current_buf() vim.cmd("stopinsert") vim.cmd("buffer " .. file_buf) - wait(200) + settle() select_and_release() vim.cmd("buffer " .. term_buf) vim.cmd("stopinsert") - wait(300) + settle() vim.cmd("buffer #") - wait(600) + settle() assert.are.equal(file_buf, vim.api.nvim_get_current_buf()) assert.are.equal("alpha", selection.get_latest_selection().text) @@ -143,12 +175,12 @@ describe("a held selection with the real plugin", function() local term_buf = vim.api.nvim_get_current_buf() vim.cmd("stopinsert") vim.cmd("buffer " .. file_buf) - wait(200) + settle() select_and_release() -- Claude holds "alpha" press("$vd") -- a second selection, on the last character, consumed before the debounce vim.cmd("buffer " .. term_buf) -- and the Claude terminal, where the debounce does nothing - wait(300) + settle() local held = selection.get_latest_selection() assert.is_true(held.selection.isEmpty) @@ -180,14 +212,14 @@ describe("a held selection with the real plugin", function() local term_buf = vim.api.nvim_get_current_buf() vim.cmd("stopinsert") vim.cmd("buffer " .. file_buf) - wait(200) + settle() select_and_release() vim.cmd("buffer " .. term_buf) vim.cmd("stopinsert") - wait(300) + settle() vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " - wait(300) + settle() assert.are.equal(term_buf, vim.api.nvim_get_current_buf()) local held = selection.get_latest_selection() @@ -205,14 +237,14 @@ describe("a held selection with the real plugin", function() local term_win = vim.api.nvim_get_current_win() vim.cmd("stopinsert") vim.api.nvim_set_current_win(file_win) - wait(200) + settle() select_and_release() vim.api.nvim_set_current_win(term_win) vim.cmd("stopinsert") - wait(300) + settle() rewrite_on_disk_and_reload(file_buf, "beta gamma") - wait(300) + settle() assert.are.same({ "beta gamma" }, vim.api.nvim_buf_get_lines(file_buf, 0, -1, false)) assert.are.equal(term_win, vim.api.nvim_get_current_win()) @@ -232,24 +264,24 @@ describe("a held selection with the real plugin", function() local term_buf = vim.api.nvim_get_current_buf() vim.cmd("stopinsert") vim.cmd("buffer " .. file_buf) - wait(200) + settle() select_and_release() vim.cmd("buffer " .. term_buf) vim.cmd("stopinsert") - wait(300) + settle() vim.cmd("bunload! " .. file_buf) - wait(200) + settle() vim.cmd("buffer " .. file_buf) vim.api.nvim_win_set_cursor(0, { 1, 0 }) -- Neovim put the cursor back where it was - wait(200) + settle() select_and_release() vim.cmd("buffer " .. term_buf) vim.cmd("stopinsert") - wait(300) + settle() vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " - wait(300) + settle() assert.are.equal(term_buf, vim.api.nvim_get_current_buf()) assert.is_true(selection.get_latest_selection().selection.isEmpty) @@ -268,7 +300,8 @@ describe("a held selection with the real plugin", function() vim.v.errmsg = "" vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " - wait(300) + settle() + wait_for_late_send() assert.are.equal("", vim.v.errmsg) assert.are.same({}, sent) -- no selection and no cursor of the terminal reached Claude @@ -283,13 +316,14 @@ describe("a held selection with the real plugin", function() start({ provider = "none" }, 1000) open_file("alpha beta gamma") local file_buf = vim.api.nvim_get_current_buf() - select_and_release() + select_and_release(true) local sent = record_broadcasts() rename_to_claude_terminal(file_buf) vim.v.errmsg = "" vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " - wait(1300) -- the demotion delay passes + settle() -- a demotion that the check did not cancel fires here + wait_for_late_send() assert.are.equal("", vim.v.errmsg) assert.are.same({}, sent) @@ -306,16 +340,17 @@ describe("a held selection with the real plugin", function() local term_buf = vim.api.nvim_get_current_buf() vim.cmd("stopinsert") vim.cmd("buffer " .. file_buf) - wait(200) + settle() select_and_release() vim.cmd("buffer " .. term_buf) vim.cmd("stopinsert") - wait(300) + settle() local sent = record_broadcasts() rename_to_claude_terminal(file_buf) vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " - wait(300) + settle() + wait_for_late_send() assert.are.equal(term_buf, vim.api.nvim_get_current_buf()) assert.are.same({}, sent) @@ -341,12 +376,12 @@ describe("a held selection with the real plugin", function() vim.api.nvim_win_set_cursor(0, { 1, 0 }) select_and_release() vim.cmd("enew") -- another file: the debounce replaces the selection with that buffer's cursor - wait(300) + settle() assert.is_true(selection.get_latest_selection().selection.isEmpty) rewrite_on_disk_and_reload(file_buf, "alpha beta gamma, cycle " .. cycle) -- on_reload, nothing held - wait(200) + settle() vim.cmd("buffer " .. file_buf) - wait(200) + settle() end end) vim.api.nvim_buf_attach = real_attach @@ -364,21 +399,21 @@ describe("a held selection with the real plugin", function() local term_win = vim.api.nvim_get_current_win() vim.cmd("stopinsert") vim.api.nvim_set_current_win(file_win) - wait(200) + settle() select_and_release() vim.api.nvim_set_current_win(term_win) vim.cmd("stopinsert") - wait(300) + settle() rewrite_on_disk_and_reload(file_buf, "alpha beta gamma, and more") - wait(300) + settle() assert.are.same({ "alpha beta gamma, and more" }, vim.api.nvim_buf_get_lines(file_buf, 0, -1, false)) assert.are.equal("alpha", selection.get_latest_selection().text) -- The listener survives the reload: the next change is seen too. vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) - wait(300) + settle() assert.is_true(selection.get_latest_selection().selection.isEmpty) end) end) @@ -407,7 +442,7 @@ describe("a held selection with the real plugin", function() vim.api.nvim_buf_delete(scratch, { force = true }) end end) - wait(400) + settle() assert.is_false(vim.api.nvim_buf_is_valid(scratch)) assert.are.equal(file_buf, vim.api.nvim_get_current_buf()) diff --git a/tests/integration/selection_visual_edit_spec.lua b/tests/integration/selection_visual_edit_spec.lua index 9ed24cbb0..0638d43ad 100644 --- a/tests/integration/selection_visual_edit_spec.lua +++ b/tests/integration/selection_visual_edit_spec.lua @@ -29,16 +29,29 @@ describe("a visual selection released after the buffer changed, with the real pl end, 5) end + -- Runs every vim.schedule callback that is queued now. A timer that fires in the last iteration of a wait has not run + -- its vim.schedule_wrap callback when the wait returns. + local function drain() + local drained = false + vim.schedule(function() + drained = true + end) + vim.wait(5000, function() + return drained + end, 5) + end + -- After an operator the plugin reports the cursor once its flush has run, and a busy host can delay that past any fixed -- wait (get_latest_selection() is nil until then). So wait until it holds a cursor, then let the debounce and the - -- demotion run, so that a selection sent late would show. + -- demotion run for 400 ms and drain the callbacks, so that a selection sent late would show. local function wait_for_cursor() local held = vim.wait(5000, function() local latest = selection.get_latest_selection() return latest ~= nil and latest.selection.isEmpty end, 5) assert.is_true(held, "the plugin holds a cursor after the operator") - wait(200) + wait(400) + drain() end local function start() @@ -276,6 +289,31 @@ describe("a visual selection released after the buffer changed, with the real pl assert.is_true(selection.get_latest_selection().selection.isEmpty) assert_last_sent_is_a_cursor() end) + + -- The two specs above press the operator within the 100 ms debounce, so usually nothing was sent in visual mode and + -- their checks of an early selection (assert_is_the_selection, and a cursor as the last message) have nothing to + -- look at. Here the debounce has sent the selection before d, on every run. + it("sends a cursor for d after the debounce sent the selection in visual mode", function() + start() + open_file({ "alpha beta gamma", "second line" }) + wait(50) + + press("ve") + local sent_in_visual_mode = vim.wait(5000, function() + return held_text() == "alpha" + end, 5) + assert.is_true(sent_in_visual_mode, "the debounce sent the selection in visual mode") + local before_operator = #sent + assert.is_true(before_operator >= 1) + press("d") + wait_for_cursor() + + assert.are.equal(" beta gamma", vim.api.nvim_buf_get_lines(0, 0, 1, false)[1]) + assert.are.same({ true }, { sent_no_selection(before_operator) }) + assert_is_the_selection(before_operator) + assert.is_true(selection.get_latest_selection().selection.isEmpty) + assert_last_sent_is_a_cursor() + end) end) -- A mapping runs its keys with no main-loop turn between them, so the check that the listener schedules for an edit