llm-tools.el

Opinionated tools for use by a (local) LLM
Log | Files | Refs | LICENSE

commit df3f4877455ecc3f01c87647092dcda202b0e771
parent bacb87e48d0757deb40df1faa5b189d0904cc872
Author: Vineet Kumar <git@vineetk.net>
Date:   Mon, 25 May 2026 16:13:14 -0400

work on hashline unit tests

Diffstat:
Mllm-tools-hl-test.el | 497+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 485 insertions(+), 12 deletions(-)

diff --git a/llm-tools-hl-test.el b/llm-tools-hl-test.el @@ -83,7 +83,9 @@ export function greet(name) { (ert-deftest llm-tools--hl-apply-replace-single-line () (let* ((content "line1\nline2\nline3\n") (file (make-temp-file nil nil nil content)) - (patch (format "@@ test\n= 2%s..2%s\n~REPLACED" (elt 1 (llm-tools--hl-hash (string-lines content)))))) + (lines (string-lines content)) + (h2 (llm-tools--hl-hash (elt lines 1))) + (patch (format "@@ %s\n= 2%s..2%s\n~REPLACED" file h2 h2))) (unwind-protect (progn (llm-tools--hl-apply patch) @@ -99,7 +101,7 @@ export function greet(name) { (let* ((content "A\nB\nC\n") (file (make-temp-file nil nil nil content)) (anchor (format "+ %s" (llm-tools--hl-file-line-hash file 1))) - (patch (concat "@@ test\n" anchor "\n~NEW"))) + (patch (format "@@ %s\n%s\n~NEW" file anchor))) (unwind-protect (progn (llm-tools--hl-apply patch) @@ -111,7 +113,7 @@ export function greet(name) { (let* ((content "A\nB\nC\n") (file (make-temp-file nil nil nil content)) (anchor (format "< %s" (llm-tools--hl-file-line-hash file 3))) - (patch (concat "@@ test\n" anchor "\n~NEW"))) + (patch (format "@@ %s\n%s\n~NEW" file anchor))) (unwind-protect (progn (llm-tools--hl-apply patch) @@ -122,7 +124,7 @@ export function greet(name) { (ert-deftest llm-tools--hl-apply-append-to-eof () (let* ((content "A\nB\n") (file (make-temp-file nil nil nil content)) - (patch "@@ test\n+ EOF\n~APPENDED")) + (patch (format "@@ %s\n+ EOF\n~APPENDED" file))) (unwind-protect (progn (llm-tools--hl-apply patch) @@ -134,7 +136,7 @@ export function greet(name) { (let* ((content "A\nB\nC\n") (file (make-temp-file nil nil nil content)) (anchor (llm-tools--hl-file-line-hash file 2)) - (patch (format "@@ test\n- 2%s..2%s" anchor anchor))) + (patch (format "@@ %s\n- 2%s..2%s" file anchor anchor))) (unwind-protect (progn (llm-tools--hl-apply patch) @@ -147,7 +149,7 @@ export function greet(name) { (let* ((content "A\nB\nC\n") (file (make-temp-file nil nil nil content)) (anchor (llm-tools--hl-file-line-hash file 2)) - (patch (format "@@ test\n= 2%s..2%s" anchor anchor))) + (patch (format "@@ %s\n= 2%s..2%s" file anchor anchor))) (unwind-protect (progn (llm-tools--hl-apply patch) @@ -161,14 +163,10 @@ export function greet(name) { (file (make-temp-file nil nil nil content)) (a1 (llm-tools--hl-file-line-hash file 1)) (a3 (llm-tools--hl-file-line-hash file 3)) - ;; Insert 2 lines after line 1, then delete original line 3 - (patch (format "@@ test\n+ 1%s\n~X\n~Y\n- 3%s..3%s" a1 a3 a3))) + (patch (format "@@ %s\n+ 1%s\n~X\n~Y\n- 3%s..3%s" file a1 a3 a3))) (unwind-protect (progn (llm-tools--hl-apply patch) - ;; After insert: A X Y B C D E (offset = +2) - ;; Original line 3 (C) is now at index 5 (1-based). Delete it. - ;; Result: A X Y B D E (let ((lines (llm-tools--hl-file-lines file))) (should (equal lines '("A" "X" "Y" "B" "D" "E"))))) (delete-file file)))) @@ -194,10 +192,485 @@ export function greet(name) { (let* ((content "A\nB\n") (file (make-temp-file nil nil nil content)) (a1 (llm-tools--hl-file-line-hash file 1)) - (patch (format "@@ test\n= 1%s..1%s\n~ONE\n~TWO\n~THREE" a1 a1))) + (patch (format "@@ %s\n= 1%s..1%s\n~ONE\n~TWO\n~THREE" file a1 a1))) (unwind-protect (progn (llm-tools--hl-apply patch) (let ((lines (llm-tools--hl-file-lines file))) (should (equal lines '("ONE" "TWO" "THREE" "B"))))) (delete-file file)))) + +;; --- llm-tools--hl-hash --- +(ert-deftest llm-tools--hl-hash-test () + "Test that hashing produces consistent, valid bigram output." + ;; Deterministic: same input -> same output + (should (string= (llm-tools--hl-hash "hello") + (llm-tools--hl-hash "hello"))) + ;; Different input -> likely different output (hash collision unlikely) + (should (not (string= (llm-tools--hl-hash "hello") + (llm-tools--hl-hash "world")))) + ;; Output is always a valid bigram from the table + (let ((result (llm-tools--hl-hash "test line"))) + (should (= (length result) 2)) + (should (member result llm-tools--hl-bigrams)))) + +;; --- llm-tools--hl-file-lines --- +(ert-deftest llm-tools--hl-file-lines-test () + "Test reading file contents as a list of strings." + (let* ((content "alpha\nbeta\ngamma\n") + (file (make-temp-file nil nil nil content))) + (unwind-protect + (progn + (should (equal (llm-tools--hl-file-lines file) + '("alpha" "beta" "gamma"))) + ;; With beg/end range (inclusive, 1-based) + (should (equal (llm-tools--hl-file-lines file 1 2) + '("alpha" "beta"))) + (should (equal (llm-tools--hl-file-lines file 2 3) + '("beta" "gamma"))) + (should (equal (llm-tools--hl-file-lines file 2 2) + '("beta")))) + (delete-file file)))) + +(ert-deftest llm-tools--hl-file-lines-empty-file-test () + "Test reading an empty file." + (let ((file (make-temp-file nil nil ""))) + (unwind-protect + (should (equal (llm-tools--hl-file-lines file) '(""))) + (delete-file file)))) + +;; --- llm-tools--hl-format-lines --- +(ert-deftest llm-tools--hl-format-lines-test () + "Test formatting lines in hashline format." + (let ((lines '("hello" "world"))) + ;; Without content + (let ((no-content (llm-tools--hl-format-lines lines))) + (should (= (length (string-lines no-content)) 2)) + (should (string-match-p "\\`1.." no-content))) + ;; With content + (let ((with-content (llm-tools--hl-format-lines lines nil t))) + (should (string-match-p "\\`1..|hello" with-content)) + (should (string-match-p "2..|world\\'" with-content))) + ;; With custom start number + (let ((custom-start (llm-tools--hl-format-lines lines 5))) + (should (string-match-p "\\`5.." custom-start)) + (should (string-match-p "6..\\'" custom-start))))) + +;; --- llm-tools--hl-file-read-range --- +(ert-deftest llm-tools--hl-file-read-range-test () + "Test reading a range of lines in hashline format." + (let* ((content "line1\nline2\nline3\nline4\n") + (file (make-temp-file nil nil nil content))) + (unwind-protect + (let ((result (llm-tools--hl-file-read-range file 2 3))) + (should (= (length (string-lines result)) 2)) + (should (string-match-p "\\`2.." result)) + (should (string-match-p "line2" result)) + (should (string-match-p "line3" result))) + (delete-file file)))) + +;; --- llm-tools--hl-file-read-hashes --- +(ert-deftest llm-tools--hl-file-read-hashes-test () + "Test reading file as list of hash strings without content." + (let* ((content "foo\nbar\n") + (file (make-temp-file nil nil nil content))) + (unwind-protect + (let ((hashes (llm-tools--hl-file-read-hashes file))) + (should (= (length hashes) 2)) + (should (string-match-p "\\`1.." (elt hashes 0))) + (should (string-match-p "\\`2.." (elt hashes 1))) + ;; Each hash is exactly 3 chars (1+2 for bigram, variable digit count) + (dolist (h hashes) + (should (>= (length h) 3)))) + (delete-file file)))) + +;; --- llm-tools--hl-file-line-hash --- +(ert-deftest llm-tools--hl-file-line-hash-test () + "Test getting the hash of a specific line in a file." + (let* ((content "first\nsecond\nthird\n") + (file (make-temp-file nil nil nil content))) + (unwind-protect + (progn + (should (string= (llm-tools--hl-file-line-hash file 1) + (llm-tools--hl-hash "first"))) + (should (string= (llm-tools--hl-file-line-hash file 2) + (llm-tools--hl-hash "second"))) + (should (string= (llm-tools--hl-file-line-hash file 3) + (llm-tools--hl-hash "third")))) + (delete-file file)))) + +;; --- llm-tools--split-patch-sections --- +(ert-deftest llm-tools--split-patch-sections-single-file-test () + "Test splitting a patch with one file section." + (let ((patch "@@ foo.ts\n= 1vx..1vx\n~hello")) + (let ((sections (llm-tools--split-patch-sections patch))) + (should (= (length sections) 1)) + (should (string= (car (car sections)) "foo.ts")) + (should (string-match-p "= 1vx" (cdr (car sections))))))) + +(ert-deftest llm-tools--split-patch-sections-multi-file-test () + "Test splitting a patch with multiple file sections." + (let ((patch "@@ a.ts\n+ 1vx\n~X\n@@ b.ts\n- 2in..2in")) + (let ((sections (llm-tools--split-patch-sections patch))) + (should (= (length sections) 2)) + (should (string= (car (car sections)) "a.ts")) + (should (string= (car (cadr sections)) "b.ts"))))) + +(ert-deftest llm-tools--split-patch-sections-empty-test () + "Test splitting an empty or invalid patch." + (should (null (llm-tools--split-patch-sections ""))) + (should (null (llm-tools--split-patch-sections "no header here")))) + +;; --- llm-tools--hl-get-anchors --- +(ert-deftest llm-tools--hl-get-anchors-test () + "Test extracting anchors from a patch." + (let ((patch "@@ test.ts\n+ 4ei\n~X\n= 3gv..6be\n~Y\n- 5ff..5ff")) + (let ((anchors (llm-tools--hl-get-anchors patch))) + (should (= (length anchors) 1)) + (let ((file-anchors (cdr (car anchors)))) + (should (member "4ei" file-anchors)) + (should (member "5ff" file-anchors)) + (should (member "3gv" file-anchors)) + (should (member "6be" file-anchors)))))) + +(ert-deftest llm-tools--hl-get-anchors-eof-bof-test () + "Test that EOF/BOF anchors are extracted correctly." + (let ((patch "@@ test.ts\n+ EOF\n~X\n< BOF\n~Y")) + (let ((anchors (llm-tools--hl-get-anchors patch))) + (let ((file-anchors (cdr (car anchors)))) + (should (member "EOF" file-anchors)) + (should (member "BOF" file-anchors)))))) + +;; --- llm-tools--hl-parse-op-line --- +(ert-deftest llm-tools--hl-parse-op-line-insert-after-test () + (let ((op (llm-tools--hl-parse-op-line "+ 4ei"))) + (should (eq (hl-op-type op) 'insert-after)) + (should (string= (hl-op-anchor op) "4ei")))) + +(ert-deftest llm-tools--hl-parse-op-line-insert-before-test () + (let ((op (llm-tools--hl-parse-op-line "< 5ff"))) + (should (eq (hl-op-type op) 'insert-before)) + (should (string= (hl-op-anchor op) "5ff")))) + +(ert-deftest llm-tools--hl-parse-op-line-delete-test () + (let ((op (llm-tools--hl-parse-op-line "- 3gv..6be"))) + (should (eq (hl-op-type op) 'delete)) + (should (equal (hl-op-range op) '("3gv" "6be"))))) + +(ert-deftest llm-tools--hl-parse-op-line-replace-test () + (let ((op (llm-tools--hl-parse-op-line "= 1vx..2in"))) + (should (eq (hl-op-type op) 'replace)) + (should (equal (hl-op-range op) '("1vx" "2in"))))) + +(ert-deftest llm-tools--hl-parse-op-line-eof-anchor-test () + (let ((op (llm-tools--hl-parse-op-line "+ EOF"))) + (should (eq (hl-op-type op) 'insert-after)) + (should (string= (hl-op-anchor op) "EOF")))) + +(ert-deftest llm-tools--hl-parse-op-line-bof-anchor-test () + (let ((op (llm-tools--hl-parse-op-line "< BOF"))) + (should (eq (hl-op-type op) 'insert-before)) + (should (string= (hl-op-anchor op) "BOF")))) + +;; --- llm-tools--hl-group-by-file --- +(ert-deftest llm-tools--hl-group-by-file-test () + "Test grouping sections by file path." + (let* ((sec1 (cons "a.ts" "body1")) + (sec2 (cons "b.ts" "body2")) + (sec3 (cons "a.ts" "body3")) + (sections (list sec1 sec2 sec3))) + (let ((grouped (llm-tools--hl-group-by-file sections))) + (let ((total 0)) + (dolist (g grouped) + (cl-incf total (length (cdr g)))) + (should (= total 3))) + (let ((a-group (rassoc "a.ts" grouped))) + (should a-group) + (should (= (length (cdr a-group)) 2))) + (let ((b-group (rassoc "b.ts" grouped))) + (should b-group) + (should (= (length (cdr b-group)) 1)))))) + +;; --- llm-tools--hl-validate-no-payload-before-op --- +(ert-deftest llm-tools--hl-validate-no-payload-before-op-test () + "Test that payload before first op is detected." + ;; This is an edge case: payload lines at the very start with no preceding op + ;; The parser won't actually produce this, but test the validator directly + (let ((op (make-hl-verify-op :type 'insert-after :anchor "4ei" + :payload '("should not be here")))) + (let ((errors (llm-tools--hl-validate-no-payload-before-op (list op)))) + (should (= (length errors) 1)))) + ;; Clean case + (let ((op (make-hl-verify-op :type 'insert-after :anchor "4ei" :payload nil))) + (let ((errors (llm-tools--hl-validate-no-payload-before-op (list op)))) + (should (= (length errors) 0))))) + +;; --- llm-tools--hl-validate-insert-has-payload --- +(ert-deftest llm-tools--hl-validate-insert-has-payload-test () + "Test that inserts without payload are flagged." + (let ((op (make-hl-verify-op :type 'insert-after :anchor "4ei" :payload nil))) + (let ((errors (llm-tools--hl-validate-insert-has-payload (list op)))) + (should (= (length errors) 1)))) + ;; Insert with payload is fine + (let ((op (make-hl-verify-op :type 'insert-after :anchor "4ei" :payload '("line")))) + (should (= (length (llm-tools--hl-validate-insert-has-payload (list op))) 0))) + ;; Insert-before without payload + (let ((op (make-hl-verify-op :type 'insert-before :anchor "4ei" :payload nil))) + (let ((errors (llm-tools--hl-validate-insert-has-payload (list op)))) + (should (= (length errors) 1))))) + +;; --- llm-tools--hl-validate-delete-no-payload --- +(ert-deftest llm-tools--hl-validate-delete-no-payload-test () + "Test that deletes with payload are flagged." + (let ((op (make-hl-verify-op :type 'delete :anchor "1vx..2in" :payload '("bad")))) + (let ((errors (llm-tools--hl-validate-delete-no-payload (list op)))) + (should (= (length errors) 1)))) + ;; Delete without payload is fine + (let ((op (make-hl-verify-op :type 'delete :anchor "1vx..2in" :payload nil))) + (should (= (length (llm-tools--hl-validate-delete-no-payload (list op))) 0)))) + +;; --- llm-tools--hl-validate-anchors --- +(ert-deftest llm-tools--hl-validate-anchors-insert-valid-test () + "Test valid insert anchors pass validation." + (let ((op (make-hl-verify-op :type 'insert-after :anchor "4ei" :payload '("x")))) + (should (= (length (llm-tools--hl-validate-anchors (list op))) 0)))) + +(ert-deftest llm-tools--hl-validate-anchors-insert-eof-test () + "Test that EOF is accepted as a valid insert anchor." + (let ((op (make-hl-verify-op :type 'insert-after :anchor "EOF" :payload '("x")))) + (should (= (length (llm-tools--hl-validate-anchors (list op))) 0)))) + +(ert-deftest llm-tools--hl-validate-anchors-insert-bof-test () + "Test that BOF is accepted as a valid insert anchor." + (let ((op (make-hl-verify-op :type 'insert-before :anchor "BOF" :payload '("x")))) + (should (= (length (llm-tools--hl-validate-anchors (list op))) 0)))) + +(ert-deftest llm-tools--hl-validate-anchors-insert-invalid-test () + "Test that malformed insert anchors are flagged." + (let ((op (make-hl-verify-op :type 'insert-after :anchor "bad" :payload '("x")))) + (let ((errors (llm-tools--hl-validate-anchors (list op)))) + (should (= (length errors) 1)))) + (let ((op (make-hl-verify-op :type 'insert-after :anchor "" :payload '("x")))) + (let ((errors (llm-tools--hl-validate-anchors (list op)))) + (should (= (length errors) 1))))) + +(ert-deftest llm-tools--hl-validate-anchors-range-valid-test () + "Test valid range anchors pass validation." + (let ((op (make-hl-verify-op :type 'delete :anchor "3gv..6be" :payload nil))) + (should (= (length (llm-tools--hl-validate-anchors (list op))) 0)))) + +(ert-deftest llm-tools--hl-validate-anchors-range-missing-dotdot-test () + "Test that ranges without '..' are flagged." + (let ((op (make-hl-verify-op :type 'delete :anchor "3gv6be" :payload nil))) + (let ((errors (llm-tools--hl-validate-anchors (list op)))) + (should (> (length errors) 0))))) + +(ert-deftest llm-tools--hl-validate-anchors-range-non-numeric-test () + "Test that range parts starting with non-numeric chars are flagged." + (let ((op (make-hl-verify-op :type 'replace :anchor "abc..def" :payload nil))) + (let ((errors (llm-tools--hl-validate-anchors (list op)))) + (should (> (length errors) 0))))) + +;; --- llm-tools--hl-verify-section --- +(ert-deftest llm-tools--hl-verify-section-clean-test () + "Test that a well-formed section produces no errors." + (let ((section (cons "test.ts" "\n+ 4ei\n~payload"))) + (should (= (length (llm-tools--hl-verify-section section)) 0)))) + +(ert-deftest llm-tools--hl-verify-section-missing-payload-test () + "Test that an insert without payload produces an error." + (let ((section (cons "test.ts" "\n+ 4ei"))) + (should (> (length (llm-tools--hl-verify-section section)) 0)))) + +;; --- llm-tools--hl-verify-structure --- +(ert-deftest llm-tools--hl-verify-structure-clean-test () + "Test that a structurally valid patch produces no errors." + (let ((patch "@@ test.ts\n+ 4ei\n~payload")) + (should (= (length (llm-tools--hl-verify-structure patch)) 0)))) + +(ert-deftest llm-tools--hl-verify-structure-errors-test () + "Test that structural violations are reported." + (let* ((content "A\n") + (file (make-temp-file nil nil nil content)) + (patch (format "@@ %s\n+ 4ei" file))) + (unwind-protect + (should (> (length (llm-tools--hl-verify-structure patch)) 0)) + (delete-file file)))) + +;; --- llm-tools--hl-verify-anchors --- +(ert-deftest llm-tools--hl-verify-anchors-matching-test () + "Test that anchors matching file content return t." + (let* ((content "hello\nworld\n") + (file (make-temp-file nil nil nil content)) + (h1 (llm-tools--hl-file-line-hash file 1)) + (patch (format "@@ %s\n+ %s\n~new" file h1))) + (unwind-protect + (let ((results (llm-tools--hl-verify-anchors patch))) + (should (= (length results) 1)) + (let ((entry (car results))) + (should (string= (car entry) file)) + (should (cdr entry)))) + (delete-file file)))) + +(ert-deftest llm-tools--hl-verify-anchors-stale-test () + "Test that stale anchors (file changed) return nil." + (let* ((content "hello\nworld\n") + (file (make-temp-file nil nil nil content)) + (patch (format "@@ %s\n= 1zzz..1zzz\n~new" file))) + ;; 1zzz is unlikely to match the actual hash + (unwind-protect + (let ((results (llm-tools--hl-verify-anchors patch))) + (should (= (length results) 1)) + (should-not (cdr (car results)))) + (delete-file file)))) + +(ert-deftest llm-tools--hl-verify-anchors-eof-bof-ignored-test () + "Test that EOF and BOF are excluded from anchor verification." + (let* ((content "hello\n") + (file (make-temp-file nil nil nil content))) + (unwind-protect + (progn + ;; EOF anchor should not cause anchor mismatch + (let ((patch (format "@@ %s\n+ EOF\n~appended" file))) + (let ((results (llm-tools--hl-verify-anchors patch))) + (should (cdr (car results))))) + ;; BOF anchor should not cause anchor mismatch + (let ((patch (format "@@ %s\n< BOF\n~prepended" file))) + (let ((results (llm-tools--hl-verify-anchors patch))) + (should (cdr (car results)))))) + (delete-file file)))) + +;; --- llm-tools--hl-verify --- +(ert-deftest llm-tools--hl-verify-clean-test () + "Test that a valid patch returns empty string." + (let* ((content "hello\nworld\n") + (file (make-temp-file nil nil nil content)) + (h1 (llm-tools--hl-file-line-hash file 1)) + (patch (format "@@ %s\n+ %s\n~new" file h1))) + (unwind-protect + (should (string= (llm-tools--hl-verify patch) "")) + (delete-file file)))) + +(ert-deftest llm-tools--hl-verify-structural-error-test () + "Test that structural errors are returned as a string." + (let* ((file (make-temp-file nil nil nil ""))) + (unwind-protect + (let ((patch (format "@@ %s\n+ 4ei" file))) + (should-not (string= (llm-tools--hl-verify patch) ""))) + (delete-file file)))) + +(ert-deftest llm-tools--hl-verify-anchor-error-test () + "Test that anchor mismatches produce a descriptive error." + (let* ((content "hello\n") + (file (make-temp-file nil nil nil content))) + (unwind-protect + (let ((patch (format "@@ %s\n= 1zzz..1zzz\n~new" file))) + (let ((msg (llm-tools--hl-verify patch))) + (should-not (string= msg "")) + (should (string-match "incorrect anchors" msg)))) + (delete-file file)))) + +;; --- llm-tools--hl-edit --- +(ert-deftest llm-tools--hl-edit-success-test () + "Test that a valid patch is applied successfully via llm-tools--hl-edit." + (let* ((content "hello\nworld\n") + (file (make-temp-file nil nil nil content)) + (h1 (llm-tools--hl-file-line-hash file 1)) + (patch (format "@@ %s\n+ %s\n~new" file h1))) + (unwind-protect + (let ((result (llm-tools--hl-edit patch))) + (should (string-match "Finished" result)) + (should (equal (llm-tools--hl-file-lines file) '("hello" "new" "world")))) + (delete-file file)))) + +(ert-deftest llm-tools--hl-edit-failure-test () + "Test that an invalid patch is rejected via llm-tools--hl-edit." + (let* ((content "hello\n") + (file (make-temp-file nil nil nil content))) + (unwind-protect + (let ((patch (format "@@ %s\n= 1zzz..1zzz\n~new" file))) + (let ((result (llm-tools--hl-edit patch))) + (should-not (string-match "Finished" result)))) + (delete-file file)))) + +;; --- llm-tools--hl-apply-one-op edge cases --- +(ert-deftest llm-tools--hl-apply-one-op-insert-at-eof-test () + "Test inserting at EOF anchor." + (let* ((content '("A" "B")) + (op (make-hl-op :type 'insert-after :anchor "EOF" :payload '("C")))) + (let ((result (llm-tools--hl-apply-one-op op content 0))) + (should (equal (car result) '("A" "B" "C")))))) + +(ert-deftest llm-tools--hl-apply-one-op-insert-at-bof-test () + "Test inserting at BOF anchor." + (let* ((content '("A" "B")) + (op (make-hl-op :type 'insert-before :anchor "BOF" :payload '("C")))) + (let ((result (llm-tools--hl-apply-one-op op content 0))) + (should (equal (car result) '("C" "A" "B")))))) + +(ert-deftest llm-tools--hl-apply-one-op-replace-blank-payload-test () + "Test that replace with nil payload produces a blank line." + (let* ((content '("A" "B" "C")) + (op (make-hl-op :type 'replace :range '("2in" "2in") :payload nil))) + (let ((result (llm-tools--hl-apply-one-op op content 0))) + (should (equal (car result) '("A" "" "C")))))) + +(ert-deftest llm-tools--hl-apply-one-op-replace-multi-line-test () + "Test replace spanning multiple lines." + (let* ((content '("A" "B" "C" "D")) + (op (make-hl-op :type 'replace :range '("2in" "3cc") :payload '("X" "Y")))) + (let ((result (llm-tools--hl-apply-one-op op content 0))) + (should (equal (car result) '("A" "X" "Y" "D")))))) + +(ert-deftest llm-tools--hl-apply-one-op-delete-range-test () + "Test deleting a range of lines." + (let* ((content '("A" "B" "C" "D" "E")) + (op (make-hl-op :type 'delete :range '("2in" "4ei")))) + (let ((result (llm-tools--hl-apply-one-op op content 0))) + (should (equal (car result) '("A" "E")))))) + +(ert-deftest llm-tools--hl-apply-one-op-offset-tracking-test () + "Test that offset is updated correctly after each op." + (let* ((content '("A" "B" "C")) + (op (make-hl-op :type 'insert-after :anchor "1vx" :payload '("X" "Y")))) + (let ((result (llm-tools--hl-apply-one-op op content 0))) + (should (= (cdr result) 2))))) + +;; --- llm-tools--hl-parse-section edge cases --- +(ert-deftest llm-tools--hl-parse-section-empty-payload-for-replace-test () + "Test that replace with no ~ lines produces nil payload." + (let ((section "= 1vx..1vx")) + (let ((op (car (llm-tools--hl-parse-section section)))) + (should (null (hl-op-payload op)))))) + +(ert-deftest llm-tools--hl-parse-section-payload-with-tabs-test () + "Test that payload lines preserve leading tabs." + (let ((section "+ 4ei\n~\tindented")) + (let ((op (car (llm-tools--hl-parse-section section)))) + (should (string= (car (hl-op-payload op)) "\tindented"))))) + +(ert-deftest llm-tools--hl-parse-section-payload-with-spaces-test () + "Test that payload lines preserve leading spaces." + (let ((section "+ 4ei\n~ two spaces")) + (let ((op (car (llm-tools--hl-parse-section section)))) + (should (string= (car (hl-op-payload op)) " two spaces"))))) + +(ert-deftest llm-tools--hl-parse-section-blank-payload-line-test () + "Test that a ~ line with no content produces an empty string in payload." + (let ((section "= 1vx..1vx\n~")) + (let ((op (car (llm-tools--hl-parse-section section)))) + (should (equal (hl-op-payload op) '("")))))) + +(ert-deftest llm-tools--hl-parse-section-eof-anchor-test () + "Test parsing an insert with EOF anchor." + (let ((section "+ EOF\n~appended")) + (let ((op (car (llm-tools--hl-parse-section section)))) + (should (eq (hl-op-type op) 'insert-after)) + (should (string= (hl-op-anchor op) "EOF"))))) + +(ert-deftest llm-tools--hl-parse-section-bof-anchor-test () + "Test parsing an insert with BOF anchor." + (let ((section "< BOF\n~prepended")) + (let ((op (car (llm-tools--hl-parse-section section)))) + (should (eq (hl-op-type op) 'insert-before)) + (should (string= (hl-op-anchor op) "BOF")))))