feat: add Keymap plugin (#68)
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
local function stateMock()
|
||||
local values = {}
|
||||
local watchers = {}
|
||||
return values, {
|
||||
get = function(key) return values[key] end,
|
||||
set = function(key, value)
|
||||
values[key] = value
|
||||
if watchers[key] ~= nil then watchers[key](value) end
|
||||
end,
|
||||
watch = function(key, callback) watchers[key] = callback end,
|
||||
}
|
||||
end
|
||||
|
||||
local function findCategory(snapshot, name)
|
||||
for _, category in ipairs(snapshot.categories or {}) do
|
||||
if category.name == name then return category end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function assertMarkedBind(snapshot, categoryName, expectedSnippet, expectedStart, expectedEnd)
|
||||
assert(snapshot.status == "ready", categoryName .. ": snapshot not ready")
|
||||
local category = findCategory(snapshot, categoryName)
|
||||
assert(type(category) == "table", categoryName .. ": category missing")
|
||||
assert(#category.binds == 1, categoryName .. ": unexpected bind count")
|
||||
local bind = category.binds[1]
|
||||
assert(bind.raw_snippet == expectedSnippet, categoryName .. ": marker missing from provenance")
|
||||
assert(bind.start_line == expectedStart and bind.end_line == expectedEnd, categoryName .. ": line range mismatch")
|
||||
assert(bind.capabilities.category == true, categoryName .. ": category editing disabled")
|
||||
end
|
||||
|
||||
do
|
||||
local values, state = stateMock()
|
||||
local source = table.concat({
|
||||
"-- 1. General",
|
||||
"-- Keymap bind-category: Media",
|
||||
'hl.bind("SUPER + G", hl.dsp.exec_cmd([[playerctl play-pause]]), { description = "Media action" })',
|
||||
'hl.bind("SUPER + H", hl.dsp.exec_cmd([[true]]), { description = "General action" })',
|
||||
"",
|
||||
}, "\n")
|
||||
noctalia = {
|
||||
state = state,
|
||||
getConfig = function(key)
|
||||
local config = { compositor = "hyprland", hyprland_config = "/tmp/hyprland.lua", merge_sequential = false }
|
||||
return config[key]
|
||||
end,
|
||||
getenv = function(key) return key == "HYPRLAND_INSTANCE_SIGNATURE" and "test" or "" end,
|
||||
expandPath = function(path) return path end,
|
||||
fileExists = function(path) return path == "/tmp/hyprland.lua" end,
|
||||
readFile = function(path) return path == "/tmp/hyprland.lua" and source or nil end,
|
||||
commandExists = function(command) return command == "hyprctl" end,
|
||||
tr = function(key) return key end,
|
||||
runAsync = function(_command, callback)
|
||||
callback({ exitCode = 0, timedOut = false, stdout = "[]" })
|
||||
return true
|
||||
end,
|
||||
json = {
|
||||
decode = function()
|
||||
return {
|
||||
{ modmask = 64, key = "G", dispatcher = "__lua", arg = "", description = "Media action", has_description = true },
|
||||
{ modmask = 64, key = "H", dispatcher = "__lua", arg = "", description = "General action", has_description = true },
|
||||
}
|
||||
end,
|
||||
},
|
||||
}
|
||||
assert(loadfile("service.luau"))()
|
||||
local snapshot = values["keymap.snapshot"]
|
||||
assertMarkedBind(
|
||||
snapshot, "Media",
|
||||
'-- Keymap bind-category: Media\n'
|
||||
.. 'hl.bind("SUPER + G", hl.dsp.exec_cmd([[playerctl play-pause]]), { description = "Media action" })',
|
||||
2, 3
|
||||
)
|
||||
assert(#findCategory(snapshot, "General").binds == 1, "Hyprland marker leaked into following bind")
|
||||
end
|
||||
|
||||
do
|
||||
local values, state = stateMock()
|
||||
local marker = " // Keymap bind-category: Media"
|
||||
local bindLine = ' Mod+G hotkey-overlay-title="Media action" { spawn-sh "playerctl play-pause"; }'
|
||||
local source = table.concat({ "binds {", marker, bindLine, "}", "" }, "\n")
|
||||
noctalia = {
|
||||
state = state,
|
||||
getConfig = function(key)
|
||||
local config = { compositor = "niri", niri_config = "/tmp/config.kdl", merge_sequential = false }
|
||||
return config[key]
|
||||
end,
|
||||
getenv = function(key) return key == "NIRI_SOCKET" and "test" or "" end,
|
||||
fileExists = function(path) return path == "/tmp/config.kdl" end,
|
||||
readFile = function(path) return path == "/tmp/config.kdl" and source or nil end,
|
||||
tr = function(key) return key end,
|
||||
}
|
||||
assert(loadfile("niri_service.luau"))()
|
||||
assertMarkedBind(values["keymap.snapshot"], "Media", marker .. "\n" .. bindLine, 2, 3)
|
||||
end
|
||||
|
||||
do
|
||||
local values, state = stateMock()
|
||||
local marker = "# Keymap bind-category: Media"
|
||||
local moved = 'bind=SUPER,G,spawn_shell,playerctl play-pause #"Media action"'
|
||||
local general = 'bind=SUPER,H,spawn_shell,true #"General action"'
|
||||
local source = table.concat({ "# General", marker, moved, general, "" }, "\n")
|
||||
noctalia = {
|
||||
state = state,
|
||||
getConfig = function(key)
|
||||
local config = { compositor = "mangowc", mangowc_config = "/tmp/mango.conf", merge_sequential = false }
|
||||
return config[key]
|
||||
end,
|
||||
getenv = function(key) return key == "MANGO_INSTANCE_SIGNATURE" and "test" or "" end,
|
||||
expandPath = function(path) return path end,
|
||||
fileExists = function(path) return path == "/tmp/mango.conf" end,
|
||||
readFile = function(path) return path == "/tmp/mango.conf" and source or nil end,
|
||||
tr = function(key) return key end,
|
||||
}
|
||||
assert(loadfile("mangowc_service.luau"))()
|
||||
local snapshot = values["keymap.snapshot"]
|
||||
assertMarkedBind(snapshot, "Media", marker .. "\n" .. moved, 2, 3)
|
||||
assert(#findCategory(snapshot, "General").binds == 1, "MangoWC marker leaked into following bind")
|
||||
end
|
||||
|
||||
print("category marker parser tests: ok")
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Structural regression checks for Keymap's source-backed command catalog."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
catalog = json.loads((ROOT / "command_library.json").read_text(encoding="utf-8"))
|
||||
translations = json.loads((ROOT / "translations" / "en.json").read_text(encoding="utf-8"))
|
||||
panel = (ROOT / "panel.luau").read_text(encoding="utf-8")
|
||||
|
||||
assert catalog["schema"] == 1
|
||||
assert set(catalog["sources"]) == {"noctalia", "hyprland", "niri", "mangowc"}
|
||||
assert all(catalog["sources"][source]["revision"] for source in catalog["sources"])
|
||||
|
||||
entries = catalog["entries"]
|
||||
counts = Counter(entry["source"] for entry in entries)
|
||||
assert counts == {"noctalia": 98, "hyprland": 51, "niri": 135, "mangowc": 78}, counts
|
||||
|
||||
ids = [entry["id"] for entry in entries]
|
||||
assert len(ids) == len(set(ids)), "command-library ids must be unique"
|
||||
assert ids == sorted(ids, key=lambda entry_id: next(
|
||||
(item["source"], item["category"], item["id"])
|
||||
for item in entries if item["id"] == entry_id
|
||||
)), "catalog ordering must remain deterministic"
|
||||
|
||||
category_translations = translations["panel"]["command_library"]["categories"]
|
||||
for entry in entries:
|
||||
assert set(entry) == {"id", "source", "category", "kind", "template", "usage"}
|
||||
assert entry["id"].startswith(entry["source"] + "/")
|
||||
assert entry["category"] in category_translations
|
||||
assert entry["kind"] == ("shell" if entry["source"] == "noctalia" else "native")
|
||||
assert entry["template"].strip() == entry["template"] and entry["template"]
|
||||
assert not re.search(r"[\r\n\x00-\x1f]", entry["template"])
|
||||
assert entry["template"].count("{{") == entry["template"].count("}}")
|
||||
|
||||
by_id = {entry["id"]: entry for entry in entries}
|
||||
for required_id in (
|
||||
"noctalia/panel-open",
|
||||
"noctalia/session",
|
||||
"hyprland/window.close",
|
||||
"hyprland/workspace.swap_monitors",
|
||||
"niri/close-window",
|
||||
"niri/toggle-overview",
|
||||
"mangowc/killclient",
|
||||
"mangowc/reload_config",
|
||||
):
|
||||
assert required_id in by_id, required_id
|
||||
|
||||
assert all(entry["template"].startswith("noctalia msg ")
|
||||
for entry in entries if entry["source"] == "noctalia")
|
||||
assert all(entry["template"].startswith("hl.dsp.") and entry["template"].endswith(")")
|
||||
for entry in entries if entry["source"] == "hyprland")
|
||||
assert all(";" not in entry["template"] and "{" not in re.sub(r"\{\{[^}]+\}\}", "", entry["template"])
|
||||
for entry in entries if entry["source"] == "niri")
|
||||
assert all("#" not in entry["template"]
|
||||
for entry in entries if entry["source"] == "mangowc")
|
||||
|
||||
# The retained UI deliberately renders only a small result window and removes
|
||||
# callbacks that are no longer part of the current render.
|
||||
assert "local visibleCount = math.min(#matches, 6)" in panel
|
||||
assert "finishDynamicCallbackRender()" in panel
|
||||
assert 'registerDynamicCallback(callbackName' in panel
|
||||
|
||||
print(f"command library tests: ok ({len(entries)} entries: {dict(counts)})")
|
||||
@@ -0,0 +1,166 @@
|
||||
local function read(path)
|
||||
local file = assert(io.open(path, "rb"))
|
||||
local value = file:read("*a")
|
||||
file:close()
|
||||
return value
|
||||
end
|
||||
|
||||
local function stateMock()
|
||||
local values, watchers = {}, {}
|
||||
return values, {
|
||||
get = function(key) return values[key] end,
|
||||
set = function(key, value)
|
||||
values[key] = value
|
||||
if watchers[key] ~= nil then watchers[key](value) end
|
||||
end,
|
||||
watch = function(key, callback) watchers[key] = callback end,
|
||||
}
|
||||
end
|
||||
|
||||
local function assertExample(snapshot, compositor, expectedTotal)
|
||||
assert(type(snapshot) == "table" and snapshot.status == "ready", compositor .. ": snapshot not ready")
|
||||
assert(snapshot.compositor == compositor, compositor .. ": wrong compositor")
|
||||
assert(snapshot.total == expectedTotal, compositor .. ": unexpected bind count " .. tostring(snapshot.total))
|
||||
local expected = {
|
||||
["Applications"] = true,
|
||||
["Windows"] = true,
|
||||
["Workspaces"] = true,
|
||||
["Screenshots"] = true,
|
||||
["Noctalia"] = true,
|
||||
["Media"] = true,
|
||||
["Utilities"] = true,
|
||||
}
|
||||
local found = {}
|
||||
for _, category in ipairs(snapshot.categories or {}) do
|
||||
found[category.name] = true
|
||||
for _, bind in ipairs(category.binds or {}) do
|
||||
assert(type(bind.id) == "string" and bind.id ~= "", compositor .. ": bind id missing")
|
||||
assert(#bind.id <= 249, compositor .. ": bind id exceeds DnD target budget")
|
||||
assert(#("before|" .. bind.id) <= 256, compositor .. ": reorder target exceeds core limit")
|
||||
end
|
||||
end
|
||||
for name, _ in pairs(expected) do
|
||||
assert(found[name], compositor .. ": missing category " .. name)
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local values, state = stateMock()
|
||||
local source = read("examples/hyprland.lua")
|
||||
local descriptions = {}
|
||||
local modifierMasks = { SUPER = 64, SHIFT = 1, CTRL = 4, ALT = 8 }
|
||||
for line in source:gmatch("[^\r\n]+") do
|
||||
local combo = line:match('hl%.bind%s*%(%s*"([^"]+)"')
|
||||
local description = line:match('description%s*=%s*"([^"]+)"')
|
||||
if combo ~= nil and description ~= nil then
|
||||
local modmask, key = 0, nil
|
||||
for token in combo:gmatch("[^+]+") do
|
||||
local normalized = token:match("^%s*(.-)%s*$")
|
||||
local mask = modifierMasks[normalized:upper()]
|
||||
if mask ~= nil then modmask = modmask + mask else key = normalized end
|
||||
end
|
||||
assert(key ~= nil, "Hyprland example bind has no non-modifier key: " .. combo)
|
||||
descriptions[#descriptions + 1] = {
|
||||
modmask, key, description, line:match("release%s*=%s*true") ~= nil,
|
||||
}
|
||||
end
|
||||
end
|
||||
local plainBlocks = {}
|
||||
for index, item in ipairs(descriptions) do
|
||||
plainBlocks[#plainBlocks + 1] = table.concat({
|
||||
item[4] == true and "bindrd" or "bindd",
|
||||
"\tmodmask: " .. tostring(item[1]),
|
||||
"\tsubmap: ",
|
||||
"\tkey: " .. item[2],
|
||||
"\tkeycode: 0",
|
||||
"\tcatchall: false",
|
||||
"\tdescription: " .. item[3],
|
||||
"\tdispatcher: __lua",
|
||||
"\targ: " .. tostring(index),
|
||||
}, "\n")
|
||||
end
|
||||
local plain = table.concat(plainBlocks, "\n\n") .. "\n"
|
||||
noctalia = {
|
||||
state = state,
|
||||
getConfig = function(key)
|
||||
local config = {
|
||||
compositor = "hyprland", hyprland_config = "/missing/hyprland.lua",
|
||||
merge_sequential = false, show_undescribed = true,
|
||||
}
|
||||
return config[key]
|
||||
end,
|
||||
getenv = function(key)
|
||||
if key == "HYPRLAND_INSTANCE_SIGNATURE" then return "test" end
|
||||
if key == "HOME" then return "/example-home" end
|
||||
return ""
|
||||
end,
|
||||
expandPath = function(path) return path end,
|
||||
fileExists = function(path) return path == "/example-home/.config/hypr/shortcuts.lua" end,
|
||||
listDir = function(path)
|
||||
return path == "/example-home/.config/hypr"
|
||||
and { "colors.lua", "keymap.lua", "old.lua.backup", "shortcuts.lua" } or nil
|
||||
end,
|
||||
readFile = function(path) return path == "/example-home/.config/hypr/shortcuts.lua" and source or nil end,
|
||||
commandExists = function(command) return command == "hyprctl" end,
|
||||
tr = function(key)
|
||||
return ({ ["category.other"] = "Other", ["category.undescribed"] = "Without description" })[key] or key
|
||||
end,
|
||||
runAsync = function(command, callback)
|
||||
callback({ exitCode = 0, timedOut = false, stdout = command == "hyprctl binds" and plain or "invalid-json" })
|
||||
return true
|
||||
end,
|
||||
json = { decode = function() error("malformed JSON fixture") end },
|
||||
}
|
||||
assert(loadfile("service.luau"))()
|
||||
assertExample(values["keymap.snapshot"], "Hyprland", 40)
|
||||
assert(values["keymap.snapshot"].source == "/example-home/.config/hypr/shortcuts.lua")
|
||||
end
|
||||
|
||||
do
|
||||
local values, state = stateMock()
|
||||
local source = read("examples/niri.kdl")
|
||||
noctalia = {
|
||||
state = state,
|
||||
getConfig = function(key)
|
||||
local config = { compositor = "niri", niri_config = "/missing/niri.kdl", merge_sequential = false }
|
||||
return config[key]
|
||||
end,
|
||||
getenv = function(key)
|
||||
if key == "NIRI_SOCKET" then return "test" end
|
||||
if key == "HOME" then return "/example-home" end
|
||||
return ""
|
||||
end,
|
||||
fileExists = function(path) return path == "/example-home/.config/niri/config.kdl" end,
|
||||
readFile = function(path) return path == "/example-home/.config/niri/config.kdl" and source or nil end,
|
||||
tr = function(key) return key == "category.other" and "Other" or key end,
|
||||
}
|
||||
assert(loadfile("niri_service.luau"))()
|
||||
assertExample(values["keymap.snapshot"], "Niri", 39)
|
||||
assert(values["keymap.snapshot"].source == "/example-home/.config/niri/config.kdl")
|
||||
end
|
||||
|
||||
do
|
||||
local values, state = stateMock()
|
||||
local source = read("examples/mangowc.conf")
|
||||
noctalia = {
|
||||
state = state,
|
||||
getConfig = function(key)
|
||||
local config = { compositor = "mangowc", mangowc_config = "/missing/mangowc.conf", merge_sequential = false }
|
||||
return config[key]
|
||||
end,
|
||||
getenv = function(key)
|
||||
if key == "MANGO_INSTANCE_SIGNATURE" then return "test" end
|
||||
if key == "HOME" then return "/example-home" end
|
||||
return ""
|
||||
end,
|
||||
expandPath = function(path) return path end,
|
||||
fileExists = function(path) return path == "/example-home/.config/mango/config.conf" end,
|
||||
readFile = function(path) return path == "/example-home/.config/mango/config.conf" and source or nil end,
|
||||
tr = function(key) return key == "category.other" and "Other" or key end,
|
||||
}
|
||||
assert(loadfile("mangowc_service.luau"))()
|
||||
assertExample(values["keymap.snapshot"], "MangoWC", 40)
|
||||
assert(values["keymap.snapshot"].source == "/example-home/.config/mango/config.conf")
|
||||
end
|
||||
|
||||
print("example config tests: ok")
|
||||
@@ -0,0 +1,115 @@
|
||||
local function xorByte(left, right)
|
||||
local result, place = 0, 1
|
||||
for _ = 1, 8 do
|
||||
if left % 2 ~= right % 2 then result = result + place end
|
||||
left, right, place = math.floor(left / 2), math.floor(right / 2), place * 2
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function fingerprint(value)
|
||||
local hash = 2166136261
|
||||
for index = 1, #value do
|
||||
local low = hash % 256
|
||||
hash = hash - low + xorByte(low, value:byte(index))
|
||||
hash = (hash * 403 + (hash % 256) * 16777216) % 4294967296
|
||||
end
|
||||
return string.format("%08x", hash)
|
||||
end
|
||||
|
||||
local function hex(value)
|
||||
local output = {}
|
||||
for index = 1, #value do output[#output + 1] = string.format("%02x", value:byte(index)) end
|
||||
return table.concat(output)
|
||||
end
|
||||
|
||||
local function hiddenSnippet(compositor, comment, original)
|
||||
local indent = original:match("^([\t ]*)") or ""
|
||||
local marker = indent .. comment .. " Keymap hidden v1"
|
||||
local blockId = fingerprint(compositor .. "\0" .. original)
|
||||
local encoded = hex(original)
|
||||
local lines = { marker .. " begin " .. blockId .. " " .. fingerprint(original) }
|
||||
for offset = 1, #encoded, 96 do lines[#lines + 1] = marker .. " data " .. encoded:sub(offset, offset + 95) end
|
||||
lines[#lines + 1] = marker .. " end " .. blockId
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
local function stateMock()
|
||||
local values, watchers = {}, {}
|
||||
return values, {
|
||||
get = function(key) return values[key] end,
|
||||
set = function(key, value)
|
||||
values[key] = value
|
||||
if watchers[key] then watchers[key](value) end
|
||||
end,
|
||||
watch = function(key, callback) watchers[key] = callback end,
|
||||
}
|
||||
end
|
||||
|
||||
local function assertHidden(snapshot, source, original, raw)
|
||||
assert(snapshot.status == "ready", "hidden-only config must be ready")
|
||||
assert(snapshot.total == 0 and #(snapshot.categories or {}) == 0, "hidden bind leaked into active data")
|
||||
assert(#(snapshot.hidden or {}) == 1, "hidden target missing")
|
||||
local target = snapshot.hidden[1]
|
||||
assert(target.hidden == true and target.source == source, "hidden provenance missing")
|
||||
assert(target.start_line <= target.end_line and target.raw_snippet == raw, "hidden raw block/range mismatch")
|
||||
assert(target.fingerprint == fingerprint(raw), "hidden block fingerprint mismatch")
|
||||
assert(target.original_fingerprint == fingerprint(original), "original fingerprint mismatch")
|
||||
assert(target.capabilities.restore == true and target.capabilities.delete == true, "hidden capabilities missing")
|
||||
assert(type(target.id) == "string" and target.id ~= "", "hidden id missing")
|
||||
assert(#(snapshot.warnings or {}) >= 1, "malformed sentinel was not reported")
|
||||
end
|
||||
|
||||
do
|
||||
local values, state = stateMock()
|
||||
local root, child = "/tmp/hidden/hyprland.lua", "/tmp/hidden/child.lua"
|
||||
local original = '-- Keymap bind-category: Media\nhl.bind("SUPER + H", hl.dsp.exec_cmd("true"), { description = "Hidden Hypr" })'
|
||||
local raw = hiddenSnippet("Hyprland", "--", original)
|
||||
local files = {
|
||||
[root] = 'require("child")\n',
|
||||
[child] = raw .. "\n-- Keymap hidden v1 begin BAD bad\n",
|
||||
}
|
||||
noctalia = {
|
||||
state = state, getConfig = function(key) return ({ compositor = "hyprland", hyprland_config = root })[key] end,
|
||||
getenv = function() return "" end, expandPath = function(path) return path end,
|
||||
fileExists = function(path) return files[path] ~= nil end, readFile = function(path) return files[path] end,
|
||||
commandExists = function() return true end, tr = function(key) return key end,
|
||||
runAsync = function(_, callback) callback({ exitCode = 0, timedOut = false, stdout = "[]" }); return true end,
|
||||
json = { decode = function() return {} end },
|
||||
}
|
||||
assert(loadfile("service.luau"))()
|
||||
assertHidden(values["keymap.snapshot"], child, original, raw)
|
||||
end
|
||||
|
||||
do
|
||||
local values, state = stateMock()
|
||||
local root, child = "/tmp/hidden/config.kdl", "/tmp/hidden/child.kdl"
|
||||
local original = ' // Keymap bind-category: Media\n Super+H hotkey-overlay-title="Hidden Niri" { spawn-sh "true"; }'
|
||||
local raw = hiddenSnippet("Niri", "//", original)
|
||||
local files = { [root] = 'include "child.kdl"\n', [child] = "binds {\n" .. raw .. "\n}\n// Keymap hidden V1 begin bad bad\n" }
|
||||
noctalia = {
|
||||
state = state, getConfig = function(key) return ({ compositor = "niri", niri_config = root })[key] end,
|
||||
getenv = function() return "" end, fileExists = function(path) return files[path] ~= nil end,
|
||||
readFile = function(path) return files[path] end, tr = function(key) return key end,
|
||||
}
|
||||
assert(loadfile("niri_service.luau"))()
|
||||
assertHidden(values["keymap.snapshot"], child, original, raw)
|
||||
end
|
||||
|
||||
do
|
||||
local values, state = stateMock()
|
||||
local root, child = "/tmp/hidden/mango.conf", "/tmp/hidden/child.conf"
|
||||
local original = '# Keymap bind-category: Media\nbind=SUPER,H,spawn_shell,true #"Hidden Mango"'
|
||||
local raw = hiddenSnippet("MangoWC", "#", original)
|
||||
local files = { [root] = "source=child.conf\n", [child] = raw .. "\n# Keymap hidden v1 begin 00000000 00000000\n" }
|
||||
noctalia = {
|
||||
state = state, getConfig = function(key) return ({ compositor = "mangowc", mangowc_config = root })[key] end,
|
||||
getenv = function() return "" end, expandPath = function(path) return path end,
|
||||
fileExists = function(path) return files[path] ~= nil end, readFile = function(path) return files[path] end,
|
||||
tr = function(key) return key end,
|
||||
}
|
||||
assert(loadfile("mangowc_service.luau"))()
|
||||
assertHidden(values["keymap.snapshot"], child, original, raw)
|
||||
end
|
||||
|
||||
print("hidden sentinel parser tests: ok")
|
||||
@@ -0,0 +1,67 @@
|
||||
local stateValues = {}
|
||||
local watchers = {}
|
||||
local sourcePath = "/tmp/keybind-test/keybind.lua"
|
||||
local source = table.concat({
|
||||
"-- 1. Applications",
|
||||
[[hl.bind("SUPER + RETURN", hl.dsp.exec_cmd("kitty"), { description = "Terminal" })]],
|
||||
[=[hl.bind("SUPER + Q", hl.dsp.exec_cmd([[browser --private]]), { description = "Browser" })]=],
|
||||
[[hl.bind("SUPER + P", hl.dsp.exec_cmd("printf \"ok\""), { description = "Quoted" })]],
|
||||
[[hl.bind("SUPER + W", hl.dsp.window.close(), { description = "Close Window" })]],
|
||||
}, "\n") .. "\n"
|
||||
|
||||
local liveBinds = {
|
||||
{ key = "RETURN", modmask = 64, description = "Terminal", has_description = true, dispatcher = "__lua" },
|
||||
{ key = "Q", modmask = 64, description = "Browser", has_description = true, dispatcher = "__lua" },
|
||||
{ key = "P", modmask = 64, description = "Quoted", has_description = true, dispatcher = "__lua" },
|
||||
{ key = "W", modmask = 64, description = "Close Window", has_description = true, dispatcher = "__lua" },
|
||||
}
|
||||
|
||||
noctalia = {
|
||||
getConfig = function(key)
|
||||
local values = {
|
||||
compositor = "hyprland", hyprland_config = sourcePath,
|
||||
show_undescribed = true, merge_sequential = false,
|
||||
}
|
||||
return values[key]
|
||||
end,
|
||||
getenv = function() return "" end,
|
||||
expandPath = function(path) return path end,
|
||||
readFile = function(path) return path == sourcePath and source or nil end,
|
||||
fileExists = function(path) return path == sourcePath end,
|
||||
commandExists = function(command) return command == "hyprctl" end,
|
||||
runAsync = function(_command, callback, _timeout)
|
||||
callback({ exitCode = 0, timedOut = false, stdout = "live-binds" })
|
||||
return true
|
||||
end,
|
||||
json = { decode = function(value) assert(value == "live-binds") return liveBinds end },
|
||||
tr = function(key)
|
||||
if key == "category.other" then return "Other" end
|
||||
if key == "category.undescribed" then return "Without description" end
|
||||
return key
|
||||
end,
|
||||
state = {
|
||||
get = function(key) return stateValues[key] end,
|
||||
set = function(key, value)
|
||||
stateValues[key] = value
|
||||
if watchers[key] ~= nil then watchers[key](value) end
|
||||
end,
|
||||
watch = function(key, callback) watchers[key] = callback end,
|
||||
},
|
||||
}
|
||||
|
||||
assert(loadfile("service.luau"))()
|
||||
|
||||
local snapshot = stateValues["keymap.snapshot"]
|
||||
assert(type(snapshot) == "table" and snapshot.status == "ready", "service did not publish a ready snapshot")
|
||||
local byDescription = {}
|
||||
for _, category in ipairs(snapshot.categories or {}) do
|
||||
for _, bind in ipairs(category.binds or {}) do byDescription[bind.description] = bind end
|
||||
end
|
||||
|
||||
assert(byDescription.Terminal.command == "kitty", "double-quoted exec_cmd was not parsed")
|
||||
assert(byDescription.Terminal.capabilities.command == true, "double-quoted command was not marked editable")
|
||||
assert(byDescription.Browser.command == "browser --private", "long-string exec_cmd regressed")
|
||||
assert(byDescription.Quoted.command == 'printf "ok"', "escaped quoted command was decoded incorrectly")
|
||||
assert(byDescription["Close Window"].capabilities.command == false, "native action was exposed as a shell command")
|
||||
|
||||
print("hypr command parser tests: ok")
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TRANSLATIONS = json.loads((ROOT / "translations" / "en.json").read_text())
|
||||
|
||||
|
||||
def resolves(key: str) -> bool:
|
||||
value = TRANSLATIONS
|
||||
for part in key.split("."):
|
||||
if not isinstance(value, dict) or part not in value:
|
||||
return False
|
||||
value = value[part]
|
||||
return isinstance(value, (str, dict))
|
||||
|
||||
|
||||
required = set()
|
||||
manifest = (ROOT / "plugin.toml").read_text()
|
||||
required.update(re.findall(r'(?:label_key|description_key)\s*=\s*"([^"]+)"', manifest))
|
||||
|
||||
for path in ROOT.glob("*.luau"):
|
||||
source = path.read_text()
|
||||
required.update(re.findall(r'(?:noctalia\.)?trp?\(\s*"([^"]+)"', source))
|
||||
required.update(re.findall(r'"((?:actions|category)\.[a-z0-9_.]+)"', source))
|
||||
|
||||
missing = sorted(key for key in required if not key.endswith(".") and not resolves(key))
|
||||
assert not missing, "missing English translation keys: " + ", ".join(missing)
|
||||
|
||||
# Physical modifier legends are standardized key names. Other literal UI prose
|
||||
# must go through noctalia.tr so new locales can translate it.
|
||||
allowed_key_labels = {"Super", "Ctrl", "Shift", "Alt"}
|
||||
literal_ui = []
|
||||
panel_source = (ROOT / "panel.luau").read_text()
|
||||
for match in re.finditer(r'\b(?:text|placeholder|tooltip)\s*=\s*"([^"]+)"', panel_source):
|
||||
if match.group(1) not in allowed_key_labels:
|
||||
literal_ui.append(match.group(1))
|
||||
assert not literal_ui, "untranslated literal UI strings: " + ", ".join(literal_ui)
|
||||
|
||||
print(f"i18n tests: ok ({len(required)} referenced keys)")
|
||||
@@ -0,0 +1,118 @@
|
||||
local sourceFile = assert(io.open("panel.luau", "rb"))
|
||||
local source = sourceFile:read("*a")
|
||||
sourceFile:close()
|
||||
|
||||
local beginMarker = "-- BEGIN KEYBOARD LAYOUT DATA"
|
||||
local endMarker = "-- END KEYBOARD LAYOUT DATA"
|
||||
local beginAt = assert(source:find(beginMarker, 1, true), "layout data start marker missing")
|
||||
local bodyAt = assert(source:find("\n", beginAt, true)) + 1
|
||||
local endAt = assert(source:find(endMarker, bodyAt, true), "layout data end marker missing")
|
||||
local layoutSource = source:sub(bodyAt, endAt - 1)
|
||||
local loader = assert(load(layoutSource .. [[
|
||||
return {
|
||||
order = KEYBOARD_LAYOUT_ORDER,
|
||||
layouts = KEYBOARD_LAYOUTS,
|
||||
}
|
||||
]], "keyboard layout data", "t", _G))
|
||||
local data = loader()
|
||||
|
||||
local expectedOrder = { "100", "96", "80", "75", "65", "60" }
|
||||
local expectedRows = { ["100"] = 6, ["96"] = 6, ["80"] = 6, ["75"] = 6, ["65"] = 5, ["60"] = 5 }
|
||||
local expectedPixels = { ["100"] = 1100, ["96"] = 908, ["80"] = 884, ["75"] = 764, ["65"] = 764, ["60"] = 716 }
|
||||
|
||||
local function keyWidth(units)
|
||||
return math.floor(units * 44 + (units - 1) * 4 + 0.5)
|
||||
end
|
||||
|
||||
local globalIds = {}
|
||||
for orderIndex, layoutId in ipairs(expectedOrder) do
|
||||
assert(data.order[orderIndex] == layoutId, "unexpected layout order at " .. orderIndex)
|
||||
local layout = assert(data.layouts[layoutId], "missing layout " .. layoutId)
|
||||
assert(layout.id == layoutId)
|
||||
assert(#layout.rows == expectedRows[layoutId], "unexpected row count for " .. layoutId)
|
||||
assert(keyWidth(layout.rowUnits) == expectedPixels[layoutId], "unexpected pixel width for " .. layoutId)
|
||||
|
||||
local layoutIds = {}
|
||||
local hasLetters, hasDigits = {}, {}
|
||||
for rowIndex, row in ipairs(layout.rows) do
|
||||
local units = 0
|
||||
assert(#row > 0, "empty row in " .. layoutId)
|
||||
for _, spec in ipairs(row) do
|
||||
local isSpacer = spec.spacer ~= nil
|
||||
local isKey = spec.id ~= nil
|
||||
assert(isSpacer ~= isKey, "spec must be exactly one of spacer or key in " .. layoutId)
|
||||
local width = tonumber(isSpacer and spec.spacer or spec.units)
|
||||
assert(width and width > 0 and width * 4 % 1 == 0, "invalid quarter-unit width in " .. layoutId)
|
||||
units = units + width
|
||||
if isKey then
|
||||
assert(type(spec.id) == "string" and spec.id:match("^[a-z0-9_]+$"), "invalid key id")
|
||||
assert(not layoutIds[spec.id], "duplicate key id " .. spec.id .. " in " .. layoutId)
|
||||
layoutIds[spec.id] = true
|
||||
assert(type(spec.label) == "string" and spec.label ~= "", "key label missing for " .. spec.id)
|
||||
if spec.bindable == false then
|
||||
assert(spec.code == nil, "passive key must not expose a compositor code")
|
||||
else
|
||||
assert(type(spec.code) == "string" and spec.code ~= "", "bindable code missing for " .. spec.id)
|
||||
end
|
||||
if globalIds[spec.id] ~= nil then
|
||||
assert(globalIds[spec.id] == spec.code, "key id changed semantic code: " .. spec.id)
|
||||
else
|
||||
globalIds[spec.id] = spec.code or false
|
||||
end
|
||||
local letter = spec.id:match("^key_([a-z])$")
|
||||
local digit = spec.id:match("^digit_(%d)$")
|
||||
if letter then hasLetters[letter] = true end
|
||||
if digit then hasDigits[digit] = true end
|
||||
end
|
||||
end
|
||||
assert(math.abs(units - layout.rowUnits) < 0.0001,
|
||||
string.format("%s row %d is %.2fu instead of %.2fu", layoutId, rowIndex, units, layout.rowUnits))
|
||||
end
|
||||
for byte = string.byte("a"), string.byte("z") do
|
||||
assert(hasLetters[string.char(byte)], "letter missing from " .. layoutId)
|
||||
end
|
||||
for digit = 0, 9 do assert(hasDigits[tostring(digit)], "digit missing from " .. layoutId) end
|
||||
end
|
||||
assert(#data.order == #expectedOrder, "unexpected extra keyboard layout")
|
||||
|
||||
local function hasId(layoutId, wanted)
|
||||
for _, row in ipairs(data.layouts[layoutId].rows) do
|
||||
for _, spec in ipairs(row) do if spec.id == wanted then return true end end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function keyX(layoutId, rowIndex, wanted)
|
||||
local x = 0
|
||||
for _, spec in ipairs(data.layouts[layoutId].rows[rowIndex]) do
|
||||
if spec.id == wanted then return x end
|
||||
x = x + tonumber(spec.spacer or spec.units)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
assert(hasId("100", "kp_1") and hasId("96", "kp_1"), "full layouts need a numpad")
|
||||
for _, layoutId in ipairs({ "80", "75", "65", "60" }) do
|
||||
assert(not hasId(layoutId, "kp_1"), layoutId .. " unexpectedly contains a numpad")
|
||||
end
|
||||
for _, layoutId in ipairs({ "100", "96", "80", "75" }) do
|
||||
assert(hasId(layoutId, "f1"), layoutId .. " needs a function row")
|
||||
end
|
||||
assert(not hasId("65", "f1") and not hasId("60", "f1"))
|
||||
assert(hasId("65", "arrow_up") and not hasId("60", "arrow_up"))
|
||||
assert(hasId("65", "fn") and not hasId("75", "fn"), "Fn must be passive and limited to the 65% view")
|
||||
|
||||
assert(keyX("96", 5, "arrow_up") == 14 and keyX("96", 5, "kp_1") == 15)
|
||||
assert(keyX("96", 6, "arrow_left") == 13 and keyX("96", 6, "arrow_down") == 14)
|
||||
assert(keyX("96", 6, "arrow_right") == 15 and keyX("96", 6, "kp_0") == 16)
|
||||
for _, layoutId in ipairs({ "75", "65" }) do
|
||||
local shiftRow = layoutId == "75" and 5 or 4
|
||||
local bottomRow = layoutId == "75" and 6 or 5
|
||||
assert(keyX(layoutId, shiftRow, "arrow_up") == 14)
|
||||
assert(keyX(layoutId, shiftRow, "end") == 15)
|
||||
assert(keyX(layoutId, bottomRow, "arrow_left") == 13)
|
||||
assert(keyX(layoutId, bottomRow, "arrow_down") == 14)
|
||||
assert(keyX(layoutId, bottomRow, "arrow_right") == 15)
|
||||
end
|
||||
|
||||
print("keyboard layout tests: ok")
|
||||
@@ -0,0 +1,413 @@
|
||||
local files = { ["command_library.json"] = "test-command-library" }
|
||||
local stateValues = {}
|
||||
local watchers = {}
|
||||
local commands = {}
|
||||
local failure = {
|
||||
preflight = false,
|
||||
validator = false,
|
||||
reloadOnce = false,
|
||||
}
|
||||
local reloadAttempts = 0
|
||||
|
||||
local function commandResult(command)
|
||||
local result = { exitCode = 0, timedOut = false }
|
||||
if failure.preflight and command:find("[ ! -L ", 1, true) == 1 then
|
||||
result.exitCode = 1
|
||||
elseif failure.validator and command == failure.validatorCommand then
|
||||
result.exitCode = 1
|
||||
elseif failure.reloadOnce and command == failure.reloadCommand then
|
||||
reloadAttempts = reloadAttempts + 1
|
||||
if reloadAttempts == 1 then result.exitCode = 1 end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
noctalia = {
|
||||
state = {
|
||||
get = function(key) return stateValues[key] end,
|
||||
set = function(key, value)
|
||||
stateValues[key] = value
|
||||
if watchers[key] ~= nil then watchers[key](value) end
|
||||
end,
|
||||
watch = function(key, callback) watchers[key] = callback end,
|
||||
},
|
||||
readFile = function(path) return files[path] end,
|
||||
fileExists = function(path) return files[path] ~= nil end,
|
||||
writeFile = function(path, content)
|
||||
files[path] = content
|
||||
return true
|
||||
end,
|
||||
renameFile = function(source, target)
|
||||
if files[source] == nil then return false end
|
||||
files[target] = files[source]
|
||||
files[source] = nil
|
||||
return true
|
||||
end,
|
||||
removeFile = function(path)
|
||||
files[path] = nil
|
||||
return true
|
||||
end,
|
||||
runAsync = function(command, callback, timeout)
|
||||
commands[#commands + 1] = { command = command, timeout = timeout }
|
||||
callback(commandResult(command))
|
||||
return true
|
||||
end,
|
||||
json = { decode = function(encoded)
|
||||
if encoded == "test-command-library" then
|
||||
return {
|
||||
schema = 1,
|
||||
entries = {
|
||||
{ id = "hyprland/window.close", source = "hyprland", kind = "native", template = "hl.dsp.window.close()" },
|
||||
{ id = "hyprland/exec_cmd", source = "hyprland", kind = "native", template = "hl.dsp.exec_cmd({{command}})" },
|
||||
{ id = "niri/close-window", source = "niri", kind = "native", template = "close-window" },
|
||||
{ id = "mangowc/killclient", source = "mangowc", kind = "native", template = "killclient" },
|
||||
},
|
||||
}
|
||||
end
|
||||
return {}
|
||||
end },
|
||||
}
|
||||
|
||||
local CASES = {
|
||||
{
|
||||
id = "hyprland",
|
||||
compositor = "Hyprland",
|
||||
rootName = "hyprland.lua",
|
||||
managedName = "keymap.lua",
|
||||
comment = "--",
|
||||
root = "-- user config\n",
|
||||
includeLine = 'require("keymap")',
|
||||
validatorPrefix = "Hyprland --verify-config -c ",
|
||||
reloadCommand = "hyprctl reload",
|
||||
first = {
|
||||
modifiers = { "SUPER", "SHIFT" }, keys = { "A" }, activation = "press",
|
||||
command = "noctalia msg panel-open launcher", description = "Open app", category = "Applications",
|
||||
entry = '-- 1. Applications\nhl.bind("SUPER + SHIFT + A", hl.dsp.exec_cmd([[noctalia msg panel-open launcher]]), { description = "Open app" })\n',
|
||||
},
|
||||
second = {
|
||||
modifiers = { "SUPER" }, keys = { "B" }, activation = "release",
|
||||
command = "second-command", description = "Second action", category = "System",
|
||||
entry = '-- 1. System\nhl.bind("SUPER + B", hl.dsp.exec_cmd([[second-command]]), { release = true, description = "Second action" })\n',
|
||||
},
|
||||
},
|
||||
{
|
||||
id = "niri",
|
||||
compositor = "Niri",
|
||||
rootName = "config.kdl",
|
||||
managedName = "keymap.kdl",
|
||||
comment = "//",
|
||||
root = "// user config\n",
|
||||
includeLine = 'include "keymap.kdl"',
|
||||
validatorPrefix = "niri validate -c ",
|
||||
reloadCommand = "niri msg action load-config-file",
|
||||
first = {
|
||||
modifiers = { "SUPER", "SHIFT" }, keys = { "A" }, activation = "press",
|
||||
command = "launch-app", description = "Open app", category = "Applications",
|
||||
entry = ' //"Applications"\n Mod+Shift+A repeat=false hotkey-overlay-title="Open app" { spawn-sh "launch-app"; }\n',
|
||||
},
|
||||
second = {
|
||||
modifiers = { "CTRL", "ALT" }, keys = { "B" }, activation = "press",
|
||||
command = "second-command", description = "Second action", category = "System",
|
||||
entry = ' //"System"\n Ctrl+Alt+B repeat=false hotkey-overlay-title="Second action" { spawn-sh "second-command"; }\n',
|
||||
},
|
||||
},
|
||||
{
|
||||
id = "mangowc",
|
||||
compositor = "MangoWC",
|
||||
rootName = "config.conf",
|
||||
managedName = "keymap.conf",
|
||||
comment = "#",
|
||||
root = "# user config\n",
|
||||
includeLine = "source=./keymap.conf",
|
||||
validatorPrefix = "mango -c ",
|
||||
validatorSuffix = " -p",
|
||||
reloadCommand = "mmsg dispatch reload_config",
|
||||
first = {
|
||||
modifiers = { "SUPER", "ALT" }, keys = { "A" }, activation = "release",
|
||||
command = "launch-app", description = "Open app", category = "Applications",
|
||||
entry = '# Keymap category: Applications\nbindr=SUPER+ALT,A,spawn_shell,launch-app #"Open app"\n',
|
||||
},
|
||||
second = {
|
||||
modifiers = {}, keys = { "B" }, activation = "press",
|
||||
command = "second-command", description = "Second action", category = "System",
|
||||
entry = '# Keymap category: System\nbind=NONE,B,spawn_shell,second-command #"Second action"\n',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local function reset(case)
|
||||
files = {}
|
||||
stateValues = {}
|
||||
commands = {}
|
||||
failure = { preflight = false, validator = false, reloadOnce = false }
|
||||
reloadAttempts = 0
|
||||
local directory = "/tmp/keymap-create-test/" .. case.id
|
||||
case.rootPath = directory .. "/" .. case.rootName
|
||||
case.managedPath = directory .. "/" .. case.managedName
|
||||
files[case.rootPath] = case.root
|
||||
stateValues["keymap.snapshot"] = {
|
||||
status = "ready",
|
||||
compositor = case.compositor,
|
||||
source = case.rootPath,
|
||||
categories = {},
|
||||
hidden = {},
|
||||
}
|
||||
end
|
||||
|
||||
local function createRequest(case, spec, requestId, source)
|
||||
return {
|
||||
request_id = requestId,
|
||||
compositor = case.compositor,
|
||||
source = source or case.rootPath,
|
||||
modifiers = spec.modifiers,
|
||||
keys = spec.keys,
|
||||
activation = spec.activation,
|
||||
command = spec.command,
|
||||
command_kind = spec.command_kind,
|
||||
library_entry_id = spec.library_entry_id,
|
||||
description = spec.description,
|
||||
category = spec.category,
|
||||
}
|
||||
end
|
||||
|
||||
local function submit(case, spec, requestId, source)
|
||||
noctalia.state.set("keymap.create_request", createRequest(case, spec, requestId, source))
|
||||
local result = stateValues["keymap.create_result"]
|
||||
assert(type(result) == "table", case.id .. ": missing create result")
|
||||
assert(result.request_id == requestId, case.id .. ": result request id mismatch")
|
||||
return result
|
||||
end
|
||||
|
||||
local function includeBlock(case)
|
||||
return case.comment .. " BEGIN Keymap managed include\n"
|
||||
.. case.includeLine .. "\n"
|
||||
.. case.comment .. " END Keymap managed include"
|
||||
end
|
||||
|
||||
local function expectedRoot(case)
|
||||
return case.root .. "\n" .. includeBlock(case) .. "\n"
|
||||
end
|
||||
|
||||
local function managedHeader(case)
|
||||
return case.comment .. " Managed by Noctalia Keymap.\n"
|
||||
.. case.comment .. " Existing entries are preserved; new entries are appended.\n"
|
||||
end
|
||||
|
||||
local function expectedManaged(case, entries)
|
||||
local content
|
||||
if case.compositor == "Niri" then
|
||||
content = managedHeader(case) .. "binds {\n"
|
||||
for _, entry in ipairs(entries) do content = content .. entry end
|
||||
return content .. "}\n"
|
||||
end
|
||||
if case.compositor == "MangoWC" then
|
||||
content = managedHeader(case) .. "\nkeymode=default\n\n"
|
||||
else
|
||||
content = managedHeader(case) .. "\n"
|
||||
end
|
||||
for _, entry in ipairs(entries) do content = content .. entry end
|
||||
return content
|
||||
end
|
||||
|
||||
local function validatorCommand(case)
|
||||
return case.validatorPrefix .. "'" .. case.rootPath .. "'" .. (case.validatorSuffix or "")
|
||||
end
|
||||
|
||||
local function assertNormalCommandSequence(case, label)
|
||||
assert(#commands == 3, label .. ": expected preflight, validator and reload")
|
||||
assert(commands[1].command:find("[ ! -L ", 1, true) == 1, label .. ": preflight was not first")
|
||||
assert(commands[1].timeout == 2000, label .. ": unexpected preflight timeout")
|
||||
assert(commands[2].command == validatorCommand(case), label .. ": wrong validator command")
|
||||
assert(commands[2].timeout == 8000, label .. ": unexpected validator timeout")
|
||||
assert(commands[3].command == case.reloadCommand, label .. ": wrong reload command")
|
||||
assert(commands[3].timeout == 5000, label .. ": unexpected reload timeout")
|
||||
end
|
||||
|
||||
local function assertNoTemporaryFiles(label)
|
||||
for path, _ in pairs(files) do
|
||||
assert(not path:match("%.keymap%-.+%.tmp$"), label .. ": temporary file left behind: " .. path)
|
||||
end
|
||||
end
|
||||
|
||||
local function runHappyPath(case)
|
||||
reset(case)
|
||||
local firstResult = submit(case, case.first, case.id .. "-first")
|
||||
assert(firstResult.ok == true, case.id .. ": first create failed: " .. tostring(firstResult.error))
|
||||
assert(firstResult.managed_path == case.managedPath, case.id .. ": wrong managed path")
|
||||
assert(files[case.rootPath] == expectedRoot(case), case.id .. ": marked include differs")
|
||||
assert(files[case.managedPath] == expectedManaged(case, { case.first.entry }),
|
||||
case.id .. ": first managed content differs\n" .. tostring(files[case.managedPath]))
|
||||
assertNormalCommandSequence(case, case.id .. " first create")
|
||||
assertNoTemporaryFiles(case.id .. " first create")
|
||||
|
||||
commands = {}
|
||||
local secondResult = submit(case, case.second, case.id .. "-second")
|
||||
assert(secondResult.ok == true, case.id .. ": second create failed: " .. tostring(secondResult.error))
|
||||
assert(files[case.rootPath] == expectedRoot(case), case.id .. ": second create duplicated include")
|
||||
local _, includeCount = files[case.rootPath]:gsub(case.includeLine:gsub("([^%w])", "%%%1"), "")
|
||||
assert(includeCount == 1, case.id .. ": include line count is " .. tostring(includeCount))
|
||||
assert(files[case.managedPath] == expectedManaged(case, { case.first.entry, case.second.entry }),
|
||||
case.id .. ": second managed content differs\n" .. tostring(files[case.managedPath]))
|
||||
assertNormalCommandSequence(case, case.id .. " second create")
|
||||
|
||||
local rootBefore = files[case.rootPath]
|
||||
local managedBefore = files[case.managedPath]
|
||||
commands = {}
|
||||
local duplicateResult = submit(case, case.second, case.id .. "-duplicate")
|
||||
assert(duplicateResult.ok == true, case.id .. ": exact duplicate was not idempotent")
|
||||
assert(files[case.rootPath] == rootBefore and files[case.managedPath] == managedBefore,
|
||||
case.id .. ": exact duplicate changed files")
|
||||
assert(#commands == 1 and commands[1].command:find("[ ! -L ", 1, true) == 1,
|
||||
case.id .. ": exact duplicate unexpectedly validated or reloaded")
|
||||
assertNoTemporaryFiles(case.id .. " duplicate")
|
||||
end
|
||||
|
||||
local function runVerifyRollback(case)
|
||||
reset(case)
|
||||
failure.validator = true
|
||||
failure.validatorCommand = validatorCommand(case)
|
||||
local result = submit(case, case.first, case.id .. "-verify-rollback")
|
||||
assert(result.ok == false and result.error == "verify_failed",
|
||||
case.id .. ": expected verify_failed, got " .. tostring(result.error))
|
||||
assert(files[case.rootPath] == case.root, case.id .. ": verify rollback did not restore root")
|
||||
assert(files[case.managedPath] == nil, case.id .. ": verify rollback left a new managed file")
|
||||
assert(#commands == 2 and commands[2].command == validatorCommand(case),
|
||||
case.id .. ": unexpected verify rollback command sequence")
|
||||
assertNoTemporaryFiles(case.id .. " verify rollback")
|
||||
end
|
||||
|
||||
local function runReloadRollbackOnAppend(case)
|
||||
reset(case)
|
||||
local baseline = submit(case, case.first, case.id .. "-reload-baseline")
|
||||
assert(baseline.ok == true, case.id .. ": reload rollback baseline failed")
|
||||
local rootBefore = files[case.rootPath]
|
||||
local managedBefore = files[case.managedPath]
|
||||
|
||||
commands = {}
|
||||
failure.reloadOnce = true
|
||||
failure.reloadCommand = case.reloadCommand
|
||||
reloadAttempts = 0
|
||||
local result = submit(case, case.second, case.id .. "-reload-rollback")
|
||||
assert(result.ok == false and result.error == "reload_failed",
|
||||
case.id .. ": expected reload_failed, got " .. tostring(result.error))
|
||||
assert(files[case.rootPath] == rootBefore, case.id .. ": reload rollback changed root")
|
||||
assert(files[case.managedPath] == managedBefore, case.id .. ": reload rollback did not restore managed file")
|
||||
assert(#commands == 4, case.id .. ": expected preflight, validator, failed reload and recovery reload")
|
||||
assert(commands[1].command:find("[ ! -L ", 1, true) == 1, case.id .. ": missing preflight")
|
||||
assert(commands[2].command == validatorCommand(case), case.id .. ": missing validator")
|
||||
assert(commands[3].command == case.reloadCommand and commands[4].command == case.reloadCommand,
|
||||
case.id .. ": restored configuration was not reloaded")
|
||||
assert(reloadAttempts == 2, case.id .. ": expected two reload attempts")
|
||||
assertNoTemporaryFiles(case.id .. " reload rollback")
|
||||
end
|
||||
|
||||
assert(loadfile("writer_service.luau"))()
|
||||
|
||||
for _, case in ipairs(CASES) do
|
||||
runHappyPath(case)
|
||||
runVerifyRollback(case)
|
||||
runReloadRollbackOnAppend(case)
|
||||
end
|
||||
|
||||
local NATIVE_CASES = {
|
||||
{
|
||||
base = CASES[1], id = "hypr-native", command = "hl.dsp.window.close()",
|
||||
library_entry_id = "hyprland/window.close",
|
||||
entry = '-- 1. Windows\nhl.bind("SUPER + N", hl.dsp.window.close(), { description = "Close window" })\n',
|
||||
},
|
||||
{
|
||||
base = CASES[2], id = "niri-native", command = "close-window",
|
||||
library_entry_id = "niri/close-window",
|
||||
entry = ' //"Windows"\n Mod+N repeat=false hotkey-overlay-title="Close window" { close-window; }\n',
|
||||
},
|
||||
{
|
||||
base = CASES[3], id = "mango-native", command = "killclient",
|
||||
library_entry_id = "mangowc/killclient",
|
||||
entry = '# Keymap category: Windows\nbind=SUPER,N,killclient #"Close window"\n',
|
||||
},
|
||||
}
|
||||
|
||||
for _, native in ipairs(NATIVE_CASES) do
|
||||
local case = native.base
|
||||
reset(case)
|
||||
local spec = {
|
||||
modifiers = { "SUPER" }, keys = { "N" }, activation = "press",
|
||||
command = native.command, command_kind = "native",
|
||||
library_entry_id = native.library_entry_id,
|
||||
description = "Close window", category = "Windows",
|
||||
}
|
||||
local result = submit(case, spec, native.id)
|
||||
assert(result.ok == true, native.id .. ": native create failed: " .. tostring(result.error))
|
||||
assert(files[case.managedPath] == expectedManaged(case, { native.entry }),
|
||||
native.id .. ": wrong native managed entry")
|
||||
end
|
||||
|
||||
do
|
||||
local case = CASES[1]
|
||||
reset(case)
|
||||
local spec = {
|
||||
modifiers = { "SUPER" }, keys = { "N" }, activation = "press",
|
||||
command = "hl.dsp.window.kill()", command_kind = "native",
|
||||
library_entry_id = "hyprland/window.close",
|
||||
description = "Tampered action", category = "Windows",
|
||||
}
|
||||
local result = submit(case, spec, "native-tampered")
|
||||
assert(result.ok == false and result.error == "library_entry_invalid",
|
||||
"tampered native action was accepted")
|
||||
assert(files[case.managedPath] == nil, "tampered native action changed files")
|
||||
end
|
||||
|
||||
do
|
||||
local case = CASES[1]
|
||||
reset(case)
|
||||
local spec = {
|
||||
modifiers = { "SUPER" }, keys = { "N" }, activation = "press",
|
||||
command = "hl.dsp.exec_cmd({{command}})", command_kind = "native",
|
||||
library_entry_id = "hyprland/exec_cmd",
|
||||
description = "Incomplete action", category = "Applications",
|
||||
}
|
||||
local result = submit(case, spec, "native-placeholder")
|
||||
assert(result.ok == false and result.error == "library_arguments_required",
|
||||
"unresolved native placeholder was accepted")
|
||||
assert(files[case.managedPath] == nil, "unresolved native placeholder changed files")
|
||||
end
|
||||
|
||||
do
|
||||
local case = CASES[3]
|
||||
reset(case)
|
||||
local spec = {
|
||||
modifiers = { "SUPER" }, keys = { "N" }, activation = "press",
|
||||
command = "hl.dsp.window.close()", command_kind = "native",
|
||||
library_entry_id = "hyprland/window.close",
|
||||
description = "Foreign action", category = "Windows",
|
||||
}
|
||||
local result = submit(case, spec, "native-foreign-source")
|
||||
assert(result.ok == false and result.error == "library_entry_invalid",
|
||||
"native action from another compositor was accepted")
|
||||
assert(files[case.managedPath] == nil, "foreign native action changed files")
|
||||
end
|
||||
|
||||
do
|
||||
local case = CASES[1]
|
||||
reset(case)
|
||||
stateValues["keymap.snapshot"].source = case.rootPath .. ".other"
|
||||
local result = submit(case, case.first, "stale-context")
|
||||
assert(result.ok == false and result.error == "stale_context", "stale context was accepted")
|
||||
assert(files[case.rootPath] == case.root and files[case.managedPath] == nil,
|
||||
"stale context changed files")
|
||||
assert(#commands == 0, "stale context reached preflight")
|
||||
end
|
||||
|
||||
do
|
||||
local case = CASES[1]
|
||||
reset(case)
|
||||
failure.preflight = true
|
||||
local result = submit(case, case.first, "symlink-preflight")
|
||||
assert(result.ok == false and result.error == "symlink_unsupported", "symlink preflight was accepted")
|
||||
assert(files[case.rootPath] == case.root and files[case.managedPath] == nil,
|
||||
"failed symlink preflight changed files")
|
||||
assert(#commands == 1 and commands[1].command:find("[ ! -L ", 1, true) == 1,
|
||||
"symlink rejection did not stop after preflight")
|
||||
end
|
||||
|
||||
print("writer create tests: ok")
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user