| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { |
| 3 | captureConfigBinding, |
| 4 | editCaptureConfig, |
| 5 | niriConfigConflict, |
| 6 | niriConfigIncludes, |
| 7 | } from "./captureConfigEdit.ts"; |
| 8 | import { readKdlNodes } from "./captureConfigKdl.ts"; |
| 9 | |
| 10 | const app = "com.t3tools.T3Code"; |
| 11 | const binding = captureConfigBinding("niri", app, "Ctrl+Shift+2"); |
| 12 | |
| 13 | describe("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 | |
| 95 | describe("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 | }); |