;;; test-pearl-modified.el --- Tests for the modified-ticket indicator -*- lexical-binding: t; -*- ;; Copyright (C) 2026 Craig Jennings ;; Author: Craig Jennings ;;; Commentary: ;; Tests for the modified-ticket indicator (docs/modified-ticket-indicator-spec.org): ;; phase 1 covers the pushable-ticket count and its mode-line segment. The count ;; is "tickets, not fields": each ticket with pushable dirty work counts once. ;;; Code: (require 'test-bootstrap (expand-file-name "test-bootstrap.el")) (require 'cl-lib) (defun test-pearl-modified--scan (&rest dirtys) "Build a scan list ((MARKER . DIRTY) ...) from DIRTY plists (marker unused)." (mapcar (lambda (d) (cons nil d)) dirtys)) ;;; pushable-ticket count (ert-deftest test-pearl-count-pushable-field-dirty-ticket-counts () "A ticket with any dirty non-comment field counts, regardless of the viewer." (let ((scan (test-pearl-modified--scan '(:title t) '(:description t) '(:state t)))) (should (= 3 (pearl--count-pushable-tickets scan "v"))) (should (= 3 (pearl--count-pushable-tickets scan nil))))) (ert-deftest test-pearl-count-pushable-many-fields-one-ticket-counts-once () "A ticket dirty in several fields counts once, not per field." (let ((scan (test-pearl-modified--scan '(:title t :description t :labels t)))) (should (= 1 (pearl--count-pushable-tickets scan "v"))))) (ert-deftest test-pearl-count-pushable-own-comment-only-counts () "A ticket dirty only via an own comment (author = viewer) counts." (let ((scan (test-pearl-modified--scan '(:comment-candidates ((:author-id "v")))))) (should (= 1 (pearl--count-pushable-tickets scan "v"))))) (ert-deftest test-pearl-count-pushable-readonly-comment-only-excluded () "A ticket dirty only via a non-own comment does not count." (let ((scan (test-pearl-modified--scan '(:comment-candidates ((:author-id "someone-else")))))) (should (= 0 (pearl--count-pushable-tickets scan "v"))))) (ert-deftest test-pearl-count-pushable-comment-only-unknown-viewer-excluded () "Comment-only dirt doesn't count while the viewer is unresolved or failed." (let ((scan (test-pearl-modified--scan '(:comment-candidates ((:author-id "v")))))) (should (= 0 (pearl--count-pushable-tickets scan nil))) (should (= 0 (pearl--count-pushable-tickets scan "v" t))))) ; viewer-failed (ert-deftest test-pearl-count-pushable-mixed () "A field-dirty ticket counts even when another ticket is only read-only-dirty." (let ((scan (test-pearl-modified--scan '(:priority t) '(:comment-candidates ((:author-id "other")))))) (should (= 1 (pearl--count-pushable-tickets scan "v"))))) ;;; mode-line segment (ert-deftest test-pearl-mode-line-appends-changed-count-with-glyph () "The lighter appends \"N changed\" and keeps the account name." (let ((pearl-accounts '(("work" :api-key "k"))) (pearl-active-account "work") (pearl-show-modified-indicator t) (pearl-ticket-glyph "🎫") (pearl--modified-count 3)) (let ((s (pearl--mode-line-lighter))) (should (string-match-p "Pearl \\[work\\]" s)) (should (string-match-p "3" s)) (should (string-match-p "🎫" s)) (should (string-match-p "changed" s))))) (ert-deftest test-pearl-mode-line-changed-count-falls-back-to-word () "With the glyph unset, the segment reads \"N tickets changed\"." (let ((pearl-accounts '(("work" :api-key "k"))) (pearl-active-account "work") (pearl-show-modified-indicator t) (pearl-ticket-glyph "") (pearl--modified-count 2)) (should (string-match-p "2 tickets changed" (pearl--mode-line-lighter))))) (ert-deftest test-pearl-mode-line-no-segment-when-clean () "A zero count adds no segment; the account name still shows." (let ((pearl-accounts '(("work" :api-key "k"))) (pearl-active-account "work") (pearl-show-modified-indicator t) (pearl--modified-count 0)) (let ((s (pearl--mode-line-lighter))) (should (string-match-p "Pearl \\[work\\]" s)) (should-not (string-match-p "changed" s))))) (ert-deftest test-pearl-mode-line-indicator-off-suppresses-count () "With the indicator disabled, no count shows even when dirty." (let ((pearl-accounts nil) (pearl-active-account nil) (pearl-show-modified-indicator nil) (pearl--modified-count 5)) (should-not (string-match-p "changed" (pearl--mode-line-lighter))))) ;;; phase 2 -- field-region highlight (ert-deftest test-pearl-modified-regions-clean-is-nil () "A clean ticket highlights nothing." (should (null (pearl--modified-highlight-regions '() "v")))) (ert-deftest test-pearl-modified-regions-title-lights-heading () (should (equal '(heading) (pearl--modified-highlight-regions '(:title t) "v")))) (ert-deftest test-pearl-modified-regions-description-lights-both () "A dirty description lights the heading (bubble-up) and the body." (should (equal '(heading description) (pearl--modified-highlight-regions '(:description t) "v")))) (ert-deftest test-pearl-modified-regions-own-comment-lights-heading () (should (equal '(heading) (pearl--modified-highlight-regions '(:comment-candidates ((:author-id "v"))) "v")))) (ert-deftest test-pearl-modified-regions-readonly-comment-nothing () "A non-own comment does not light the heading." (should (null (pearl--modified-highlight-regions '(:comment-candidates ((:author-id "other"))) "v")))) (ert-deftest test-pearl-modified-regions-comment-unknown-viewer-nothing () "A comment-only ticket lights nothing while the viewer is unresolved." (should (null (pearl--modified-highlight-regions '(:comment-candidates ((:author-id "v"))) nil)))) (ert-deftest test-pearl-issue-description-region-spans-body-only () "The description region covers the body, not the drawer or the Comments child." (with-temp-buffer (insert "* F\n** TODO PEARL-1 Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n:END:\n" "Body one.\nBody two.\n*** Comments\n**** c\nhi\n") (org-mode) (goto-char (point-min)) (re-search-forward "PEARL-1") (org-back-to-heading t) (let* ((r (pearl--issue-description-region)) (text (buffer-substring-no-properties (car r) (cdr r)))) (should (string-match-p "Body one" text)) (should (string-match-p "Body two" text)) (should-not (string-match-p "Comments" text)) (should-not (string-match-p "LINEAR-ID" text))))) (ert-deftest test-pearl-apply-modified-highlights-marks-dirty-heading-idempotently () "A title-dirty issue gets exactly one heading overlay, even on a second pass." (with-temp-buffer (insert "* F\n** TODO PEARL-1 Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n" ":LINEAR-TITLE-SHA256: deadbeef\n:END:\n") (org-mode) (let ((pearl-show-modified-indicator t)) (cl-flet ((modified-ovs () (seq-filter (lambda (o) (overlay-get o 'pearl-modified)) (overlays-in (point-min) (point-max))))) (pearl--apply-modified-highlights) (should (= 1 (length (modified-ovs)))) (pearl--apply-modified-highlights) (should (= 1 (length (modified-ovs)))))))) (ert-deftest test-pearl-apply-modified-highlights-off-clears () "With the indicator disabled, the applier lays nothing (and clears prior)." (with-temp-buffer (insert "* F\n** TODO PEARL-1 Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n" ":LINEAR-TITLE-SHA256: deadbeef\n:END:\n") (org-mode) (let ((pearl-show-modified-indicator t)) (pearl--apply-modified-highlights)) (let ((pearl-show-modified-indicator nil)) (pearl--apply-modified-highlights)) (should (null (seq-filter (lambda (o) (overlay-get o 'pearl-modified)) (overlays-in (point-min) (point-max))))))) ;;; phase 4 -- per-comment highlight by ownership (ert-deftest test-pearl-comment-highlight-face-own-is-pushable () (should (eq 'pearl-modified-highlight (pearl--comment-highlight-face '(:author-id "v") "v")))) (ert-deftest test-pearl-comment-highlight-face-non-own-is-local () (should (eq 'pearl-modified-local (pearl--comment-highlight-face '(:author-id "other") "v")))) (ert-deftest test-pearl-comment-highlight-face-unresolved-viewer-is-unknown () (should (eq 'pearl-modified-unknown (pearl--comment-highlight-face '(:author-id "v") nil))) (should (eq 'pearl-modified-unknown (pearl--comment-highlight-face '(:author-id "v") "v" t)))) (ert-deftest test-pearl-comment-region-spans-the-comment-subtree () (with-temp-buffer (insert "* F\n** TODO PEARL-1 Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n:END:\n" "*** Comments\n**** Comment by X\n:PROPERTIES:\n:LINEAR-COMMENT-ID: c1\n:END:\n" "the comment body\n") (org-mode) (goto-char (point-min)) (re-search-forward "Comment by X") (org-back-to-heading t) (let ((text (let ((r (pearl--comment-region))) (buffer-substring-no-properties (car r) (cdr r))))) (should (string-match-p "Comment by X" text)) (should (string-match-p "the comment body" text))))) ;;; phase 3 -- live after-change trigger (ert-deftest test-pearl-modified-after-change-schedules-timer () "An edit schedules a debounced redecorate timer when the indicator is on." (with-temp-buffer (let ((pearl-show-modified-indicator t) (pearl--modified-timer nil)) (pearl--modified-after-change) (should (timerp pearl--modified-timer)) (pearl--modified-cleanup) (should (null pearl--modified-timer))))) (ert-deftest test-pearl-modified-after-change-off-schedules-nothing () "With the indicator disabled, an edit schedules no timer." (with-temp-buffer (let ((pearl-show-modified-indicator nil) (pearl--modified-timer nil)) (pearl--modified-after-change) (should (null pearl--modified-timer))))) (ert-deftest test-pearl-modified-after-change-cancels-prior-timer () "A second edit replaces the pending timer rather than leaking it." (with-temp-buffer (let ((pearl-show-modified-indicator t) (pearl--modified-timer nil)) (pearl--modified-after-change) (let ((first pearl--modified-timer)) (pearl--modified-after-change) (should-not (eq first pearl--modified-timer)) (should (timerp pearl--modified-timer)) (pearl--modified-cleanup))))) (provide 'test-pearl-modified) ;;; test-pearl-modified.el ends here