1import { describe, expect, it } from "vite-plus/test";
2import {
3 captureConfigBinding,
4 editCaptureConfig,
5 niriConfigConflict,
6 niriConfigIncludes,
7} from "./captureConfigEdit.ts";
8import { readKdlNodes } from "./captureConfigKdl.ts";
9
10const app = "com.t3tools.T3Code";
11const binding = captureConfigBinding("niri", app, "Ctrl+Shift+2");
12
13describe("Niri capture config edits", () => {
14 it("inserts into the existing top-level binds, preserving unrelated text", () => {
15 const before =
16 '// My desktop\ninput { keyboard { xkb { layout "us"; }; }; }\nbinds {\n Mod+Q { spawn "kitty"; } // keep me\n}\n';
17 const result = editCaptureConfig(before, "niri", app, "install");
18 expect(result.after).toBe(before.replace("\n}\n", `\n ${binding}\n}\n`));
19 expect(readKdlNodes(result.after).filter((node) => node.name === "binds")).toHaveLength(1);
20 });
21 it("ignores commented, nested, and string-valued binds and braces", () => {
22 const before = `// binds { fake\n/* binds { /* nested */ } */\n/- binds { Ctrl+Shift+2 { quit; } }\nrecent-windows { binds { Alt+Tab { next-window; } }; }\nspawn-at-startup "echo" r#"binds { }"#\n"binds" {\n Mod+Q { spawn "a } \\" b"; }\n}\n`;
23 // A quoted brace in a valid shell argument must not close the binds section.
24 const valid = before.replace('"a } \\" b"', '"a } b"');
25 const result = editCaptureConfig(valid, "niri", app, "install");
26 expect(result.after).toContain(` ${binding}\n}\n`);
27 expect(result.after.startsWith(valid.slice(0, valid.indexOf('"binds" {')))).toBe(true);
28 });
29 it.each(["binds {}", "binds { Mod+Q { quit; }; }", "input { }\n", ""])(
30 "handles compact or absent binds: %s",
31 (before) => {
32 const result = editCaptureConfig(before, "niri", app, "install");
33 const block = readKdlNodes(result.after).find((node) => node.name === "binds")!;
34 expect(block.children.some((node) => node.name === "Ctrl+Shift+2")).toBe(true);
35 },
36 );
37 it("preserves CRLF and the UTF-8 BOM", () => {
38 const before = "\uFEFFbinds {\r\n}\r\n";
39 expect(editCaptureConfig(before, "niri", app, "install").after).toBe(
40 `\uFEFFbinds {\r\n ${binding}\r\n}\r\n`,
41 );
42 });
43 it("preserves Unicode spacing around nodes and line continuations", () => {
44 const spaces =
45 "\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000";
46 const before = `binds${spaces}\\${spaces}\n{\n}\n`;
47 expect(editCaptureConfig(before, "niri", app, "install").after).toBe(
48 before.replace("}\n", ` ${binding}\n}\n`),
49 );
50 });
51 it("does not duplicate an existing binding, including a custom chord", () => {
52 const before = `binds {\n ${binding.replace("Ctrl+Shift+2", "Alt+Ctrl+Y")}\n}\n`;
53 const result = editCaptureConfig(before, "niri", app, "install");
54 expect(result.after).toBe(before);
55 expect(result.shortcut).toBe("Ctrl+Alt+Y");
56 });
57 it("replaces and removes only this app's capture binding", () => {
58 const other = captureConfigBinding("niri", "com.t3tools.Other", "Ctrl+Alt+4");
59 const before = `binds {\n ${binding}\n ${other}\n}\n`;
60 const result = editCaptureConfig(before, "niri", app, "install", "Ctrl+Alt+Y");
61 expect(result.after).toContain(other);
62 expect(result.after).not.toContain("Ctrl+Shift+2");
63 expect(result.after).toContain("Ctrl+Alt+Y");
64 const removed = editCaptureConfig(result.after, "niri", app, "remove");
65 expect(removed.after).toBe(`binds {\n ${other}\n}\n`);
66 });
67 it("refuses occupied keys regardless of modifier order, without removing them", () => {
68 const before = "binds { Shift+Control+2 { quit; } }";
69 expect(niriConfigConflict(before, app, "Ctrl+Shift+2")).toBe(true);
70 expect(() => editCaptureConfig(before, "niri", app, "install")).toThrow("already used");
71 });
72 it("does not count a slash-dash-disabled key as occupied", () => {
73 expect(niriConfigConflict("binds { /- Ctrl+Shift+2 { quit; }; }", app, "Ctrl+Shift+2")).toBe(
74 false,
75 );
76 });
77 it.each(["binds {", "binds {}\nbinds {}", "/* unfinished", 'spawn "unfinished'])(
78 "fails safely for malformed structure: %s",
79 (source) => {
80 expect(() => editCaptureConfig(source, "niri", app, "install")).toThrow();
81 },
82 );
83 it("reads only real top-level include directives", () => {
84 expect(
85 niriConfigIncludes(
86 '// include "no"\n/- include "no"\ninclude "keys.kdl"\ninclude optional=true "extra.kdl"',
87 ),
88 ).toEqual([
89 { path: "keys.kdl", optional: false },
90 { path: "extra.kdl", optional: true },
91 ]);
92 });
93});
94
95describe("Hyprland capture config edits", () => {
96 it.each(["hyprland", "hyprland-lua"] as const)(
97 "is idempotent and reversible for %s",
98 (format) => {
99 const before =
100 format === "hyprland"
101 ? "# Keep my preferences\n$mainMod = SUPER\n"
102 : "-- Keep my preferences\nhl.config({ general = { border_size = 2 } })\n";
103 const added = editCaptureConfig(before, format, app, "install");
104 expect(added.after.startsWith(before)).toBe(true);
105 expect(editCaptureConfig(added.after, format, app, "install").after).toBe(added.after);
106 const changed = editCaptureConfig(added.after, format, app, "install", "Ctrl+Alt+Y");
107 expect(changed.after).not.toContain(captureConfigBinding(format, app, "Ctrl+Shift+2"));
108 expect(changed.after).toContain(captureConfigBinding(format, app, "Ctrl+Alt+Y"));
109 expect(editCaptureConfig(changed.after, format, app, "remove").after.trim()).toBe(
110 before.trim(),
111 );
112 },
113 );
114 it("does not steal an existing legacy shortcut", () => {
115 expect(() =>
116 editCaptureConfig("bind = SHIFT CTRL, 2, exec, kitty\n", "hyprland", app, "install"),
117 ).toThrow("already used");
118 });
119 it("leaves commented bindings alone", () => {
120 const before = "# bind = CTRL SHIFT, 2, global, com.t3tools.T3Code:capture-window\n";
121 expect(editCaptureConfig(before, "hyprland", app, "remove").after).toBe(before);
122 });
123 it.each([
124 "return {}",
125 '--[[\nhl.bind("CTRL + SHIFT + 2", hl.dsp.global("com.t3tools.T3Code:capture-window"))\n]]',
126 ])("does not guess how to edit complex Lua", (source) => {
127 expect(() => editCaptureConfig(source, "hyprland-lua", app, "install")).toThrow("manual edit");
128 });
129 it.each(['Ctrl+"\nexec', "Ctrl+;", "2", "Mod+2"])(
130 "rejects unsafe or ambiguous keys: %s",
131 (keys) => {
132 expect(() => editCaptureConfig("", "hyprland", app, "install", keys)).toThrow();
133 },
134 );
135});