diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-05 00:56:19 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-05 00:56:19 -0500 |
| commit | 951abea0b2ed07d71676cd4d10ff7c6ca50d1390 (patch) | |
| tree | 6fbe516d9b757e38a5e6e6950f6fe8e4767bf40b | |
| parent | 387c8a9ff9828d1b024987d136d216ef12c5103f (diff) | |
| download | pearl-951abea0b2ed07d71676cd4d10ff7c6ca50d1390.tar.gz pearl-951abea0b2ed07d71676cd4d10ff7c6ca50d1390.zip | |
feat(views): sort the active view interactively
pearl-set-sort reorders the current view by updated, created, priority, or title without hand-editing a source. pearl-toggle-sort-order flips asc/desc on the current sort, defaulting to updated descending. Both are M-x for now. The spec deferred a key binding until the menu layout settles.
Priority and title sort client-side: pearl--reorder-issue-subtrees moves whole issue subtrees by LINEAR-ID, byte-for-byte, so unsaved description and comment edits survive and a non-issue heading is kept rather than dropped. Updated and created refetch with the new server orderBy. The #+LINEAR-SOURCE header is rewritten only after the reorder or refetch succeeds, so a failed sort never advertises an order the buffer doesn't show.
Two things the 2026-05-24 spec predates and I had to settle. A view's :sort now holds Linear's IssueSortInput, so the interactive sort on a view persists under separate :client-sort/:client-order keys and pearl--effective-sort-order re-applies it on refresh. That keeps a view sort surviving a refresh the same way a filter sort does, rather than behaving differently on a distinction the user can't see. Grouped views aren't reorderable in place yet. They refuse with a message. A created/updated sort on a Custom View is refused too, since Linear gives views no server-side order.
| -rw-r--r-- | README.org | 6 | ||||
| -rw-r--r-- | pearl.el | 251 | ||||
| -rw-r--r-- | tests/test-pearl-sort.el | 294 |
3 files changed, 540 insertions, 11 deletions
@@ -295,6 +295,12 @@ Sorting is local and deterministic. Query filters are AND-only; use a Linear Cus =pearl-refresh-current-view= merges the fresh fetch into the buffer by issue id rather than rebuilding it: issues update in place, new matches are appended, and ones no longer in the result are dropped. Your view survives: point and the fold state of every untouched issue stay put, and only the subtrees that actually changed get re-folded. Editing an issue and refreshing won't collapse the subtree you were working in or scroll you back to the top. +*** Sorting the current view + +=pearl-set-sort= reorders the active view without hand-editing a source. It asks for a key (=updated=, =created=, =priority=, =title=) and an order (=desc= / =asc=). =pearl-toggle-sort-order= flips the order on the current sort, defaulting to =updated= descending when nothing is set yet. + +Priority and title sort the fetched issues in place: whole subtrees move by id, so unsaved edits survive, and the order sticks across a refresh. Updated and created refetch with the new server ordering. A Custom View has no server-side order in Linear's API, so =updated= / =created= are refused on a view -- sort it by priority or title instead, which still reorders the issues it returned. Sorting a grouped view in place isn't supported yet. Both commands are =M-x= for now. A key binding lands once the menu layout settles. + *** Editing issues Pearl has one write path. Edit an issue however you like in the buffer, then save it -- Pearl diffs each field against what it last fetched and pushes only what changed. Nothing pushes the moment you pick a value, and there is no per-field "push" command to remember. All issue commands work from anywhere inside an issue subtree. @@ -4294,6 +4294,42 @@ produces the same heading order rather than reshuffling into noise." `updatedAt', the only other field Linear's public ordering supports." (if (eq sort 'created) 'createdAt 'updatedAt)) +(defconst pearl--sort-keys '(updated created priority title) + "The interactive sort keys. `priority'/`title' sort client-side; `updated'/ +`created' map to the server `orderBy'.") + +(defconst pearl--client-sort-keys '(priority title) + "Sort keys computed client-side from buffer state, reordering without a refetch.") + +(defun pearl--check-sort-order (sort order) + "Validate SORT and ORDER symbols; signal a `user-error' on an unknown value. +Returns t when both are acceptable. A nil SORT or ORDER is allowed (no sort)." + (when (and sort (not (memq sort pearl--sort-keys))) + (user-error "Unknown sort key %s; use one of %s" + sort (mapconcat #'symbol-name pearl--sort-keys "/"))) + (when (and order (not (memq order '(asc desc)))) + (user-error "Unknown order %s; use asc or desc" order)) + t) + +(defun pearl--source-with-sort (source sort order) + "Return SOURCE with the interactive SORT/ORDER recorded. +A view stores them under `:client-sort'/`:client-order', leaving its server +`:sort' (an `IssueSortInput') intact; every other source uses `:sort'/`:order' +\(symbols). The split keeps the interactive reorder from clobbering a view's +configured server order, which the refresh path feeds back to Linear." + (let ((s (copy-sequence source))) + (if (eq (plist-get source :type) 'view) + (plist-put (plist-put s :client-sort sort) :client-order order) + (plist-put (plist-put s :sort sort) :order order)))) + +(defun pearl--effective-sort-order (source) + "Return (SORT . ORDER) for the client-side render sort of SOURCE. +A view uses `:client-sort'/`:client-order' (nil until the user sorts it, so the +view's server order is honored); every other source uses `:sort'/`:order'." + (if (eq (plist-get source :type) 'view) + (cons (plist-get source :client-sort) (plist-get source :client-order)) + (cons (plist-get source :sort) (plist-get source :order)))) + ;;; Sentinel infrastructure for filter / pick-one prompts ;; ;; These sentinel constants and helpers back the prompt UX shared by @@ -5418,14 +5454,15 @@ boundary shared by list-issues, the ad-hoc filter, local views, views, and refresh." (pcase (pearl--query-result-status result) ('ok - (let ((issues (pearl--sort-issues - (mapcar (lambda (n) - (pearl--cap-issue-list-comments - (pearl--normalize-issue n))) - (pearl--query-result-issues result)) - (plist-get source :sort) - (plist-get source :order))) - (truncated (pearl--query-result-truncated-p result))) + (let* ((eff (pearl--effective-sort-order source)) + (issues (pearl--sort-issues + (mapcar (lambda (n) + (pearl--cap-issue-list-comments + (pearl--normalize-issue n))) + (pearl--query-result-issues result)) + (car eff) + (cdr eff))) + (truncated (pearl--query-result-truncated-p result))) (condition-case err (progn (pearl--update-org-from-issues issues source truncated) @@ -6553,13 +6590,14 @@ rather than dropping every issue, mirroring the non-destructive empty handling of the replace path." (pcase (pearl--query-result-status result) ('ok - (let* ((issues (pearl--sort-issues + (let* ((eff (pearl--effective-sort-order source)) + (issues (pearl--sort-issues (mapcar (lambda (n) (pearl--cap-issue-list-comments (pearl--normalize-issue n))) (pearl--query-result-issues result)) - (plist-get source :sort) - (plist-get source :order))) + (car eff) + (cdr eff))) (truncated (pearl--query-result-truncated-p result)) (counts (pearl--merge-issues-into-buffer issues (plist-get source :group)))) @@ -6618,6 +6656,197 @@ commands. Errors if no source is recorded." (plist-get source :show-completed)))) (_ (user-error "Unknown Linear source type: %s" (plist-get source :type))))))) +;;; Interactive sort/order of the active view + +(defun pearl--write-linear-source-header (source) + "Replace the buffer's `#+LINEAR-SOURCE:' descriptor with SOURCE. +Distinct from `pearl--update-source-header', which only advances the +count/timestamp provenance and leaves the descriptor alone." + (save-excursion + (goto-char (point-min)) + (when (re-search-forward "^#\\+LINEAR-SOURCE: .*$" nil t) + (replace-match (format "#+LINEAR-SOURCE: %s" (prin1-to-string source)) t t)))) + +(defun pearl--active-source-or-error () + "Return the active source descriptor, or signal a `user-error' if absent." + (or (pearl--read-active-source) + (user-error "No Linear source recorded in this file; run a query or view first"))) + +(defun pearl--issue-sort-key (sort) + "Return the client-side SORT key for the issue heading at point. +`priority' reads the heading cookie (0-4); `title' reads the stripped heading +title downcased -- the visible title, including an unsaved local edit. Point +must be on the issue heading." + (pcase sort + ('priority (pearl--priority-number-at-point)) + ('title (downcase (pearl--issue-title-at-point))) + (_ nil))) + +(defun pearl--sort-subtree-entries (entries order) + "Sort ENTRIES, each a plist (:key :idx :text), by `:key' in ORDER. +A nil `:key' (a non-issue / malformed subtree) always sorts last, in original +order. The keyed sort is stable, so equal keys keep document order." + (let ((keyed (cl-remove-if-not (lambda (e) (plist-get e :key)) entries)) + (unkeyed (cl-remove-if (lambda (e) (plist-get e :key)) entries))) + (let* ((cmp (lambda (a b) + (let ((ka (plist-get a :key)) (kb (plist-get b :key))) + (if (numberp ka) + (if (eq order 'asc) (< ka kb) (> ka kb)) + (if (eq order 'asc) (string< ka kb) (string> ka kb)))))) + (ordered (cl-stable-sort (copy-sequence keyed) cmp))) + (append ordered unkeyed)))) + +(defun pearl--reorder-issue-subtrees (sort order) + "Reorder the active view's issue subtrees in place by SORT in ORDER. +Moves whole subtrees byte-for-byte -- descriptions, comments, drawers, and any +unsaved local edits survive unchanged -- rather than reconstructing them from +parsed data. A non-issue level-2 subtree (no `LINEAR-ID') is kept and sorted +last. Returns t on success, `grouped' when issues sit below level 2 (a grouped +view, unsupported in v1), or `no-issues' when there are none." + (save-excursion + (let ((markers (pearl--issue-subtree-markers))) + (cond + ((null markers) 'no-issues) + ((let (grouped) + (dolist (m markers) + (goto-char (cdr m)) + (unless (= (or (org-current-level) 0) 2) (setq grouped t))) + grouped) + 'grouped) + (t + ;; The issues are the direct level-2 children of the single view parent. + ;; Walk every level-2 subtree in that span, capture each region's text, + ;; sort, and rewrite the contiguous block in one delete+insert. + (goto-char (cdr (car markers))) + (org-up-heading-safe) + (let ((parent-end (save-excursion (org-end-of-subtree t t) (point))) + (children '()) (idx 0) (block-beg nil) (block-end nil)) + (outline-next-heading) + (while (and (< (point) parent-end) (org-at-heading-p)) + (if (= (or (org-current-level) 0) 2) + (let* ((beg (point)) + (end (save-excursion (org-end-of-subtree t t) (point))) + (id (org-entry-get nil "LINEAR-ID")) + (key (and id (not (string-empty-p id)) + (pearl--issue-sort-key sort))) + (text (buffer-substring-no-properties beg end))) + (unless block-beg (setq block-beg beg)) + (setq block-end end) + (push (list :key key :idx idx :text text) children) + (setq idx (1+ idx)) + (goto-char end)) + (goto-char (save-excursion (org-end-of-subtree t t) (point))))) + (when (and block-beg block-end) + (let ((sorted (pearl--sort-subtree-entries (nreverse children) order))) + (delete-region block-beg block-end) + (goto-char block-beg) + (insert (mapconcat (lambda (c) (plist-get c :text)) sorted "")))) + t)))))) + +(defun pearl--toggle-sort-target (source) + "Return the (SORT . ORDER) to apply when toggling SOURCE's order. +On a filter with no sort, establishes `updated' descending; otherwise flips the +order, keeping the sort. On a view it flips `:client-order'; with no client +sort set it signals a `user-error', since a Custom View has no symbol order to +toggle until you sort it." + (if (eq (plist-get source :type) 'view) + (let ((cs (plist-get source :client-sort))) + (unless cs + (user-error "Sort the view first with `pearl-set-sort'; a Custom View has no default order to toggle")) + (cons cs (if (eq (plist-get source :client-order) 'asc) 'desc 'asc))) + (let ((s (plist-get source :sort)) + (o (plist-get source :order))) + (if (null s) + (cons 'updated 'desc) + (cons s (if (eq o 'asc) 'desc 'asc)))))) + +(defun pearl--apply-server-sort (source sort order) + "Refetch SOURCE (a filter) with the server `orderBy' for SORT, then persist. +On success merges the result and writes the updated `#+LINEAR-SOURCE' header; on +an empty/failed fetch it leaves the header unchanged and reports the merge +outcome, so a failed sort never advertises an order the buffer doesn't show." + (let ((buffer (current-buffer))) + (pearl--progress "Refetching %s ordered by %s %s..." + (pearl--source-name source) sort order) + (pearl--query-issues-async + (pearl--build-issue-filter (plist-get source :filter)) + (lambda (result) + (when (buffer-live-p buffer) + (with-current-buffer buffer + (pcase (pearl--query-result-status result) + ('ok + (pearl--merge-query-result result source) + (pearl--write-linear-source-header source) + (message "Refetched %s ordered by %s %sending" + (pearl--source-name source) sort + (if (eq order 'asc) "asc" "desc"))) + (_ (pearl--merge-query-result result source)))))) + (pearl--sort->order-by sort)))) + +(defun pearl--apply-sort (source sort order) + "Apply interactive SORT/ORDER to SOURCE in the current buffer. +Priority/title reorder the fetched issues in place (`client'); updated/created +refetch with the new server ordering (`server'), refused on a Custom View. The +`#+LINEAR-SOURCE' header is written only after a successful reorder or refetch. +Returns `client', `server', `grouped', or `no-issues'." + (pearl--check-sort-order sort order) + (let ((type (plist-get source :type)) + (client (memq sort pearl--client-sort-keys)) + (updated (pearl--source-with-sort source sort order))) + (cond + ((and (eq type 'view) (not client)) + (user-error "Linear has no server-side ordering for Custom Views; sort by priority or title to reorder the fetched issues")) + (client + (pcase (pearl--reorder-issue-subtrees sort order) + ('grouped + (message "Can't sort a grouped view in place yet; ungroup or sort in Linear") + 'grouped) + ('no-issues + (message "No issues to sort in this buffer") + 'no-issues) + (_ + (pearl--write-linear-source-header updated) + (message "Sorted current buffer by %s %sending" + sort (if (eq order 'asc) "asc" "desc")) + 'client))) + (t + (pearl--apply-server-sort updated sort order) + 'server)))) + +;;;###autoload +(defun pearl-set-sort (sort order) + "Sort the active Linear view by SORT in ORDER. +Interactively completes the sort key (updated/created/priority/title) and the +order (asc/desc). Priority and title reorder the fetched issues in place, +preserving unsaved edits; updated and created refetch with the new server +ordering -- refused on a Custom View, which Linear gives no server-side order. +The recorded source advances only after the sort succeeds, so a refresh +reproduces what you see." + (interactive + (list (intern (completing-read + "Sort by: " + (pearl--completion-table-keep-order + '("updated" "created" "priority" "title")) + nil t)) + (intern (completing-read + "Order: " + (pearl--completion-table-keep-order '("desc" "asc")) + nil t nil nil "desc")))) + (pearl--require-account-context) + (pearl--apply-sort (pearl--active-source-or-error) sort order)) + +;;;###autoload +(defun pearl-toggle-sort-order () + "Flip the active Linear view between ascending and descending. +Keeps the current sort and flips the order; with no sort yet, establishes +`updated' descending. On a Custom View it flips the in-place priority/title +sort -- with none set, asks you to run `pearl-set-sort' first." + (interactive) + (pearl--require-account-context) + (let* ((source (pearl--active-source-or-error)) + (target (pearl--toggle-sort-target source))) + (pearl--apply-sort source (car target) (cdr target)))) + (defun pearl--view-node-prefs (view) "Extract the runnable view preferences from a Custom View node VIEW. Returns `(:sort SORT :show-completed SC)': SORT is the `IssueSortInput' for the diff --git a/tests/test-pearl-sort.el b/tests/test-pearl-sort.el new file mode 100644 index 0000000..6527989 --- /dev/null +++ b/tests/test-pearl-sort.el @@ -0,0 +1,294 @@ +;;; test-pearl-sort.el --- Tests for interactive sort/order -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for changing the order of the active view interactively: +;; the source-plist helpers (symbol :sort/:order for filters, +;; :client-sort/:client-order for views), the #+LINEAR-SOURCE header +;; rewrite, the byte-for-byte client-side subtree reorder (preserving +;; local edits), and the pearl--apply-sort routing (client vs server vs +;; refuse) plus the toggle target. + +;;; Code: + +(require 'test-bootstrap (expand-file-name "test-bootstrap.el")) +(require 'cl-lib) + +(defmacro test-pearl-sort--in-org (content &rest body) + "Run BODY in an org-mode temp buffer holding CONTENT." + (declare (indent 1)) + `(let ((org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE")))) + (with-temp-buffer + (insert ,content) + (org-mode) + (goto-char (point-min)) + ,@body))) + +(defconst test-pearl-sort--flat + (concat + "#+title: Linear — My open issues\n" + "#+STARTUP: show2levels\n" + "#+TODO: TODO | DONE\n" + "#+LINEAR-SOURCE: (:type filter :name \"My open issues\" :filter (:open t))\n" + "#+LINEAR-COUNT: 2\n\n" + "* My open issues\n" + "** TODO [#C] SE-2: Beta\n:PROPERTIES:\n:LINEAR-ID: i2\n:LINEAR-IDENTIFIER: SE-2\n:END:\n" + "Body beta.\n" + "** TODO [#A] SE-1: Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n:LINEAR-IDENTIFIER: SE-1\n:END:\n" + "Body alpha.\n") + "A flat two-issue buffer: SE-2 [#C] Beta first, SE-1 [#A] Alpha second.") + +(defun test-pearl-sort--issue-ids () + "Return the LINEAR-IDs of the level-2 issue headings in document order." + (let (ids) + (save-excursion + (goto-char (point-min)) + (while (re-search-forward "^\\*\\* " nil t) + (let ((id (org-entry-get nil "LINEAR-ID"))) + (when id (push id ids))))) + (nreverse ids))) + +;;; source-plist helpers + +(ert-deftest test-pearl-source-with-sort-filter-uses-sort-keys () + "A filter source records the interactive sort under :sort/:order (symbols)." + (let ((out (pearl--source-with-sort '(:type filter :name "x") 'priority 'asc))) + (should (eq (plist-get out :sort) 'priority)) + (should (eq (plist-get out :order) 'asc)))) + +(ert-deftest test-pearl-source-with-sort-view-uses-client-keys () + "A view source records the interactive sort under :client-sort/:client-order, +leaving its server :sort (an IssueSortInput) untouched." + (let ((out (pearl--source-with-sort + '(:type view :name "v" :id "vid" :sort ((:priority "Ascending"))) + 'title 'desc))) + (should (eq (plist-get out :client-sort) 'title)) + (should (eq (plist-get out :client-order) 'desc)) + (should (equal (plist-get out :sort) '((:priority "Ascending")))))) + +(ert-deftest test-pearl-effective-sort-order-filter () + "Effective client sort for a filter is its :sort/:order." + (should (equal (pearl--effective-sort-order '(:type filter :sort priority :order asc)) + '(priority . asc)))) + +(ert-deftest test-pearl-effective-sort-order-view-with-client () + "Effective client sort for a view is its :client-sort/:client-order." + (should (equal (pearl--effective-sort-order + '(:type view :sort ((:x "y")) :client-sort title :client-order desc)) + '(title . desc)))) + +(ert-deftest test-pearl-effective-sort-order-view-without-client () + "A view with no client sort has no effective client sort (server order honored)." + (should (equal (pearl--effective-sort-order '(:type view :sort ((:x "y")))) + '(nil . nil)))) + +;;; symbol contract + +(ert-deftest test-pearl-check-sort-order-valid () + "Valid sort/order symbols pass." + (should (pearl--check-sort-order 'priority 'asc)) + (should (pearl--check-sort-order 'updated 'desc)) + (should (pearl--check-sort-order nil nil))) + +(ert-deftest test-pearl-check-sort-order-unknown-sort-errors () + "An unknown sort symbol signals a user-error." + (should-error (pearl--check-sort-order 'sideways 'asc) :type 'user-error)) + +(ert-deftest test-pearl-check-sort-order-unknown-order-errors () + "An unknown order symbol signals a user-error." + (should-error (pearl--check-sort-order 'priority 'upward) :type 'user-error)) + +;;; header rewrite + +(ert-deftest test-pearl-write-linear-source-header-roundtrips () + "Writing a new source header is read back by `pearl--read-active-source'." + (test-pearl-sort--in-org test-pearl-sort--flat + (pearl--write-linear-source-header + '(:type filter :name "My open issues" :filter (:open t) + :sort priority :order asc)) + (let ((src (pearl--read-active-source))) + (should (eq (plist-get src :sort) 'priority)) + (should (eq (plist-get src :order) 'asc))))) + +;;; client-side reorder + +(ert-deftest test-pearl-reorder-priority-ascending () + "Priority-ascending reorder puts the [#A] issue before the [#C] issue." + (test-pearl-sort--in-org test-pearl-sort--flat + (should (eq (pearl--reorder-issue-subtrees 'priority 'asc) t)) + (should (equal (test-pearl-sort--issue-ids) '("i1" "i2"))))) + +(ert-deftest test-pearl-reorder-priority-descending () + "Priority-descending reorder keeps the [#C] issue before the [#A] issue." + (test-pearl-sort--in-org test-pearl-sort--flat + (should (eq (pearl--reorder-issue-subtrees 'priority 'desc) t)) + (should (equal (test-pearl-sort--issue-ids) '("i2" "i1"))))) + +(ert-deftest test-pearl-reorder-title-ascending () + "Title-ascending reorder sorts Alpha before Beta." + (test-pearl-sort--in-org test-pearl-sort--flat + (should (eq (pearl--reorder-issue-subtrees 'title 'asc) t)) + (should (equal (test-pearl-sort--issue-ids) '("i1" "i2"))))) + +(ert-deftest test-pearl-reorder-preserves-local-edits () + "Reorder moves whole subtrees byte-for-byte, keeping an unsaved body edit." + (test-pearl-sort--in-org test-pearl-sort--flat + ;; Edit SE-1's body in place before sorting. + (goto-char (point-min)) + (search-forward "Body alpha.") + (replace-match "Body alpha EDITED LOCALLY.") + (should (eq (pearl--reorder-issue-subtrees 'priority 'asc) t)) + (should (equal (test-pearl-sort--issue-ids) '("i1" "i2"))) + (goto-char (point-min)) + (should (search-forward "Body alpha EDITED LOCALLY." nil t)))) + +(ert-deftest test-pearl-reorder-grouped-refuses () + "A grouped buffer (issues below level 2) refuses with 'grouped, unchanged." + (test-pearl-sort--in-org + (concat + "#+LINEAR-SOURCE: (:type view :name \"v\" :id \"vid\")\n\n" + "* v\n" + "** Group One\n" + "*** TODO [#C] SE-2: Beta\n:PROPERTIES:\n:LINEAR-ID: i2\n:END:\n" + "*** TODO [#A] SE-1: Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n:END:\n") + (let ((before (buffer-string))) + (should (eq (pearl--reorder-issue-subtrees 'priority 'asc) 'grouped)) + (should (equal (buffer-string) before))))) + +(ert-deftest test-pearl-reorder-no-issues () + "A buffer with no issue headings returns 'no-issues." + (test-pearl-sort--in-org "* Nothing here\nplain text\n" + (should (eq (pearl--reorder-issue-subtrees 'priority 'asc) 'no-issues)))) + +(ert-deftest test-pearl-reorder-keeps-non-issue-subtree-last () + "A level-2 heading with no LINEAR-ID is kept (sorted last), not dropped." + (test-pearl-sort--in-org + (concat + "#+LINEAR-SOURCE: (:type filter :name \"x\" :filter nil)\n\n" + "* x\n" + "** TODO [#C] SE-2: Beta\n:PROPERTIES:\n:LINEAR-ID: i2\n:LINEAR-IDENTIFIER: SE-2\n:END:\n" + "** A stray local heading\nsome notes\n" + "** TODO [#A] SE-1: Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n:LINEAR-IDENTIFIER: SE-1\n:END:\n") + (should (eq (pearl--reorder-issue-subtrees 'priority 'asc) t)) + (should (equal (test-pearl-sort--issue-ids) '("i1" "i2"))) + (goto-char (point-min)) + (should (search-forward "A stray local heading" nil t)))) + +;;; toggle target + +(ert-deftest test-pearl-toggle-target-filter-no-sort-defaults () + "Toggle on a filter with no sort establishes updated/desc." + (should (equal (pearl--toggle-sort-target '(:type filter)) + '(updated . desc)))) + +(ert-deftest test-pearl-toggle-target-filter-flips-order () + "Toggle on a filter flips the current order, keeping the sort." + (should (equal (pearl--toggle-sort-target '(:type filter :sort priority :order asc)) + '(priority . desc)))) + +(ert-deftest test-pearl-toggle-target-view-needs-client-sort () + "Toggle on a view with no client sort refuses (nothing to flip)." + (should-error (pearl--toggle-sort-target '(:type view :sort ((:x "y")))) + :type 'user-error)) + +(ert-deftest test-pearl-toggle-target-view-flips-client-order () + "Toggle on a view flips the client order." + (should (equal (pearl--toggle-sort-target + '(:type view :client-sort title :client-order desc)) + '(title . asc)))) + +;;; apply-sort routing + +(ert-deftest test-pearl-apply-sort-view-server-key-refuses () + "A created/updated sort on a view is refused (Linear has no view orderBy)." + (test-pearl-sort--in-org test-pearl-sort--flat + (should-error (pearl--apply-sort '(:type view :name "v" :id "vid") 'updated 'desc) + :type 'user-error))) + +(ert-deftest test-pearl-apply-sort-filter-client-key-reorders-and-persists () + "A priority sort on a filter reorders in place and writes :sort to the header." + (test-pearl-sort--in-org test-pearl-sort--flat + (should (eq (pearl--apply-sort + '(:type filter :name "My open issues" :filter (:open t)) + 'priority 'asc) + 'client)) + (should (equal (test-pearl-sort--issue-ids) '("i1" "i2"))) + (should (eq (plist-get (pearl--read-active-source) :sort) 'priority)))) + +(ert-deftest test-pearl-apply-sort-view-client-key-persists-client-keys () + "A priority sort on a view reorders and writes :client-sort, not :sort." + (test-pearl-sort--in-org + (concat + "#+LINEAR-SOURCE: (:type view :name \"v\" :id \"vid\" :sort ((:priority \"Ascending\")))\n\n" + "* v\n" + "** TODO [#C] SE-2: Beta\n:PROPERTIES:\n:LINEAR-ID: i2\n:LINEAR-IDENTIFIER: SE-2\n:END:\n" + "** TODO [#A] SE-1: Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n:LINEAR-IDENTIFIER: SE-1\n:END:\n") + (should (eq (pearl--apply-sort + (pearl--read-active-source) 'priority 'asc) + 'client)) + (should (equal (test-pearl-sort--issue-ids) '("i1" "i2"))) + (let ((src (pearl--read-active-source))) + (should (eq (plist-get src :client-sort) 'priority)) + (should (equal (plist-get src :sort) '((:priority "Ascending"))))))) + +(ert-deftest test-pearl-apply-sort-grouped-refuses-no-header-write () + "A grouped buffer refuses the client reorder and leaves the header unchanged." + (test-pearl-sort--in-org + (concat + "#+LINEAR-SOURCE: (:type filter :name \"x\" :filter nil)\n\n" + "* x\n" + "** Group\n" + "*** TODO [#A] SE-1: Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n:END:\n") + (should (eq (pearl--apply-sort '(:type filter :name "x" :filter nil) 'priority 'asc) + 'grouped)) + (should (null (plist-get (pearl--read-active-source) :sort))))) + +;;; server-side refetch persistence (HP3) + +(ert-deftest test-pearl-apply-server-sort-ok-writes-header () + "A successful server refetch writes the updated sort into #+LINEAR-SOURCE." + (test-pearl-sort--in-org test-pearl-sort--flat + (cl-letf (((symbol-function 'pearl--query-issues-async) + (lambda (_filter callback &optional _order-by) + (funcall callback '(:status ok :issues nil :truncated nil)))) + ((symbol-function 'pearl--merge-query-result) (lambda (&rest _) nil)) + ((symbol-function 'pearl--progress) (lambda (&rest _) nil))) + (pearl--apply-server-sort + '(:type filter :name "My open issues" :filter (:open t) :sort updated :order asc) + 'updated 'asc) + (let ((src (pearl--read-active-source))) + (should (eq (plist-get src :sort) 'updated)) + (should (eq (plist-get src :order) 'asc)))))) + +(ert-deftest test-pearl-apply-server-sort-failure-keeps-header () + "A failed server refetch leaves the #+LINEAR-SOURCE header unchanged (HP3)." + (test-pearl-sort--in-org test-pearl-sort--flat + (cl-letf (((symbol-function 'pearl--query-issues-async) + (lambda (_filter callback &optional _order-by) + (funcall callback '(:status error :message "boom")))) + ((symbol-function 'pearl--merge-query-result) (lambda (&rest _) nil)) + ((symbol-function 'pearl--progress) (lambda (&rest _) nil))) + (pearl--apply-server-sort + '(:type filter :name "My open issues" :filter (:open t) :sort updated :order asc) + 'updated 'asc) + (should (null (plist-get (pearl--read-active-source) :sort)))))) + +(provide 'test-pearl-sort) +;;; test-pearl-sort.el ends here |
