aboutsummaryrefslogtreecommitdiff
path: root/tests/test-pearl-favorites.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-27 22:54:43 -0500
committerCraig Jennings <c@cjennings.net>2026-05-27 22:54:43 -0500
commitdcfecf3e4f46c920eb77ca9b3169d0719fa0b9fd (patch)
treedc710dc4412152897aa7fa617c0cabc6bbc08caa /tests/test-pearl-favorites.el
parent15ce896744a8095c221bdeaf887f6311f9a94526 (diff)
downloadpearl-dcfecf3e4f46c920eb77ca9b3169d0719fa0b9fd.tar.gz
pearl-dcfecf3e4f46c920eb77ca9b3169d0719fa0b9fd.zip
feat(sources): favorites, views, and filters via pick-source
Pearl now treats Linear sources (favorites, custom views, ad-hoc filters, saved queries) as one surface. `pearl-pick-source` is the unified picker: it lists every Linear favorite alongside every locally saved query, and dispatches by kind. View / project / cycle / label / user favorites resolve to runnable filter or view sources; issue / document / dashboard favorites open in the browser since they aren't lists. The filter compiler grows id-based forms (`:label-id` and `:assignee-id`), so favorites that name a Linear entity (a user, a label) compile to rename-proof queries rather than fragile name matching. The ad-hoc builder gets a matching member-assignee option ("me / member / any"), so a one-off "what's a teammate working on?" doesn't require building a saved query first. Two pre-existing builder bugs surfaced during verify and got fixed in the same pass. The builder previously passed display names straight to the compiler, but `:team` compiles to `team.key.eq` and `:project` to `project.id.eq`, so Linear rejected every builder run that pinned a team or project. Now the builder resolves the picked name to its key / id before assembling. And `pearl-list-issues-by-project` previously injected `:assignee :me` into the filter, hiding every teammate's work in the project. It now returns the whole project. The save prompt on the builder was rewritten so the local-only nature is unambiguous, and the README grew a Sources section plus a keymap-table entry for `f s`.
Diffstat (limited to 'tests/test-pearl-favorites.el')
-rw-r--r--tests/test-pearl-favorites.el237
1 files changed, 237 insertions, 0 deletions
diff --git a/tests/test-pearl-favorites.el b/tests/test-pearl-favorites.el
new file mode 100644
index 0000000..b47cde6
--- /dev/null
+++ b/tests/test-pearl-favorites.el
@@ -0,0 +1,237 @@
+;;; test-pearl-favorites.el --- Tests for favorites layer -*- 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 the favorites layer (spec: docs/issue-sources-spec.org). Covers
+;; `pearl--normalize-favorite' (node -> typed plist), `pearl--favorite->source'
+;; (typed plist -> runnable source or browser action), and
+;; `pearl--favorites-async' (paged fetch with the request boundary stubbed).
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+(require 'cl-lib)
+
+(defun test-pearl-fav--node (type title url sort-order entity-key entity-id &optional identifier)
+ "Build a favorite NODE alist for TYPE, optionally with an ENTITY-KEY/ID ref.
+For an issue node, IDENTIFIER fills the issue's user-facing key."
+ (let ((node (list (cons 'id (format "fav-%s" type))
+ (cons 'type type)
+ (cons 'title title)
+ (cons 'url url)
+ (cons 'sortOrder sort-order))))
+ (when entity-key
+ (push (cons entity-key
+ (append (list (cons 'id entity-id))
+ (when identifier (list (cons 'identifier identifier)))))
+ node))
+ node))
+
+;;; normalize per type
+
+(ert-deftest test-pearl-normalize-favorite-custom-view ()
+ "A customView favorite normalizes to :kind view with the view's id."
+ (let ((p (pearl--normalize-favorite
+ (test-pearl-fav--node "customView" "Active bugs" "https://linear.app/x/view/v1" 0.1
+ 'customView "view-1"))))
+ (should (eq 'view (plist-get p :kind)))
+ (should (string= "Active bugs" (plist-get p :title)))
+ (should (string= "view-1" (plist-get p :id)))))
+
+(ert-deftest test-pearl-normalize-favorite-project ()
+ (let ((p (pearl--normalize-favorite
+ (test-pearl-fav--node "project" "Platform" "https://linear.app/x/project/p1" 0.2
+ 'project "proj-1"))))
+ (should (eq 'project (plist-get p :kind)))
+ (should (string= "proj-1" (plist-get p :id)))))
+
+(ert-deftest test-pearl-normalize-favorite-cycle ()
+ (let ((p (pearl--normalize-favorite
+ (test-pearl-fav--node "cycle" "Sprint 12" "https://linear.app/x/cycle/c1" 0.3
+ 'cycle "cyc-1"))))
+ (should (eq 'cycle (plist-get p :kind)))
+ (should (string= "cyc-1" (plist-get p :id)))))
+
+(ert-deftest test-pearl-normalize-favorite-label-and-project-label ()
+ "Both label and projectLabel normalize to :kind label (collapsed for dispatch)."
+ (let ((a (pearl--normalize-favorite
+ (test-pearl-fav--node "label" "security" "https://linear.app/x/label/l1" 0.4
+ 'label "lbl-1")))
+ (b (pearl--normalize-favorite
+ (test-pearl-fav--node "projectLabel" "milestone" "https://linear.app/x/pl/pl1" 0.5
+ 'projectLabel "plbl-1"))))
+ (should (eq 'label (plist-get a :kind)))
+ (should (string= "lbl-1" (plist-get a :id)))
+ (should (eq 'label (plist-get b :kind)))
+ (should (string= "plbl-1" (plist-get b :id)))))
+
+(ert-deftest test-pearl-normalize-favorite-user ()
+ (let ((p (pearl--normalize-favorite
+ (test-pearl-fav--node "user" "Vrezh" "https://linear.app/x/user/u1" 0.6
+ 'user "user-1"))))
+ (should (eq 'user (plist-get p :kind)))
+ (should (string= "user-1" (plist-get p :id)))))
+
+(ert-deftest test-pearl-normalize-favorite-issue-carries-identifier ()
+ "An issue favorite carries the user-facing identifier alongside the id."
+ (let ((p (pearl--normalize-favorite
+ (test-pearl-fav--node "issue" "Some bug" "https://linear.app/x/issue/ENG-1" 0.7
+ 'issue "iss-1" "ENG-1"))))
+ (should (eq 'issue (plist-get p :kind)))
+ (should (string= "iss-1" (plist-get p :id)))
+ (should (string= "ENG-1" (plist-get p :identifier)))))
+
+(ert-deftest test-pearl-normalize-favorite-unknown-type-is-other ()
+ "An unrecognized favorite type normalizes to :kind other (browser-only)."
+ (let ((p (pearl--normalize-favorite
+ (test-pearl-fav--node "release" "v1.2" "https://linear.app/x/release/r1" 0.8
+ nil nil))))
+ (should (eq 'other (plist-get p :kind)))
+ (should (null (plist-get p :id)))
+ (should (string= "https://linear.app/x/release/r1" (plist-get p :url)))))
+
+;;; favorite->source dispatch
+
+(defun test-pearl-fav--norm (kind id &optional title url)
+ "Hand-build a normalized favorite for dispatch tests."
+ (list :kind kind :title (or title "T") :url (or url "https://x")
+ :sort-order 0.0 :id id))
+
+(ert-deftest test-pearl-favorite->source-view ()
+ "A view favorite dispatches to a (:type view ...) source."
+ (let ((s (pearl--favorite->source
+ (test-pearl-fav--norm 'view "view-1" "Active bugs" "https://linear.app/x/v"))))
+ (should (eq 'view (plist-get s :type)))
+ (should (string= "Active bugs" (plist-get s :name)))
+ (should (string= "view-1" (plist-get s :id)))))
+
+(ert-deftest test-pearl-favorite->source-project ()
+ (let ((s (pearl--favorite->source (test-pearl-fav--norm 'project "proj-1" "Platform"))))
+ (should (eq 'filter (plist-get s :type)))
+ (should (string= "Platform" (plist-get s :name)))
+ (should (equal '(:project "proj-1" :open t) (plist-get s :filter)))))
+
+(ert-deftest test-pearl-favorite->source-cycle ()
+ (let ((s (pearl--favorite->source (test-pearl-fav--norm 'cycle "cyc-1" "Sprint 12"))))
+ (should (equal '(:cycle "cyc-1" :open t) (plist-get s :filter)))))
+
+(ert-deftest test-pearl-favorite->source-label-uses-id ()
+ "Label favorites dispatch to (:label-id ID) -- rename-proof."
+ (let ((s (pearl--favorite->source (test-pearl-fav--norm 'label "lbl-1" "security"))))
+ (should (equal '(:label-id "lbl-1" :open t) (plist-get s :filter)))))
+
+(ert-deftest test-pearl-favorite->source-user-uses-id ()
+ "User favorites dispatch to (:assignee-id ID) -- sidesteps email availability."
+ (let ((s (pearl--favorite->source (test-pearl-fav--norm 'user "user-1" "Vrezh"))))
+ (should (equal '(:assignee-id "user-1" :open t) (plist-get s :filter)))))
+
+(ert-deftest test-pearl-favorite->source-issue-is-browser ()
+ "Issue favorites are browser-only in v1 (no source)."
+ (let ((s (pearl--favorite->source
+ (append (test-pearl-fav--norm 'issue "iss-1" "Some bug" "https://linear.app/x/issue/ENG-1")
+ (list :identifier "ENG-1")))))
+ (should (plist-get s :browser))
+ (should (string= "https://linear.app/x/issue/ENG-1" (plist-get s :url)))))
+
+(ert-deftest test-pearl-favorite->source-other-is-browser ()
+ "An unknown-kind favorite is browser-only."
+ (let ((s (pearl--favorite->source (test-pearl-fav--norm 'other nil "Release v1" "https://linear.app/x/r"))))
+ (should (plist-get s :browser))
+ (should (string= "https://linear.app/x/r" (plist-get s :url)))))
+
+;;; favorites-async (request boundary stubbed)
+
+(ert-deftest test-pearl-favorites-async-single-page ()
+ "A single-page favorites response is collected into a list of normalized plists."
+ (testutil-linear-with-response
+ `((data (favorites
+ (nodes . ,(vector (test-pearl-fav--node "customView" "V" "https://x/v" 0.1
+ 'customView "view-1")
+ (test-pearl-fav--node "project" "P" "https://x/p" 0.2
+ 'project "proj-1")))
+ (pageInfo (hasNextPage . :json-false) (endCursor . nil)))))
+ (let (result)
+ (pearl--favorites-async (lambda (r) (setq result r)))
+ (should (= 2 (length result)))
+ (should (eq 'view (plist-get (nth 0 result) :kind)))
+ (should (eq 'project (plist-get (nth 1 result) :kind))))))
+
+;;; pick-source candidate builder
+
+(ert-deftest test-pearl-pick-source-candidates-orders-favorites-by-sort-order ()
+ "Favorites are listed first in ascending :sort-order, then saved queries alphabetically."
+ (let* ((favs (list (test-pearl-fav--norm 'project "p1" "Bravo") ; sort-order 0.0
+ (let ((f (test-pearl-fav--norm 'view "v1" "Alpha")))
+ (plist-put f :sort-order -1.0)))) ; lower, comes first
+ (saved '(("My open" :filter (:assignee :me :open t))
+ ("All bugs" :filter (:labels ("bug") :open t))))
+ (cands (pearl--pick-source-candidates favs saved))
+ (labels (mapcar #'car cands)))
+ (should (equal labels
+ '("[view] Alpha"
+ "[project] Bravo"
+ "[saved] All bugs"
+ "[saved] My open")))))
+
+(ert-deftest test-pearl-pick-source-candidates-dispatch-from-attached-plist ()
+ "Dispatch reads the attached source plist, not the display string."
+ (let* ((favs (list (test-pearl-fav--norm 'project "proj-1" "Platform")))
+ (cands (pearl--pick-source-candidates favs nil))
+ (source (cdr (assoc "[project] Platform" cands))))
+ (should (eq 'filter (plist-get source :type)))
+ (should (equal '(:project "proj-1" :open t) (plist-get source :filter)))))
+
+(ert-deftest test-pearl-pick-source-candidates-disambiguates-duplicates ()
+ "Two candidates that would share a display string are made unique with a suffix."
+ (let* ((favs (list (test-pearl-fav--norm 'project "p1" "Same")
+ (test-pearl-fav--norm 'project "p2" "Same")))
+ (cands (pearl--pick-source-candidates favs nil))
+ (labels (mapcar #'car cands)))
+ (should (= 2 (length cands)))
+ ;; both unique
+ (should (= 2 (length (delete-dups (copy-sequence labels)))))))
+
+(ert-deftest test-pearl-pick-source-candidates-empty ()
+ "No favorites and no saved queries yields no candidates."
+ (should (null (pearl--pick-source-candidates nil nil))))
+
+;;; open-favorite-url
+
+(ert-deftest test-pearl-open-favorite-url-with-url-browses ()
+ "A valid URL is handed to browse-url and a message names what opened."
+ (let (visited)
+ (cl-letf (((symbol-function 'browse-url) (lambda (u &rest _) (setq visited u))))
+ (pearl--open-favorite-url
+ (list :browser t :url "https://linear.app/x/issue/ENG-1"
+ :title "Some bug" :kind 'issue))
+ (should (string= "https://linear.app/x/issue/ENG-1" visited)))))
+
+(ert-deftest test-pearl-open-favorite-url-nil-refuses ()
+ "A nil/empty URL refuses cleanly and never calls browse-url."
+ (let (visited)
+ (cl-letf (((symbol-function 'browse-url) (lambda (u &rest _) (setq visited u))))
+ (should-error (pearl--open-favorite-url
+ (list :browser t :url nil :title "X" :kind 'other))
+ :type 'user-error)
+ (should-not visited))))
+
+(provide 'test-pearl-favorites)
+;;; test-pearl-favorites.el ends here