From 6c15527031c2af29e89210bff5f30a4a1bb9250c Mon Sep 17 00:00:00 2001
From: OGAWA Keiji <70509917+kibi2@users.noreply.github.com>
Date: Fri, 2 Oct 2026 15:02:29 +0900
Subject: [PATCH 01/25] test: add ci test environment
---
.luacov | 15 +
README.md | 2 +-
lua/rush/config.lua | 24 -
lua/rush/init.lua | 11 +-
lua/rush/log.lua | 469 ------------------
lua/rush/notify.lua | 51 --
tests/.gitignore | 18 +
tests/backend/nvim.sh | 38 ++
tests/cases/basic/out-expected.txt | 86 ++++
tests/cases/basic/run.vim | 18 +
tests/cases/basic/run1.lua | 27 +
tests/cases/basic/run2.lua | 21 +
tests/cases/basic/run3.lua | 21 +
tests/cases/basic/run4.lua | 41 ++
tests/cases/check/format/out-expected.txt | 0
tests/cases/check/format/run.sh | 4 +
tests/cases/check/format/skip-ci | 0
tests/cases/check/rg_ng_char/out-expected.txt | 0
tests/cases/check/rg_ng_char/run.sh | 7 +
tests/cases/check/rg_ng_char/skip-ci | 0
tests/cases/check/rg_ng_char/sort | 0
tests/cases/check/rg_probe/out-expected.txt | 0
tests/cases/check/rg_probe/run.sh | 4 +
tests/common.vim | 138 ++++++
tests/common_key.lua | 51 ++
tests/coverage.sh | 24 +
tests/data/sample.md | 316 ++++++++++++
tests/data/simple.md | 9 +
tests/format.sh | 9 +
tests/golden_test.sh | 218 ++++++++
tests/run.sh | 5 +
31 files changed, 1077 insertions(+), 550 deletions(-)
create mode 100644 .luacov
delete mode 100644 lua/rush/config.lua
delete mode 100644 lua/rush/log.lua
delete mode 100644 lua/rush/notify.lua
create mode 100644 tests/.gitignore
create mode 100644 tests/backend/nvim.sh
create mode 100644 tests/cases/basic/out-expected.txt
create mode 100644 tests/cases/basic/run.vim
create mode 100644 tests/cases/basic/run1.lua
create mode 100644 tests/cases/basic/run2.lua
create mode 100644 tests/cases/basic/run3.lua
create mode 100644 tests/cases/basic/run4.lua
create mode 100644 tests/cases/check/format/out-expected.txt
create mode 100644 tests/cases/check/format/run.sh
create mode 100644 tests/cases/check/format/skip-ci
create mode 100644 tests/cases/check/rg_ng_char/out-expected.txt
create mode 100644 tests/cases/check/rg_ng_char/run.sh
create mode 100644 tests/cases/check/rg_ng_char/skip-ci
create mode 100644 tests/cases/check/rg_ng_char/sort
create mode 100644 tests/cases/check/rg_probe/out-expected.txt
create mode 100644 tests/cases/check/rg_probe/run.sh
create mode 100644 tests/common.vim
create mode 100644 tests/common_key.lua
create mode 100755 tests/coverage.sh
create mode 100644 tests/data/sample.md
create mode 100644 tests/data/simple.md
create mode 100755 tests/format.sh
create mode 100755 tests/golden_test.sh
create mode 100755 tests/run.sh
diff --git a/.luacov b/.luacov
new file mode 100644
index 0000000..22cfe71
--- /dev/null
+++ b/.luacov
@@ -0,0 +1,15 @@
+return {
+ statsfile = "luacov.stats.out",
+ reportfile = "luacov.report.out",
+
+ include = {
+ "lua/.*",
+ },
+
+ exclude = {
+ ".*/nvim/runtime/.*",
+ ".*/.luarocks/.*",
+ ".*/site/pack/.*",
+ ".*/tests/cases/.*",
+ },
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 9aa9bac..5bb737d 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# rush.nvim
-**Time-based key mappings for Neovim.**
+**Control Vim motions with key repeats and holds.**
`rush.nvim` changes the amount of motion based on how you repeat and hold a key.
diff --git a/lua/rush/config.lua b/lua/rush/config.lua
deleted file mode 100644
index 5ab630e..0000000
--- a/lua/rush/config.lua
+++ /dev/null
@@ -1,24 +0,0 @@
-local M = {}
-
-local default_config = {
- log = {
- level = vim.log.levels.ERROR,
- output = "file", -- "buffer", "file", "print", "notify"
- buffer_name = "rush://log",
- file_name = "/tmp/rush.log",
- use_timestamp = false,
- single_line = true,
- probe = true,
- monitor = false,
- },
-}
-
-function M.setup(opts)
- local config =
- vim.tbl_deep_extend("force", vim.deepcopy(default_config), opts or {})
- for key, value in pairs(config) do
- M[key] = value
- end
-end
-
-return M
diff --git a/lua/rush/init.lua b/lua/rush/init.lua
index 43b68c7..6e34141 100644
--- a/lua/rush/init.lua
+++ b/lua/rush/init.lua
@@ -1,7 +1,7 @@
-local config = require("rush.config")
+local config = require("keyevent.config")
local KeyEvent = require("keyevent.keyevent")
local bitflag = require("keyevent.bitflag")
-local log = require("rush.log")
+local log = require("keyevent.log")
local M = {}
@@ -25,6 +25,7 @@ local key_sets = {
b = "w",
e = "ge",
}
+
local accelerate = {
forward = META.C,
backward = META.A,
@@ -65,7 +66,8 @@ local function get_new_motion(event)
end
local function debug_event(event)
- log.debug(
+ log.watch(
+ "RSH",
"%7s %3s %s",
state,
get_new_motion(event),
@@ -252,8 +254,7 @@ function M.key_process(motion)
return get_new_motion(event)
end
-function M.setup(opts)
- config.setup(opts or {})
+function M.setup()
for motion, _ in pairs(key_sets) do
vim.keymap.set(KEYMAP_MODE, motion, function()
return M.key_process(motion)
diff --git a/lua/rush/log.lua b/lua/rush/log.lua
deleted file mode 100644
index a29231a..0000000
--- a/lua/rush/log.lua
+++ /dev/null
@@ -1,469 +0,0 @@
-local api = vim.api -- Neovim
-local bo = vim.bo
-local fn = vim.fn
-
-local config = require("rush.config")
-local notify = require("rush./notify")
-
--- =============================================================================
-
-local M = {}
-
-local levels = vim.log.levels
-
-local level_names = {}
-local PREFIX = "TIR"
-local uv = vim.loop
-local queue = {}
-local scheduled = false
-local log_bufnr = nil
-
-for name, value in pairs(levels) do
- level_names[value] = name
-end
-
-local last_tick = {}
-local last_mem = 0
-local last_time = uv.hrtime()
-local monitoring = false
-
--- =============================================================================
---#region Private
-
----@return integer
-local function get_tick()
- local bufnr = api.nvim_get_current_buf()
- return api.nvim_buf_get_changedtick(bufnr)
-end
-
----@return integer
-local function get_delta_tick()
- local bufnr = api.nvim_get_current_buf()
- local curr_tick = api.nvim_buf_get_changedtick(bufnr)
- local prev_tick = last_tick[bufnr]
- last_tick[bufnr] = curr_tick
- if not prev_tick then
- return 0
- end
- -- local delta_tick = curr_tick - prev_tick
- -- if delta_tick > 1 then
- -- M.probe(
- -- "TICK [%d] %d - %d = %d",
- -- bufnr,
- -- curr_tick,
- -- prev_tick,
- -- delta_tick
- -- )
- -- end
- return curr_tick - prev_tick
-end
-
----@return integer
-local function get_mem_mb()
- return collectgarbage("count") / 1024
-end
-
----@return string
-local function get_monitor()
- if not config.log.monitor then
- return ""
- end
- local current_mem = get_mem_mb()
- local delta_mem = current_mem - last_mem
- return string.format("[%d:%d(+%d)MB]", get_tick(), current_mem, delta_mem)
-end
-
-local function monitor()
- if monitoring then
- return
- end
- monitoring = true
-
- local mem = get_mem_mb()
- local now = uv.hrtime()
-
- local delta_tick = get_delta_tick()
- if now - last_time < 1000 * 1e6 then
- if delta_tick > 100 then
- M.error("changedtick runaway detected")
- end
- end
-
- if mem > 2000 then
- M.error("memory runaway: " .. mem .. "MB")
- end
-
- last_mem = mem
- last_time = now
-
- monitoring = false
-end
-
----@param value any
----@return any
-local function normalize_for_log(value)
- local v_type = type(value)
- if v_type ~= "table" then
- return value
- end
- local mt = getmetatable(value)
- if mt and mt.__tostring then
- return tostring(value)
- end
- local result = {}
- for key, val in pairs(value) do
- result[key] = normalize_for_log(val)
- end
- return result
-end
-
----@param value any
----@return string
-local function stringify(value)
- local v_type = type(value)
-
- if v_type == "table" then
- local normalized = normalize_for_log(value)
- if config.log.single_line then
- return string.format(
- "
%s",
- vim.inspect(normalized, {
- newline = " ",
- indent = "",
- depth = 4,
- })
- )
- else
- return string.format("\n%s", vim.inspect(value))
- end
- elseif v_type == "string" then
- return string.format(" %s", value)
- else
- return string.format("<%s> %s", v_type, tostring(value))
- end
-end
-
----@return string
-local function get_timestamp()
- if not config.log.use_timestamp then
- return ""
- end
- local now = uv.hrtime()
- if not last_time then
- last_time = now
- ---@type string
- return os.date("[%H:%M:%S]")
- end
- local delta_ms = (now - last_time) / 1e6
- last_time = now
- return string.format("[+%.0fms]", delta_ms)
-end
-
-local function get_bufnr_by_name(name)
- for _, bufnr in ipairs(api.nvim_list_bufs()) do
- if api.nvim_buf_get_name(bufnr) == name then
- return bufnr
- end
- end
- return nil
-end
-
-local category_hl_map = {}
-local category_match_id = {}
-api.nvim_set_hl(0, "TirLog_Entry", { fg = "#55ffff", bold = true })
-api.nvim_set_hl(
- 0,
- "TirLog_Error",
- { fg = "#ffffff", bg = "#ff0000", bold = true }
-)
-api.nvim_set_hl(0, "TirLog_num", { fg = "#cc55cc", bold = true })
-
----@param bufnr number
-local function apply_log_highlight(bufnr)
- local winid = fn.bufwinid(bufnr)
- if winid == -1 then
- return
- end
- api.nvim_win_call(winid, function()
- fn.clearmatches()
- fn.matchadd("TirLog_Entry", "===")
- --fn.matchadd("TirLog_Error", [[\[[0-9,]\+\]]])
- fn.matchadd("TirLog_num", "\\[[0-9,]\\+\\]")
- fn.matchadd("TirLog_Error", "\\[ERROR\\]")
- for cat, hl in pairs(category_hl_map) do
- fn.matchadd(hl, "\\[" .. cat .. "\\]")
- end
- end)
-end
-
-local aug = api.nvim_create_augroup("TirenviLogHL", { clear = true })
----@param bufnr number
-local function register_autocmds(bufnr)
- api.nvim_create_autocmd("BufWinEnter", {
- group = aug,
- buffer = bufnr,
- callback = function(args)
- --if args.buf == log_bufnr then
- apply_log_highlight(args.buf)
- --end
- end,
- })
-end
-
----@return number
-local function ensure_log_buf()
- if log_bufnr and api.nvim_buf_is_valid(log_bufnr) then
- return log_bufnr
- end
- log_bufnr = get_bufnr_by_name(config.log.buffer_name)
- if log_bufnr then
- return log_bufnr
- end
- ---@type number
- log_bufnr = api.nvim_create_buf(false, true)
- api.nvim_buf_set_name(log_bufnr, config.log.buffer_name)
-
- bo[log_bufnr].buftype = "nofile"
- bo[log_bufnr].bufhidden = "hide"
- bo[log_bufnr].swapfile = false
- register_autocmds(log_bufnr)
- return log_bufnr
-end
-
-local function flush_buffer(buf_string)
- local bufnr = ensure_log_buf()
- api.nvim_buf_set_lines(bufnr, -1, -1, false, vim.split(buf_string, "\n"))
- local line_count = api.nvim_buf_line_count(bufnr)
- local win = fn.bufwinid(bufnr)
- if win ~= -1 then
- api.nvim_win_set_cursor(win, { line_count, 0 })
- api.nvim_win_call(win, function()
- -- vim.cmd("normal! 3kzz")
- end)
- end
-end
-
-local initialized = false
-local function flush_file(buf_string)
- local file = config.log.file_name or "/tmp/tirenvi.log"
- if not initialized then
- local fds = io.open(file, "w")
- if fds then
- fds:close()
- else
- error("tirenvi: failed to open log file: " .. file)
- end
- initialized = true
- end
- local fds = io.open(file, "a")
- if fds then
- fds:write(buf_string .. "\n")
- fds:close()
- end
-end
-
-local function flush()
- scheduled = false
- if #queue == 0 then
- return
- end
- local buf_string = table.concat(queue, "\n")
- buf_string = buf_string:gsub("\r", "")
- if config.log.output == "buffer" then
- flush_buffer(buf_string)
- elseif config.log.output == "file" then
- flush_file(buf_string)
- end
- queue = {}
-end
-
----@param msg string
-local function write_buffer(msg)
- table.insert(queue, msg)
- if not scheduled then
- scheduled = true
- vim.schedule(flush)
- end
-end
-
-local unpack = table.unpack or unpack -- Lua 5.1/5.2 compatibility
-local palette = {
- "#ff5555",
- "#55aaaa",
- "#ffff55",
- "#aaff55",
- "#aa55ff",
- "#ffaa55",
-}
-
-local assigned = {}
-local used = {}
-
-local function pick_color(cat)
- if assigned[cat] then
- return assigned[cat]
- end
- local hash = fn.sha256(cat)
- local base = (fn.str2nr(hash:sub(1, 8), 16) % #palette) + 1
- for i = 0, #palette - 1 do
- local idx = ((base + i - 1) % #palette) + 1
- local color = palette[idx]
- if not used[color] then
- assigned[cat] = color
- used[color] = true
- return color
- end
- end
- local color = palette[base]
- assigned[cat] = color
- return color
-end
-
-local function ensure_category_highlight(category)
- if category_hl_map[category] then
- return category_hl_map[category]
- end
-
- local hl = "TirLog_" .. category
- local color = pick_color(category)
-
- api.nvim_set_hl(0, hl, { fg = color, bold = true })
-
- category_hl_map[category] = hl
- return hl
-end
-
-local function ensure_match(bufnr, category, hl)
- if category_match_id[category] then
- return
- end
-
- api.nvim_buf_call(bufnr, function()
- local pattern = "\\[" .. category .. "\\]"
- local id = fn.matchadd(hl, pattern)
- category_match_id[category] = id
- end)
-end
-
----@param force boolean
----@param level integer
----@param opts? table
----@param fmt any
----@param ... unknown
-local function emit(force, level, opts, fmt, ...)
- monitor()
- if not force and level < config.log.level then
- return
- end
- if opts == nil or fmt == nil then
- return
- end
- local category = opts and opts.category
- if category then
- local bufnr = ensure_log_buf()
- local hl = ensure_category_highlight(category)
- ensure_match(bufnr, category, hl)
- end
- local info = debug.getinfo(3, "Sl")
- local file = info and (info.short_src:match("([^/\\]+)$")) or "?"
- local line = info and info.currentline or 0
- local args = { ... }
- local msg
- local ok, result = pcall(function()
- return string.format(fmt, unpack(args))
- end)
- if ok then
- msg = result
- else
- local args = { fmt, ... }
- local parts = vim.tbl_map(stringify, args)
- msg = table.concat(parts, " ")
- end
- local ts = get_timestamp()
- local mon = get_monitor()
- local name = level_names[level]
- if force then
- name = "🟧PRB"
- elseif category then
- name = category
- end
- local final = string.format(
- "[%s]%s%s[%s][%s %d] %s",
- PREFIX,
- ts,
- mon,
- name,
- file,
- line,
- msg
- )
- if config.log.output == "buffer" then
- write_buffer(final)
- elseif config.log.output == "file" then
- write_buffer(final)
- elseif config.log.output == "print" then
- print(final)
- else
- notify.notify(final, level)
- end
-end
-
---#endregion
--- =============================================================================
--- Public API
-
----@param ... unknown
-function M.debug(...)
- emit(false, levels.DEBUG, {}, ...)
-end
-
----@param ... unknown
-function M.info(...)
- emit(false, levels.INFO, {}, ...)
-end
-
----@param ... unknown
-function M.warn(...)
- emit(false, levels.WARN, {}, ...)
-end
-
----@param ... unknown
-function M.error(...)
- emit(false, levels.ERROR, {}, ...)
-end
-
----@param ... unknown
-function M.probe(...)
- if not config.log.probe then
- return
- end
- emit(true, levels.ERROR, {}, ...)
-end
-
----@param category string
----@param ... unknown
-function M.watch(category, ...)
- if levels.DEBUG < config.log.level then
- return
- end
- emit(false, levels.DEBUG, { category = category }, ...)
-end
-
----@return boolean
-function M.is_debug()
- return levels.DEBUG >= config.log.level
-end
-
----@param condition any
----@param message string
----@param ... unknown
-function M.assert(condition, message, ...)
- if M.is_debug() then
- assert(condition, message, ...)
- end
- if not condition then
- M.error(message, ...)
- end
-end
-
-return M
diff --git a/lua/rush/notify.lua b/lua/rush/notify.lua
deleted file mode 100644
index 6e02b85..0000000
--- a/lua/rush/notify.lua
+++ /dev/null
@@ -1,51 +0,0 @@
--- =============================================================================
-
-local M = {}
-
--- =============================================================================
---#region Private
-
----@param msg string
----@param level integer
-local function emit(msg, level)
- if msg == nil then
- return
- end
- -- In test mode, avoid scheduling for deterministic behavior.
- if vim.g.tirenvi_test_mode then
- if msg ~= "" then
- print(msg)
- end
- else
- vim.schedule(function()
- vim.notify(msg, level)
- end)
- end
-end
-
---#endregion
--- =============================================================================
--- Public API
-
----@param msg string
-function M.error(msg)
- emit(msg, vim.log.levels.ERROR)
-end
-
----@param msg string
-function M.warn(msg)
- emit(msg, vim.log.levels.WARN)
-end
-
----@param msg string
-function M.info(msg)
- emit(msg, vim.log.levels.INFO)
-end
-
----@param msg string
----@param level integer
-function M.notify(msg, level)
- emit(msg, level)
-end
-
-return M
diff --git a/tests/.gitignore b/tests/.gitignore
new file mode 100644
index 0000000..b584f8c
--- /dev/null
+++ b/tests/.gitignore
@@ -0,0 +1,18 @@
+# --- Generated by test runner ---
+!data/
+
+# Actual output (never commit)
+out-actual.txt
+gen*
+diff-*
+work
+
+# Captured logs
+stdout.txt
+stderr.txt
+
+# Temporary files
+*.tmp
+*.swp
+*.swo
+*~
diff --git a/tests/backend/nvim.sh b/tests/backend/nvim.sh
new file mode 100644
index 0000000..8be9fb9
--- /dev/null
+++ b/tests/backend/nvim.sh
@@ -0,0 +1,38 @@
+TEST_BIN="${TEST_BIN:-nvim}"
+
+backend_setup() {
+ echo "Running with: $TEST_BIN"
+ printf "${BOLD}${GREEN}%s${RESET}\n" "$($TEST_BIN --version | head -n 1)"
+ STATS="luacov.stats.out"
+ eval "$(luarocks path)"
+ : > "$ROOT_DIR/$STATS"
+}
+
+backend_case_setup() {
+ if [ ! -e "./$STATS" ]; then
+ ln -s "$ROOT_DIR/$STATS" "./$STATS"
+ fi
+}
+
+backend_case_cleanup() {
+ :
+}
+
+backend_runner() {
+ echo "run.vim"
+}
+
+backend_run() {
+ if [ -f run.sh ]; then
+ TEST_BIN=$TEST_BIN sh run.sh > stdout.txt 2> stderr.txt
+ else
+ [ -f run.env ] && . run.env
+ env \
+ NVIM_APPNAME="${NVIM_APPNAME:-}" \
+ NVIM_TIRENVI_DEV=1 \
+ $TEST_BIN --headless -u NONE -n \
+ -c "source run.vim" \
+ -c "qa!" \
+ >> stdout.txt 2>> stderr.txt
+ fi
+}
\ No newline at end of file
diff --git a/tests/cases/basic/out-expected.txt b/tests/cases/basic/out-expected.txt
new file mode 100644
index 0000000..f5c7f96
--- /dev/null
+++ b/tests/cases/basic/out-expected.txt
@@ -0,0 +1,86 @@
+--- basic ---
+=== MESSAGE ===
+
+--- CASE 6: : tan tan ta ta tan ---
+PLC src type (t h r) key (itv h) seq
+onk onk click (1 0 0) j (900 0) j
+onk map repeat (1 1 1) j (500 0) j↻
+MAP map repeat (1 1 1) j (500 0) j↻
+onk onk repeat (1 1 2) j (150 0) j↻
+onk onk repeat (1 1 3) j (150 0) j↻
+onk onk repeat (1 1 4) j (150 0) j↻
+onk onk tap (1 1 0) j (300 0) j↻j
+onk onk repeat (1 2 1) j (500 0) j↻j↻
+onk onk repeat (1 2 2) j (150 0) j↻j↻
+onk onk tap (1 2 0) j (300 0) j↻j↻j
+onk onk tap (2 2 0) j (300 0) j↻j↻jj
+onk onk tap (3 2 0) j (300 0) j↻j↻jjj
+onk onk repeat (3 3 1) j (500 0) j↻j↻jjj↻
+onk onk repeat (3 3 2) j (150 0) j↻j↻jjj↻
+onk onk repeat (3 3 3) j (150 0) j↻j↻jjj↻
+onk onk repeat (3 3 4) j (150 0) j↻j↻jjj↻
+onk onk repeat (3 3 5) j (150 1) j↻j↻jjj↻
+onk onk repeat (3 3 6) j (150 1) j↻j↻jjj↻
+onk onk repeat (3 3 7) j (150 1) j↻j↻jjj↻
+hold_time = 500 + 150 * 6 = 1400
+
+--- CASE 9: : tan tan ta ta tan (meta) ---
+PLC src type (t h r) key (itv h) seq
+onk onk click (1 0 0) j (900 0) j
+onk map repeat (1 1 1) M-j (520 0) j↻
+MAP map repeat (1 1 1) M-j (520 0) j↻
+onk onk repeat (1 1 2) S-j (170 0) j↻
+onk onk repeat (1 1 3) C-j (130 0) j↻
+onk onk repeat (1 1 4) A-j (150 0) j↻
+onk onk tap (1 1 0) D-j (280 0) j↻
+onk onk repeat (1 2 1) D-j (500 0) j↻↻
+onk onk repeat (1 2 2) T-j (150 0) j↻↻
+onk onk tap (1 2 0) T-j (280 0) j↻↻
+onk onk tap (2 2 0) j (320 0) j↻↻j
+onk onk tap (3 2 0) S-j (300 0) j↻↻j
+onk onk repeat (3 3 1) T-j (480 0) j↻↻j↻
+onk onk repeat (3 3 2) j (150 0) j↻↻j↻
+onk onk click (1 0 0) k (950 0) k
+
+--- CASE 12: : tan tan ta ta tan (diff key) ---
+PLC src type (t h r) key (itv h) seq
+onk onk click (1 0 0) x (900 0) x
+onk map repeat (1 1 1) x (500 0) x↻
+MAP map repeat (1 1 1) x (500 0) x↻
+onk onk repeat (1 1 2) x (150 0) x↻
+onk onk repeat (1 1 3) x (150 0) x↻
+onk onk repeat (1 1 4) x (150 0) x↻
+onk onk tap (1 1 0) h (300 0) x↻h
+onk onk repeat (1 2 1) h (500 0) x↻h↻
+onk onk repeat (1 2 2) h (150 0) x↻h↻
+onk onk tap (1 2 0) w (300 0) x↻h↻w
+onk onk tap (1 2 0) e (300 0) x↻h↻we
+onk onk tap (1 2 0) j (300 0) x↻h↻wej
+onk onk repeat (1 3 1) j (500 0) x↻h↻wej↻
+onk onk repeat (1 3 2) j (150 0) x↻h↻wej↻
+onk onk click (1 0 0) k (900 0) k
+
+--- CASE 15: : tan tan ta ta tan (interval) ---
+PLC src type (t h r) key (itv h) seq
+onk onk click (1 0 0) j (900 0) j
+onk onk repeat (1 1 1) j (200 0) j↻
+onk onk repeat (1 1 2) j (300 0) j↻
+onk onk repeat (1 1 3) j (300 0) j↻
+onk onk repeat (1 1 4) j (300 1) j↻
+onk onk tap (1 1 0) j (100 0) j↻j
+onk onk repeat (1 2 1) j (200 0) j↻j↻
+onk onk repeat (1 2 2) j (300 0) j↻j↻
+onk onk tap (1 2 0) j (100 0) j↻j↻j
+onk onk tap (2 2 0) j (100 0) j↻j↻jj
+onk onk tap (3 2 0) j (100 0) j↻j↻jjj
+onk onk repeat (3 3 1) j (200 0) j↻j↻jjj↻
+onk onk repeat (3 3 2) j (300 0) j↻j↻jjj↻
+onk onk repeat (3 3 3) j (300 0) j↻j↻jjj↻
+onk onk repeat (3 3 4) j (300 1) j↻j↻jjj↻
+onk onk repeat (3 3 5) j (300 1) j↻j↻jjj↻
+onk onk repeat (3 3 6) j (300 1) j↻j↻jjj↻
+onk onk repeat (3 3 7) j (300 2) j↻j↻jjj↻
+onk onk click (1 0 0) j (311 0) j
+
+=== DISPLAY ===
+
diff --git a/tests/cases/basic/run.vim b/tests/cases/basic/run.vim
new file mode 100644
index 0000000..6f54ea2
--- /dev/null
+++ b/tests/cases/basic/run.vim
@@ -0,0 +1,18 @@
+source $KIBI2_REPO_ROOT/tests/common.vim
+
+" ===== BASIC =====
+new
+
+CASE : tan tan ta ta tan
+lua require "run1"
+
+CASE : tan tan ta ta tan (meta)
+lua require "run2"
+
+CASE : tan tan ta ta tan (diff key)
+lua require "run3"
+
+CASE : tan tan ta ta tan (interval)
+lua require "run4"
+
+call Snapshot({ 'desc': 'basic' })
diff --git a/tests/cases/basic/run1.lua b/tests/cases/basic/run1.lua
new file mode 100644
index 0000000..25abf99
--- /dev/null
+++ b/tests/cases/basic/run1.lua
@@ -0,0 +1,27 @@
+local common_key = require("common_key")
+
+---@type KeyInfo[]
+local tan_tan_ta_ta_tann = {
+ { key = "j", interval = CLICK }, -- tan
+ { key = "j", interval = HOLD, keymap = true }, -- repeat 1
+ { key = "j", interval = REPEAT }, -- repeat 2
+ { key = "j", interval = REPEAT }, -- repeat 3
+ { key = "j", interval = REPEAT }, -- repeat 4
+ { key = "j", interval = TAP }, -- tan
+ { key = "j", interval = HOLD }, -- repeat 1 nh = 2
+ { key = "j", interval = REPEAT }, -- repeat 2
+ { key = "j", interval = TAP }, -- ta
+ { key = "j", interval = TAP }, -- ta
+ { key = "j", interval = TAP }, -- tan
+ { key = "j", interval = HOLD }, -- repeat 1 nh = 3
+ { key = "j", interval = REPEAT }, -- repeat 2
+ { key = "j", interval = REPEAT }, -- repeat 3
+ { key = "j", interval = REPEAT }, -- repeat 4
+ { key = "j", interval = REPEAT }, -- repeat 5
+ { key = "j", interval = REPEAT }, -- repeat 6
+ { key = "j", interval = REPEAT }, -- repeat 7
+}
+
+common_key.run_sequence(tan_tan_ta_ta_tann)
+local events = KeyEvent.get_evcents(2)
+print("hold_time = 500 + 150 * 6 = ", KeyEvent.hold_time(events[1]))
diff --git a/tests/cases/basic/run2.lua b/tests/cases/basic/run2.lua
new file mode 100644
index 0000000..ca5f3f9
--- /dev/null
+++ b/tests/cases/basic/run2.lua
@@ -0,0 +1,21 @@
+local common_key = require("common_key")
+
+---@type KeyInfo[]
+local meta = {
+ { key = "j", interval = CLICK }, -- tan
+ { key = "", interval = HOLD + DELTA, keymap = true }, -- repeat 1
+ { key = "J", interval = REPEAT + DELTA }, -- repeat 2
+ { key = "", interval = REPEAT - DELTA }, -- repeat 3
+ { key = "", interval = REPEAT }, -- repeat 4
+ { key = "", interval = TAP - DELTA }, -- tan
+ { key = "", interval = HOLD }, -- repeat 1 nh = 2
+ { key = "", interval = REPEAT }, -- repeat 2
+ { key = "", interval = TAP - DELTA }, -- ta
+ { key = "j", interval = TAP + DELTA }, -- ta
+ { key = "J", interval = TAP }, -- tan
+ { key = "", interval = HOLD - DELTA }, -- repeat 1 nh = 3
+ { key = "j", interval = REPEAT }, -- repeat 2
+ { key = "k", interval = CLICK + 50 }, -- click k
+}
+
+common_key.run_sequence(meta)
diff --git a/tests/cases/basic/run3.lua b/tests/cases/basic/run3.lua
new file mode 100644
index 0000000..27203aa
--- /dev/null
+++ b/tests/cases/basic/run3.lua
@@ -0,0 +1,21 @@
+local common_key = require("common_key")
+
+---@type KeyInfo[]
+local diff_key = {
+ { key = "x", interval = CLICK }, -- tan
+ { key = "x", interval = HOLD, keymap = true }, -- repeat 1
+ { key = "x", interval = REPEAT }, -- repeat 2
+ { key = "x", interval = REPEAT }, -- repeat 3
+ { key = "x", interval = REPEAT }, -- repeat 4
+ { key = "h", interval = TAP }, -- tan h
+ { key = "h", interval = HOLD }, -- repeat 1 nh = 2
+ { key = "h", interval = REPEAT }, -- repeat 2
+ { key = "w", interval = TAP }, -- ta w
+ { key = "e", interval = TAP }, -- ta e
+ { key = "j", interval = TAP }, -- tan j
+ { key = "j", interval = HOLD }, -- repeat 1 nh = 3
+ { key = "j", interval = REPEAT }, -- repeat 2
+ { key = "k", interval = CLICK }, -- click k
+}
+
+common_key.run_sequence(diff_key)
diff --git a/tests/cases/basic/run4.lua b/tests/cases/basic/run4.lua
new file mode 100644
index 0000000..c33aa07
--- /dev/null
+++ b/tests/cases/basic/run4.lua
@@ -0,0 +1,41 @@
+local common_key = require("common_key")
+
+DELTA = 10
+TAP = 100
+HOLD = 200
+REPEAT = 300
+
+local opts = {
+ threshold = {
+ delta = DELTA,
+ tap = TAP,
+ delay = HOLD,
+ interval = REPEAT,
+ },
+}
+
+require("keyevent").setup(opts)
+---@type KeyInfo[]
+local tan_tan_ta_ta_tann = {
+ { key = "j", interval = CLICK }, -- tan
+ { key = "j", interval = HOLD }, -- repeat 1
+ { key = "j", interval = REPEAT }, -- repeat 2
+ { key = "j", interval = REPEAT }, -- repeat 3
+ { key = "j", interval = REPEAT }, -- repeat 4
+ { key = "j", interval = TAP }, -- tan
+ { key = "j", interval = HOLD }, -- repeat 1 nh = 2
+ { key = "j", interval = REPEAT }, -- repeat 2
+ { key = "j", interval = TAP }, -- ta
+ { key = "j", interval = TAP }, -- ta
+ { key = "j", interval = TAP }, -- tan
+ { key = "j", interval = HOLD }, -- repeat 1 nh = 3
+ { key = "j", interval = REPEAT }, -- repeat 2
+ { key = "j", interval = REPEAT }, -- repeat 3
+ { key = "j", interval = REPEAT }, -- repeat 4
+ { key = "j", interval = REPEAT }, -- repeat 5
+ { key = "j", interval = REPEAT }, -- repeat 6
+ { key = "j", interval = REPEAT }, -- repeat 7
+ { key = "j", interval = REPEAT + DELTA + 1 }, -- repeat 7
+}
+
+common_key.run_sequence(tan_tan_ta_ta_tann)
diff --git a/tests/cases/check/format/out-expected.txt b/tests/cases/check/format/out-expected.txt
new file mode 100644
index 0000000..e69de29
diff --git a/tests/cases/check/format/run.sh b/tests/cases/check/format/run.sh
new file mode 100644
index 0000000..6882292
--- /dev/null
+++ b/tests/cases/check/format/run.sh
@@ -0,0 +1,4 @@
+#!/bin/sh
+set -eu
+
+stylua --check $KIBI2_REPO_ROOT/lua $KIBI2_REPO_ROOT/tests > out-actual.txt
\ No newline at end of file
diff --git a/tests/cases/check/format/skip-ci b/tests/cases/check/format/skip-ci
new file mode 100644
index 0000000..e69de29
diff --git a/tests/cases/check/rg_ng_char/out-expected.txt b/tests/cases/check/rg_ng_char/out-expected.txt
new file mode 100644
index 0000000..e69de29
diff --git a/tests/cases/check/rg_ng_char/run.sh b/tests/cases/check/rg_ng_char/run.sh
new file mode 100644
index 0000000..5c6e1da
--- /dev/null
+++ b/tests/cases/check/rg_ng_char/run.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+set -eu
+
+echo $KIBI2_REPO_ROOT
+LC_ALL=C rg --sort=path -g '*.lua' -g '*.sh' -g '*.vim' '[^\x00-\x7F]' $KIBI2_REPO_ROOT > out-actual.txt # no ascii
+
+LC_ALL=C rg --sort=path "\bM:[a-zA-Z_]" $KIBI2_REPO_ROOT/lua | grep -v function >> out-actual.txt # no colon
\ No newline at end of file
diff --git a/tests/cases/check/rg_ng_char/skip-ci b/tests/cases/check/rg_ng_char/skip-ci
new file mode 100644
index 0000000..e69de29
diff --git a/tests/cases/check/rg_ng_char/sort b/tests/cases/check/rg_ng_char/sort
new file mode 100644
index 0000000..e69de29
diff --git a/tests/cases/check/rg_probe/out-expected.txt b/tests/cases/check/rg_probe/out-expected.txt
new file mode 100644
index 0000000..e69de29
diff --git a/tests/cases/check/rg_probe/run.sh b/tests/cases/check/rg_probe/run.sh
new file mode 100644
index 0000000..89a4a34
--- /dev/null
+++ b/tests/cases/check/rg_probe/run.sh
@@ -0,0 +1,4 @@
+#!/bin/sh
+set -eu
+
+rg "log\.probe\(" $KIBI2_REPO_ROOT/lua > out-actual.txt
\ No newline at end of file
diff --git a/tests/common.vim b/tests/common.vim
new file mode 100644
index 0000000..e0bfe49
--- /dev/null
+++ b/tests/common.vim
@@ -0,0 +1,138 @@
+" ===== common.vim =====
+
+set noswapfile
+set nobackup
+set nowritebackup
+set noundofile
+set shortmess+=A
+
+let s:root = $KIBI2_REPO_ROOT
+execute 'set rtp+=' . s:root
+let g:kibi2_test_mode = 1
+filetype plugin indent on
+
+lua << EOF
+require("luacov")
+local opts = {
+ log = {
+ level = vim.log.levels.WARN,
+ -- level = vim.log.levels.DEBUG,
+ use_timestamp = false,
+ monitor = false,
+ -- probe = false,
+ probe = true,
+ output = "print",
+ },
+ table = {
+ wrap_mode = "nowrap",
+ },
+}
+require("keyevent").setup(opts)
+-- KeyEvent = require("keyevent.keyevent")
+-- Os = require("keyevent.os")
+-- KeyEvent.setup({
+ -- time = function()
+ -- return 123
+ -- end
+-- })
+local root = assert(os.getenv("KIBI2_REPO_ROOT"))
+package.path = root .. "/tests/?.lua;" .. package.path
+EOF
+
+" ----------------------------
+let g:case_no = 0
+let g:case_name = ""
+
+function! CaseImpl(id, desc) abort
+ echomsg " "
+ echomsg printf("--- CASE %d: %s ---", a:id, a:desc)
+ let g:case_tag = printf("CASE %d", a:id)
+endfunction
+
+command! -nargs=1 CASE call CaseImpl(expand(''), )
+
+function! Dump(cmd) abort
+ redir => msg
+ execute a:cmd
+ redir END
+
+ let msg = substitute(msg, '\n', ' ', 'g')
+ echomsg printf('%s => %s', a:cmd, msg)
+endfunction
+
+let s:last_msg_count = 0
+
+function! s:CollectMessages() abort
+ redir => l:msgs
+ silent messages
+ redir END
+ let l:lines = split(l:msgs, "\n")
+ let l:new = l:lines[s:last_msg_count :]
+ let s:last_msg_count = len(l:lines)
+ return l:new
+endfunction
+
+function! s:CollectDisplay()
+ return getline(1, '$')
+endfunction
+
+function! s:CollectFile(path)
+ if a:path !=# '' && filereadable(a:path)
+ return readfile(a:path)
+ endif
+ return []
+endfunction
+
+function! s:LoadFileForCI(file) abort
+ if fnamemodify(a:file, ':e') ==# 'tir'
+ return systemlist('jq -cS . ' . shellescape(a:file))
+ endif
+ return s:CollectFile(a:file)
+endfunction
+
+function! s:CollectAll(opts) abort
+ let l:out = []
+
+ " DESCRIPTION
+ if has_key(a:opts, 'desc')
+ call add(l:out, '--- ' . a:opts.desc . ' ---')
+ endif
+
+ " MESSAGE
+ if !has_key(a:opts, 'nomessage')
+ let l:msgs = s:CollectMessages()
+ call filter(l:msgs, 'v:val !~# " written$"')
+ if !empty(l:msgs)
+ call add(l:out, '=== MESSAGE ===')
+ let l:out += l:msgs
+ endif
+ endif
+
+ " DISPLAY
+ call add(l:out, '')
+ call add(l:out, '=== DISPLAY ===')
+ let l:out += s:CollectDisplay()
+
+ " FILE
+ if has_key(a:opts, 'file')
+ let l:file = s:LoadFileForCI(a:opts.file)
+ if !empty(l:file)
+ call add(l:out, '')
+ call add(l:out, '=== FILE ===')
+ let l:out += l:file
+ endif
+ endif
+
+ return l:out
+endfunction
+
+" ----------------------------
+" opts:
+" file: 'output.csv'
+" desc: 'description'
+function! Snapshot(opts) abort
+ lua vim.wait(50)
+ let l:out = s:CollectAll(a:opts)
+ " call writefile([''], 'out-actual.txt', 'a')
+ call writefile(l:out, 'out-actual.txt', 'a')
+endfunction
\ No newline at end of file
diff --git a/tests/common_key.lua b/tests/common_key.lua
new file mode 100644
index 0000000..5220943
--- /dev/null
+++ b/tests/common_key.lua
@@ -0,0 +1,51 @@
+KeyEvent = require("keyevent.keyevent")
+Os = require("keyevent.os")
+local log = require("keyevent.log")
+
+local M = {}
+
+local test_time = 0
+
+---@class KeyInfo
+---@field key string
+---@field interval integer
+---@field keymap boolean|nil
+
+local function debug_event(title, event)
+ print(title, KeyEvent.to_string(event))
+end
+
+function M.advance(ms)
+ test_time = test_time + ms
+end
+
+--- @param sequence KeyInfo[]
+function M.run_sequence(sequence)
+ print("PLC src type (t h r) key (itv h) seq")
+ for _, keyinfo in ipairs(sequence) do
+ M.advance(keyinfo.interval)
+ if keyinfo.keymap then
+ local event = KeyEvent.keymap_event(keyinfo.key)
+ debug_event("MAP", event)
+ end
+ KeyEvent.on_key_test(keyinfo.key)
+ end
+end
+
+KeyEvent.setup({
+ time = function()
+ return test_time
+ end,
+})
+
+require("keyevent").on_event(function(event)
+ debug_event("onk", event)
+end)
+
+CLICK = 900
+TAP = 300
+HOLD = 500
+REPEAT = 150
+DELTA = 20
+
+return M
diff --git a/tests/coverage.sh b/tests/coverage.sh
new file mode 100755
index 0000000..e08a084
--- /dev/null
+++ b/tests/coverage.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
+ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
+
+cd "$ROOT_DIR"
+
+eval "$(luarocks path)"
+
+if [ ! -f "$ROOT_DIR/luacov.stats.out" ]; then
+ echo "No coverage data. Run tests first."
+ exit 1
+fi
+
+nvim --headless +"lua require('luacov.reporter.html').report()" +qa
+mv luacov.report.out luacov.report.html
+nvim --headless +"lua require('luacov.reporter.default').report()" +qa
+
+echo "Coverage report: $ROOT_DIR/luacov.report.html"
+
+if [ -z "${GITHUB_ACTIONS:-}" ] && command -v open >/dev/null 2>&1; then
+ open "$ROOT_DIR/luacov.report.html"
+fi
\ No newline at end of file
diff --git a/tests/data/sample.md b/tests/data/sample.md
new file mode 100644
index 0000000..3a9fcb2
--- /dev/null
+++ b/tests/data/sample.md
@@ -0,0 +1,316 @@
+# KeyEvent Laboratory
+
+これは keyevent.nvim と rush.nvim の動作を確認するためのテスト用 Markdown 文書です。
+日本語、English、数字、記号、Markdown 構文を意図的に混在させています。
+
+## 01. はじめに
+
+キーボードから入力された文字は、単純な一文字ではなく、時間の流れを持ったイベントとして観察できます。
+たとえば `j` を一度押す場合と、`j` を押し続ける場合では、同じキーでも異なるイベント列になります。
+`click`、`tap`、`repeat`、そしてキーを離した瞬間を観察すると、入力の時間的な構造が見えてきます。
+
+ここでは特定の文章を読むことよりも、カーソルを自由に動かして、キー入力の挙動を眺めることを目的とします。
+
+## 02. 短い行
+
+a
+ab
+abc
+abcd
+日本
+日本語
+テスト
+ABC
+ABC123
+1234567890
+!
+!!
+???
+---
+
+---
+
+短い行の次には、普通の長さの行を置きます。
+
+## 03. 普通の文章
+
+春の朝、窓を開けると静かな風が部屋の中へ入ってきました。机の上には古いノートと新しいキーボードが並んでいます。
+プログラムを書いていると、画面上の小さなカーソルが意外なほど重要な存在であることに気づきます。
+
+日本語の文章では、漢字、ひらがな、カタカナが自然に混ざります。
+English words may appear in the middle of a Japanese sentence, and numbers such as 2026 or 3.141592653589793 may appear as well.
+
+キー操作のテストでは、`h`、`j`、`k`、`l` を何度も入力します。
+さらに `w`、`b`、`e`、`ge` のような単語移動も試します。
+
+## 04. 記号
+
+ASCII 記号をまとめて並べます。
+
+```text
+! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ \ ] ^ _ ` { | } ~
+```
+
+日本語の記号も確認します。
+
+```text
+「こんにちは」『テスト文章』【重要】(補足)[配列]〈項目〉《引用》〔注記〕
+```
+
+似ている文字でも Unicode 上では別の文字です。
+
+```text
+() () [] [] {} {} <> <> -- ー ― ~ ~
+```
+
+## 05. Markdown
+
+Markdown の強調記法を確認します。
+
+**太字の日本語**、*斜体の日本語*、`inline code`、~~取り消し線~~。
+リンク風の文字列は [example](https://example.com/test?q=123&lang=ja) と書けます。
+
+> これは引用文です。カーソルを行頭から行末まで何度も往復させてみてください。
+
+* 最初の項目
+* 二番目の項目
+
+ * 内側の項目
+ * もう一つの項目
+* 最後の項目
+
+1. 一番目
+2. 二番目
+3. 三番目
+
+## 06. 数字と英字
+
+数字を連続させます。
+
+```text
+0123456789 1234567890 9876543210 314159265358979323846264338327950288419716939937510
+```
+
+英字も連続させます。
+
+```text
+abcdefghijklmnopqrstuvwxyz
+ABCDEFGHIJKLMNOPQRSTUVWXYZ
+Aa Bb Cc Dd Ee Ff Gg Hh Ii Jj Kk Ll Mm Nn Oo Pp Qq Rr Ss Tt Uu Vv Ww Xx Yy Zz
+```
+
+識別子らしい文字列もあります。
+
+```text
+keyevent.nvim
+rush.nvim
+vim.v.count
+vim.keymap.set
+nvim_buf_get_lines
+on_key
+repeat_end
+```
+
+## 07. CJK と ASCII の混在
+
+日本語ABC日本語123日本語xyz日本語---日本語+++日本語===日本語___日本語。
+Hello世界、world世界、Neovim日本語、Luaコード、GitHubリポジトリ、OSS開発。
+`日本語` と `English` と `12345` と `!@#$%` を同じ行に置いてみます。
+
+「ABC」「日本語」「123」「!@#」「foo_bar」「foo-bar」「foo/bar」「foo.bar」を比較します。
+
+## 08. URL とパス
+
+URL は長くなることがあります。
+
+https://example.com/path/to/document?id=12345&lang=ja&mode=test#section-04
+
+ファイルパスもあります。
+
+```text
+~/oss/keyevent.nvim/lua/keyevent/keyevent.lua
+~/.config/nvim/lua/custom/plugins/rush.lua
+/usr/local/bin/nvim
+/Users/example/Documents/project/test-data.md
+```
+
+Windows 風のパスも置きます。
+
+```text
+C:\Users\example\Documents\project\test.md
+D:\work\keyevent\data\sample.txt
+```
+
+## 09. 長い行
+
+この行は横方向のカーソル移動を試すために意図的に長くしています。日本語の文字列と English words、1234567890、記号 !@#$%^&*()_+-=[]{};:',.<>/?|、Markdown の **bold** や *italic*、`inline code`、URL https://example.com/path/to/resource?q=123&lang=ja#section などを混在させています。さらに「日本語の括弧(全角)」「半角括弧 (ASCII)」「[全角角括弧] [ASCII brackets]」「【隅付き括弧】」「」「key=value」「foo->bar」「a::b::c」といったパターンも含め、横方向に長く連続させています。
+
+この文章は rush.nvim の h/l による移動を試すためのものです。カーソルを行頭に置いてから l を長押しし、行末付近まで移動してみます。その後 h を長押しして逆方向へ戻ります。日本語と ASCII の幅が異なるため、表示上の位置と内部的な文字位置について考えるきっかけにもなります。さらに w や b を使えば、単純な一文字単位とは違う移動の様子も確認できます。
+
+## 10. さらに長い行
+
+keyevent.nvim はキー入力を時間情報とともに扱いますが、テストデータそのものには時間情報がありません。そのため、このような長い行を用意しておくと、実際のキーボード操作で click、tap、repeat が発生したときに、カーソルがどの位置まで進んだのかを目視で確認できます。たとえば `8j` の後に j を押し続ける操作、`6l` の後に l を押し続ける操作、あるいは w と b を交互に押す操作などを組み合わせると、通常の Neovim の移動と rush.nvim による加速の違いを確認しやすくなります。
+
+## 11. 表
+
+| key | event | meaning | example |
+| --- | ------ | ------------------ | -------- |
+| `j` | click | normal motion | 下へ移動 |
+| `j` | repeat | accelerated motion | 長押し |
+| `k` | repeat | reverse motion | 上へ移動 |
+| `w` | tap | word motion | 次の単語 |
+| `b` | tap | backward motion | 前の単語 |
+| `l` | repeat | horizontal motion | 右へ移動 |
+
+表の中にも日本語と ASCII を混ぜています。
+
+## 12. コードブロック
+
+```lua
+local KeyEvent = require("keyevent")
+
+local count = 8
+local direction = "j"
+
+print("count =", count)
+print("direction =", direction)
+
+for index = 1, count do
+ print(index, direction)
+end
+```
+
+コードの中には `local`、`function`、`return`、`end`、`if`、`then` などの短い単語が現れます。
+
+## 13. 記号の密集
+
+```text
+foo(bar[baz{qux}])!
+foo(bar[baz{qux}])?
+foo::bar::baz->qux=>xyz
+a==b && c!=d || e<=f && g>=h
+x[0] = y[1] + z[2] * 3 / 4
+```
+
+記号が密集した場所では、`w` や `e` の動作も試してみます。
+
+## 14. 日本語の句読点
+
+日本語にはさまざまな句読点があります。
+「これは引用です。」(これは括弧です。)【これは別の括弧です】[こちらも括弧です]〈さらに別の括弧〉。
+文章の途中に……三点リーダがあります。――ダッシュもあります。〜波線もあります。
+
+句読点の直後に英単語を置きます。たとえば、Neovim、Lua、Git、GitHub、Markdown などです。
+
+## 15. 数値
+
+整数: 0, 1, 2, 3, 10, 100, 1000
+負数: -1, -2, -100
+小数: 0.1, 1.5, 3.141592653589793
+指数: 1e3, 2.5e-4
+16進数: 0x00, 0x10, 0xff, 0x12345678
+
+大きな整数もあります。
+
+```text
+12345678901234567890123456789012345678901234567890
+```
+
+## 16. 短い行と長い行
+
+ここからは行長を意図的に変化させます。
+
+短い。
+
+少し長い行です。
+
+もう少し長い行です。日本語と English を混ぜます。
+
+これはさらに長い行です。カーソルを左右に移動させて、画面端付近でスクロールがどのように発生するかを確認してください。1234567890 !@#$% `code` **bold** [link](https://example.com)。
+
+## 17. 箇条書きの深さ
+
+* level 1
+
+ * level 2
+
+ * level 3
+
+ * level 4
+
+ * level 5
+* 日本語の level 1
+
+ * 日本語の level 2
+
+ * 日本語の level 3
+
+## 18. 空行
+
+上の節とこの節の間には、複数の空行があります。
+
+空行を越えてカーソルを移動するときの動作も確認できます。
+
+## 19. 混在した文章
+
+あるところでは日本語だけを書き、別のところでは English only の文章を書き、その間に 12345 や記号 !@#$% を挿入します。すると、カーソルは文字種の境界を何度も通過することになります。たとえば `日本語ABC123` のような短い文字列から始めて、`日本語---ABC---123---xyz---!@#$%` のような文字列へ進み、最後には URL やファイルパスまで続けることができます。
+
+## 20. テストシナリオ
+
+まずカーソルをこの行の先頭へ移動します。
+次に `l` を一回ずつ入力します。
+続いて `l` を押し続けます。
+その後 `h` を押し続けて元の位置へ戻ります。
+
+次に `j` を押します。
+そのまま `j` を連続して入力します。
+最後に `k` を連続して入力して戻ります。
+
+## 21. 特殊な文字列
+
+```text
+aaaaabbbbbcccccdddddeeeee
+jjjjjkkkkklllllhhhhh
+wwwwwbbbbbeeeeegegege
+-----=====+++++*****
+.....,,,,,;;;;;:::::
+```
+
+日本語でも同じような繰り返しを置きます。
+
+```text
+あああああいいいいいううううう
+漢漢漢漢漢字字字字字
+テテテテテストストスト
+```
+
+## 22. Markdown の境界
+
+**bold** の直前と直後、`code` の前後、[link](https://example.com) の前後には記号があります。
+たとえば `foo**bold**bar`、`foo`code`bar`、`foo[link](https://example.com)bar` のような文字列です。
+
+## 23. コメント風文字列
+
+```text
+# heading
+// comment
+/* comment */
+-- lua comment
+
+```
+
+これらはすべて単なるテストデータです。
+
+## 24. 500文字近いテスト行
+
+最後に非常に長い行を置きます。この行では、日本語、English、数字、記号、Markdown、URL、ファイルパス、括弧、引用符、演算子などを一つの行にまとめています。日本語の文章を書きながら `keyevent.nvim` と `rush.nvim` の名前を何度も登場させ、さらに `h j k l w b e ge`、``、``、``、`vim.v.count`、`vim.keymap.set()` といった文字列を追加します。数字は 0123456789、16進数は 0x123456789abcdef、記号は !@#$%^&*()_+-=[]{};:'",.<>/?|~ を使用します。URL は https://example.com/a/b/c?q=123&lang=ja#test、パスは ~/oss/keyevent.nvim/lua/keyevent/keyevent.lua、括弧は (日本語)(ASCII)[日本語][ASCII]【日本語】{ASCII}、引用符は「日本語」"English" 'single' `code` とします。さらに ABCDEFGHIJKLMNOPQRSTUVWXYZ と abcdefghijklmnopqrstuvwxyz を加え、最後に 日本語ABC123xyz!@#$% を置きます。このように異なる文字種を連続して配置すると、カーソル移動、スクロール、単語移動、キーリピートによる加速などを一つの行でまとめて観察できます。
+
+## 25. 終了
+
+ここまでがテストデータです。
+
+最後の確認として、`h`、`j`、`k`、`l`、`w`、`b`、`e`、`ge` を自由に組み合わせてカーソルを動かしてください。
+
+長い行では横方向の移動を、短い行では行間の移動を、コードブロックでは記号を、通常の文章では日本語と ASCII の混在を確認できます。
+
+keyevent.nvim のイベント表示と rush.nvim の加速動作が、この文書を使って観察できればテスト成功です。
diff --git a/tests/data/simple.md b/tests/data/simple.md
new file mode 100644
index 0000000..eaf9258
--- /dev/null
+++ b/tests/data/simple.md
@@ -0,0 +1,9 @@
+# KeyEvent Laboratory
+
+これは keyevent.nvim と rush.nvim の動作を確認するためのテスト用 Markdown 文書です。日本語、English、数字、記号、Markdown 構文を意図的に混在させています。
+
+## 01. はじめに
+
+キーボードから入力された文字は、単純な一文字ではなく、時間の流れを持ったイベントとして観察できます。たとえば `j` を一度押す場合と、`j` を押し続ける場合では、同じキーでも異なるイベント列になります。`click`、`tap`、`repeat`、そしてキーを離した瞬間を観察すると、入力の時間的な構造が見えてきます。
+
+ここでは特定の文章を読むことよりも、カーソルを自由に動かして、キー入力の挙動を眺めることを目的とします。
diff --git a/tests/format.sh b/tests/format.sh
new file mode 100755
index 0000000..e0d6136
--- /dev/null
+++ b/tests/format.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
+ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
+
+echo "Format lua tests"
+
+stylua $ROOT_DIR/lua $ROOT_DIR/tests
\ No newline at end of file
diff --git a/tests/golden_test.sh b/tests/golden_test.sh
new file mode 100755
index 0000000..71f86ba
--- /dev/null
+++ b/tests/golden_test.sh
@@ -0,0 +1,218 @@
+#!/usr/bin/env bash
+
+# Usage:
+# ./run.sh # run all tests
+# ./run.sh file/csv # run tests matching pattern
+# ./run.sh --update # generate expected outputs (for new tests)
+#
+# Environment variables:
+# FAIL_FAST=1 # stop on first failure
+# NO_COLOR=1 # disable colored output
+#
+# Notes:
+# - Tests are located under cases/
+# - Each test must have run.sh or run.vim
+# - --update does NOT overwrite existing out-expected.txt
+
+set -euo pipefail
+
+run_hook() {
+ local phase="$1"
+ case "$phase" in
+ pre|post)
+ ;;
+ *)
+ echo "Unknown hook phase: $phase" >&2
+ exit 1
+ ;;
+ esac
+ if [ -f hook.sh ]; then
+ sh hook.sh "$phase" >> stdout.txt 2>> stderr.txt
+ fi
+}
+
+normalize_output() {
+ LC_ALL=C sed -E '/PRB/! s/ [0-9]+]/]/g' \
+ out-actual.txt > gen.txt
+ mv gen.txt out-actual.txt
+
+ LC_ALL=C sed -E \
+ 's/[0-9]+ seconds? ago/