diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-03 09:12:04 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-03 09:12:04 -0500 |
| commit | 1f729465d2247fb6882185e426860de869fe9b71 (patch) | |
| tree | 09902839c64633a583cdf696c7422ecc6cfd331d /tests | |
| parent | 580309bb65a5e529c7aab5e58d3983a37c471023 (diff) | |
| download | pearl-1f729465d2247fb6882185e426860de869fe9b71.tar.gz pearl-1f729465d2247fb6882185e426860de869fe9b71.zip | |
feat(views): render a Linear view's grouping as Org sections
A Linear view can group its issues (by status, project, assignee, priority, cycle), and until now pearl ignored that: a "by status" or "by project" view came back as one flat list and didn't look like itself. The sort half of matching the view UI shipped earlier. This is the grouping half.
I read issueGrouping off the view preferences (next to the ordering pearl already fetches) and stamp it on the source as :group. When it's a single-valued dimension, the builder partitions issues into groups in first-appearance order and renders each as a level-2 section heading with its issues at level 3. An issue with no value for the dimension lands in a "No project" / "No assignee" bucket, mirroring Linear. The fold depth tracks where issues render (show3levels grouped, show2levels flat), so the buffer always opens unfolded to the issue level. issueGrouping "none", label-grouping (an issue carries several labels, deferred), and sub-grouping render the flat list.
The group headings carry no LINEAR-ID, so save and merge skip them like the help header. Two merge points needed grouping-awareness: an in-place update now re-renders at the issue's existing heading level rather than flattening to level 2, and a new issue on refresh is placed under its group section (creating the section if it doesn't exist yet) instead of appended at the end. Both entry points share the resolver, so run-linear-view and a favorited view picked in pearl-pick-source group the same way.
Verified live against the workspace: a by-project view renders one section per project plus a "No project" bucket, issues at level 3, opened to show3levels.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test-pearl-merge.el | 79 | ||||
| -rw-r--r-- | tests/test-pearl-output.el | 47 | ||||
| -rw-r--r-- | tests/test-pearl-refresh.el | 4 | ||||
| -rw-r--r-- | tests/test-pearl-views.el | 70 |
4 files changed, 198 insertions, 2 deletions
diff --git a/tests/test-pearl-merge.el b/tests/test-pearl-merge.el index 931849d..9f9307c 100644 --- a/tests/test-pearl-merge.el +++ b/tests/test-pearl-merge.el @@ -72,6 +72,22 @@ "#+LINEAR-TRUNCATED: no\n\n" (mapconcat #'pearl--format-issue-as-org-entry issues ""))) +(defun test-pearl-merge--issue-st (id title state-name state-type) + "A normalized issue plist in state STATE-NAME/STATE-TYPE, for grouping tests." + (list :id id :identifier (concat "ENG-" id) :title title + :description (concat "Desc " id ".") + :priority 2 :url (concat "https://linear.app/x/" id) + :updated-at "2026-05-23T03:00:00.000Z" + :state (list :id (concat "s-" state-name) :name state-name :type state-type) + :team (list :id "t1" :key "ENG" :name "Engineering"))) + +(defun test-pearl-merge--grouped-buffer (grouping &rest issues) + "Render ISSUES into a grouped view buffer (no help header, network stubbed)." + (let ((pearl-help-header nil)) + (cl-letf (((symbol-function 'pearl--team-states) (lambda (_team-id) nil))) + (pearl--build-org-content + issues (list :type 'view :name "By status" :id "v1" :group grouping))))) + ;;; --merge-issues-into-buffer (ert-deftest test-pearl-merge-updates-existing-in-place () @@ -123,6 +139,69 @@ (goto-char (point-min)) (should-not (re-search-forward "Beta" nil t))))) +;;; grouped-view merge (issueGrouping sections) + +(ert-deftest test-pearl-merge-grouped-update-preserves-level () + "Updating an issue in a grouped buffer re-renders it at level 3 (under its +group), not promoted to level 2 the way a flat append would render it." + (test-pearl-merge--in-org + (test-pearl-merge--grouped-buffer + "workflowState" + (test-pearl-merge--issue-st "a" "Alpha" "Todo" "unstarted") + (test-pearl-merge--issue-st "b" "Beta" "Done" "completed")) + (let ((counts (pearl--merge-issues-into-buffer + (list (test-pearl-merge--issue-st "a" "Alpha Renamed" "Todo" "unstarted") + (test-pearl-merge--issue-st "b" "Beta" "Done" "completed")) + "workflowState"))) + (should (= 2 (plist-get counts :updated))) + (goto-char (point-min)) + (should (re-search-forward "^\\*\\*\\* .*Alpha Renamed" nil t)) + ;; Not promoted to a level-2 heading. + (should-not (save-excursion (goto-char (point-min)) + (re-search-forward "^\\*\\* [A-Z].*Alpha Renamed" nil t)))))) + +(ert-deftest test-pearl-merge-grouped-new-issue-joins-existing-group () + "A new issue whose group already exists is inserted under that group's section +at level 3, not appended at the buffer end." + (test-pearl-merge--in-org + (test-pearl-merge--grouped-buffer + "workflowState" + (test-pearl-merge--issue-st "a" "Alpha" "Todo" "unstarted") + (test-pearl-merge--issue-st "b" "Beta" "Done" "completed")) + (let ((counts (pearl--merge-issues-into-buffer + (list (test-pearl-merge--issue-st "a" "Alpha" "Todo" "unstarted") + (test-pearl-merge--issue-st "b" "Beta" "Done" "completed") + (test-pearl-merge--issue-st "c" "Gamma" "Todo" "unstarted")) + "workflowState"))) + (should (= 1 (plist-get counts :added))) + ;; Gamma renders at level 3, inside the Todo section (before Done). + (goto-char (point-min)) + (let ((todo (re-search-forward "^\\*\\* Todo$" nil t)) + (gamma (re-search-forward "^\\*\\*\\* .*Gamma" nil t)) + (done (save-excursion (goto-char (point-min)) + (re-search-forward "^\\*\\* Done$" nil t)))) + (should (and todo gamma done)) + (should (< todo gamma)) + (should (< gamma done)))))) + +(ert-deftest test-pearl-merge-grouped-new-issue-creates-missing-group () + "A new issue whose group doesn't exist yet gets a fresh `** LABEL' section +created for it, with the issue at level 3 under it." + (test-pearl-merge--in-org + (test-pearl-merge--grouped-buffer + "workflowState" + (test-pearl-merge--issue-st "a" "Alpha" "Todo" "unstarted")) + (should-not (save-excursion (re-search-forward "^\\*\\* In Progress$" nil t))) + (let ((counts (pearl--merge-issues-into-buffer + (list (test-pearl-merge--issue-st "a" "Alpha" "Todo" "unstarted") + (test-pearl-merge--issue-st "d" "Delta" "In Progress" "started")) + "workflowState"))) + (should (= 1 (plist-get counts :added))) + (goto-char (point-min)) + (let ((grp (re-search-forward "^\\*\\* In Progress$" nil t))) + (should grp) + (should (re-search-forward "^\\*\\*\\* .*Delta" nil t)))))) + (ert-deftest test-pearl-merge-keeps-unpushed-edit-on-update () "An existing subtree with unpushed body edits is kept, not overwritten." (test-pearl-merge--in-org diff --git a/tests/test-pearl-output.el b/tests/test-pearl-output.el index 8b02fcb..f02df5f 100644 --- a/tests/test-pearl-output.el +++ b/tests/test-pearl-output.el @@ -70,6 +70,51 @@ (should (string-match-p "^#\\+title:" out)) (should-not (string-match-p "^\\*\\*\\* " out)))) +;;; --build-org-content grouping (issueGrouping -> Org sections) + +(ert-deftest test-pearl-build-org-content-flat-startup-show2levels () + "An ungrouped view folds to show2levels and renders issues at level 2." + (let* ((issues '((:id "1" :identifier "SE-1" :title "One" + :state (:name "Started" :type "started") :priority 0))) + (source '(:type view :name "Flat" :id "v1")) + (out (pearl--build-org-content issues source))) + (should (string-match-p "^#\\+STARTUP: show2levels$" out)) + (should (string-match-p "^\\*\\* STARTED " out)) + (should-not (string-match-p "^\\*\\*\\* " out)))) + +(ert-deftest test-pearl-build-org-content-grouped-sections-and-startup () + "A grouped view folds to show3levels, emits a level-2 group heading per group +in first-appearance order, and renders issues at level 3 under them." + (let* ((issues '((:id "1" :identifier "SE-1" :title "One" + :state (:name "Started" :type "started") :priority 0) + (:id "2" :identifier "SE-2" :title "Two" + :state (:name "Backlog" :type "backlog") :priority 0) + (:id "3" :identifier "SE-3" :title "Three" + :state (:name "Started" :type "started") :priority 0))) + (source '(:type view :name "By status" :id "v1" :group "workflowState")) + (out (pearl--build-org-content issues source))) + (should (string-match-p "^#\\+STARTUP: show3levels$" out)) + (should (string-match-p "^\\*\\* Started$" out)) + (should (string-match-p "^\\*\\* Backlog$" out)) + ;; Issues live at level 3 under the group headings. + (should (string-match-p "^\\*\\*\\* STARTED " out)) + ;; First-appearance order: SE-1 (Started) before SE-2 (Backlog). + (should (< (string-match "^\\*\\* Started$" out) + (string-match "^\\*\\* Backlog$" out))) + ;; The group heading carries no LINEAR-ID, so save/merge skip it. + (let ((started (string-match "^\\*\\* Started$" out)) + (next-id (string-match "LINEAR-ID:" out))) + (should started) + (should (< started next-id))))) + +(ert-deftest test-pearl-build-org-content-grouped-source-roundtrips () + "A grouped view's source (with :group) reads back from the rendered header." + (let* ((source '(:type view :name "By project" :id "v1" :group "project")) + (out (pearl--build-org-content '() source))) + (with-temp-buffer + (insert out) + (should (equal source (pearl--read-active-source)))))) + (ert-deftest test-pearl-build-org-content-title-uses-view-name-verbatim () "The view name in the file title matches Linear's capitalization verbatim, independent of `pearl-title-case-headings' (which governs issue headings only)." @@ -336,7 +381,7 @@ refresh prompt stubbed to return CHOICE. BUF and TMP are bound in BODY." (let ((merged nil)) (test-pearl-output--with-dirty-buffer ?m (cl-letf (((symbol-function 'pearl--merge-issues-into-buffer) - (lambda (_issues) (setq merged t) '(:updated 1 :added 0 :dropped 0 :skipped 0))) + (lambda (_issues &optional _grouping) (setq merged t) '(:updated 1 :added 0 :dropped 0 :skipped 0))) ((symbol-function 'pearl--update-source-header) #'ignore) ((symbol-function 'pearl--update-derived-todo-header) #'ignore) ((symbol-function 'pearl-highlight-comments) #'ignore) diff --git a/tests/test-pearl-refresh.el b/tests/test-pearl-refresh.el index d09c96c..199b346 100644 --- a/tests/test-pearl-refresh.el +++ b/tests/test-pearl-refresh.el @@ -93,7 +93,9 @@ (re-search-forward "Stale Title") (pearl-refresh-current-issue) (goto-char (point-min)) - (re-search-forward "^\\*\\* ") + ;; refresh preserves the issue's heading level (here level 3) rather than + ;; flattening it -- a grouped view's issues stay at level 3 on refresh + (re-search-forward "^\\*\\*\\* ") ;; heading + drawer reflect the remote (should (string-match-p "Refreshed Title" (thing-at-point 'line t))) (should (string= "In Progress" (org-entry-get nil "LINEAR-STATE-NAME"))) diff --git a/tests/test-pearl-views.el b/tests/test-pearl-views.el index 3675200..7c8ec80 100644 --- a/tests/test-pearl-views.el +++ b/tests/test-pearl-views.el @@ -325,6 +325,76 @@ the pick-source path showed a view's completed/canceled issues and ignored sort. ;; The favorite's own keys survive the enrichment. (should (string= "https://x" (plist-get rendered-source :url)))))) +;;; --view-grouping-active-p / --view-group-label / --group-issues + +(ert-deftest test-pearl-view-grouping-active-p-supported-types () + "The single-valued grouping dimensions are active in v1." + (dolist (g '("workflowState" "project" "assignee" "priority" "cycle")) + (should (pearl--view-grouping-active-p g)))) + +(ert-deftest test-pearl-view-grouping-active-p-deferred-and-none () + "Label-grouping (multi-valued, deferred), none, nil, and unknown values are +inactive, so the view renders flat." + (dolist (g '("label" "none" nil "someFutureGrouping")) + (should-not (pearl--view-grouping-active-p g)))) + +(ert-deftest test-pearl-view-group-label-by-dimension () + "Each supported grouping resolves an issue to its group label." + (let ((issue '(:state (:name "In Progress" :type "started") + :project (:name "Orchestration") + :assignee (:name "Eric") + :priority 1 + :cycle (:number 4 :name "Sprint 4")))) + (should (string= "In Progress" (pearl--view-group-label issue "workflowState"))) + (should (string= "Orchestration" (pearl--view-group-label issue "project"))) + (should (string= "Eric" (pearl--view-group-label issue "assignee"))) + (should (string= "Urgent" (pearl--view-group-label issue "priority"))) + (should (string= "Sprint 4" (pearl--view-group-label issue "cycle"))))) + +(ert-deftest test-pearl-view-group-label-missing-values () + "A missing dimension value gets a stable \"No X\" bucket label, matching how +Linear shows unset issues in a grouped view." + (let ((issue '(:priority 0))) + (should (string= "No status" (pearl--view-group-label issue "workflowState"))) + (should (string= "No project" (pearl--view-group-label issue "project"))) + (should (string= "No assignee" (pearl--view-group-label issue "assignee"))) + (should (string= "No cycle" (pearl--view-group-label issue "cycle"))) + (should (string= "None" (pearl--view-group-label issue "priority"))))) + +(ert-deftest test-pearl-view-group-label-cycle-number-fallback () + "A cycle with no name falls back to its number." + (should (string= "Cycle 7" + (pearl--view-group-label '(:cycle (:number 7)) "cycle")))) + +(ert-deftest test-pearl-group-issues-inactive-is-nil () + "An inactive grouping returns nil, signaling the caller to render flat." + (should-not (pearl--group-issues '((:id "a" :state (:name "Todo"))) "none")) + (should-not (pearl--group-issues '((:id "a" :state (:name "Todo"))) nil))) + +(ert-deftest test-pearl-group-issues-orders-by-first-appearance () + "Groups appear in the order their first issue does; issues keep their input +order within a group (the layer-1 sort is preserved inside each section)." + (let* ((issues '((:id "1" :state (:name "Started")) + (:id "2" :state (:name "Backlog")) + (:id "3" :state (:name "Started")) + (:id "4" :state (:name "Backlog")))) + (groups (pearl--group-issues issues "workflowState"))) + (should (equal '("Started" "Backlog") (mapcar #'car groups))) + (should (equal '("1" "3") (mapcar (lambda (i) (plist-get i :id)) + (cdr (assoc "Started" groups))))) + (should (equal '("2" "4") (mapcar (lambda (i) (plist-get i :id)) + (cdr (assoc "Backlog" groups))))))) + +;;; --view-node-prefs carries grouping + +(ert-deftest test-pearl-view-node-prefs-extracts-grouping () + "The view node's issueGrouping is read into :group alongside sort and +show-completed, so run/refresh can section the issues." + (let ((prefs (pearl--view-node-prefs + '((id . "v1") + (viewPreferencesValues (issueGrouping . "workflowState")))))) + (should (string= "workflowState" (plist-get prefs :group))))) + ;;; refresh-current-view, view branch (ert-deftest test-pearl-refresh-current-view-runs-view-source () |
