;;; test-pearl-favorites.el --- Tests for favorites layer -*- lexical-binding: t; -*- ;; Copyright (C) 2026 Craig Jennings ;; Author: Craig Jennings ;; 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 . ;;; 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-custom-view-blank-title-uses-name () "A favorited custom view with a blank own title takes the customView's name. Linear populates Favorite.title only for favorite folders, so an entity favorite arrives with a null/empty title and the entity's own name is the only display string -- the favorites query selects customView.name for exactly this." (let* ((node (list (cons 'id "fav-customView") (cons 'type "customView") (cons 'title nil) (cons 'url "https://linear.app/x/view/v1") (cons 'sortOrder 0.1) (cons 'customView (list (cons 'id "view-1") (cons 'name "Pearl Open Issues"))))) (p (pearl--normalize-favorite node))) (should (eq 'view (plist-get p :kind))) (should (string= "Pearl Open Issues" (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-renders-in-buffer () "An issue favorite resolves to a `:type issue' source, rendered in-buffer \(not the v1 browser punt). The identifier becomes the source name." (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-not (plist-get s :browser)) (should (eq 'issue (plist-get s :type))) (should (string= "iss-1" (plist-get s :id))) (should (string= "ENG-1" (plist-get s :identifier))) (should (string= "ENG-1" (plist-get s :name))))) (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 local views 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 '("[linear] Alpha" "[project] Bravo" "[local] All bugs" "[local] 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 local views yields no candidates." (should (null (pearl--pick-source-candidates nil nil)))) ;;; pick-source candidate builder -- synced local views (view-sync Phase 4) (ert-deftest test-pearl-pick-source-candidates-synced-saved-query-team-scope () "A team-scoped synced entry renders as `[local \\u2192 Linear:] '." (let* ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering")))) (saved '(("My team view" :filter (:open t) :linear-view-id "view-uuid" :linear-view-team-id "team-eng-id" :linear-view-shared t))) (cands (pearl--pick-source-candidates nil saved teams))) (should (assoc "[local → Linear:Engineering] My team view" cands)))) (ert-deftest test-pearl-pick-source-candidates-synced-saved-query-personal-scope () "A personal synced entry renders as `[local \\u2192 Linear:Personal] '." (let* ((saved '(("Mine" :filter (:open t) :linear-view-id "view-uuid" :linear-view-team-id nil :linear-view-shared :json-false))) (cands (pearl--pick-source-candidates nil saved nil))) (should (assoc "[local → Linear:Personal] Mine" cands)))) (ert-deftest test-pearl-pick-source-candidates-synced-saved-query-unknown-team-renders-q () "A synced entry whose team-id isn't in TEAMS renders `[local \\u2192 Linear:?] '." (let* ((teams '(((id . "other-id") (key . "OTH") (name . "Other")))) (saved '(("Stale" :filter (:open t) :linear-view-id "view-uuid" :linear-view-team-id "gone-team-id"))) (cands (pearl--pick-source-candidates nil saved teams))) (should (assoc "[local → Linear:?] Stale" cands)))) (ert-deftest test-pearl-pick-source-candidates-synced-saved-query-no-teams-arg-renders-q () "When TEAMS is nil and an entry has a non-nil team-id, scope is `?'." (let* ((saved '(("S" :filter (:open t) :linear-view-id "view-uuid" :linear-view-team-id "team-x")))) (should (assoc "[local → Linear:?] S" (pearl--pick-source-candidates nil saved nil))))) (ert-deftest test-pearl-pick-source-candidates-tracked-view-dispatches-as-local-filter () "Decision 16: a tracked local view dispatches as :type filter (its LOCAL filter), not :type view -- the local view is the source of truth." (let* ((saved '(("S" :filter (:open t) :linear-view-id "view-uuid-X" :linear-view-team-id nil))) (cands (pearl--pick-source-candidates nil saved nil)) (source (cdr (assoc "[local → Linear:Personal] S" cands)))) (should (eq 'filter (plist-get source :type))) (should (equal '(:open t) (plist-get source :filter))) (should (equal "S" (plist-get source :name))) (should-not (eq 'view (plist-get source :type))))) (ert-deftest test-pearl-pick-source-candidates-synced-source-includes-url-when-stored () "A synced entry carrying `:linear-view-url' surfaces it in the source plist so `pearl-open-current-view-in-linear' can dispatch to the browser." (let* ((saved '(("S" :filter (:open t) :linear-view-id "view-uuid-X" :linear-view-team-id nil :linear-view-url "https://linear.app/ws/view/abc"))) (cands (pearl--pick-source-candidates nil saved nil)) (source (cdr (assoc "[local → Linear:Personal] S" cands)))) (should (equal "https://linear.app/ws/view/abc" (plist-get source :url))))) (ert-deftest test-pearl-pick-source-candidates-synced-source-no-url-when-not-stored () "An entry synced before `:linear-view-url' tracking landed has no URL in the source plist; the open-in-Linear command errors cleanly until the user re-syncs." (let* ((saved '(("S" :filter (:open t) :linear-view-id "view-uuid-X" :linear-view-team-id nil))) (cands (pearl--pick-source-candidates nil saved nil)) (source (cdr (assoc "[local → Linear:Personal] S" cands)))) (should-not (plist-get source :url)))) (ert-deftest test-pearl-pick-source-candidates-local-only-saved-query-keeps-saved-label () "A local view without :linear-view-id still renders as `[local] ' and dispatches as filter." (let* ((saved '(("Local" :filter (:open t)))) (cands (pearl--pick-source-candidates nil saved '())) (source (cdr (assoc "[local] Local" cands)))) (should source) (should (eq 'filter (plist-get source :type))) (should (equal '(:open t) (plist-get source :filter))))) (ert-deftest test-pearl-pick-source-candidates-mixed-local-and-synced () "A picker mixing local-only and synced entries renders both label styles." (let* ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering")))) (saved '(("Local thing" :filter (:open t)) ("Synced thing" :filter (:open t) :linear-view-id "view-uuid" :linear-view-team-id "team-eng-id"))) (cands (pearl--pick-source-candidates nil saved teams)) (labels (mapcar #'car cands))) (should (member "[local] Local thing" labels)) (should (member "[local → Linear:Engineering] Synced thing" labels)))) ;;; pearl--saved-query-scope-label (ert-deftest test-pearl-saved-query-scope-label-local-only-returns-nil () "An entry without :linear-view-id has no scope and returns nil." (should (null (pearl--saved-query-scope-label '(:filter (:open t)) nil)))) (ert-deftest test-pearl-saved-query-scope-label-personal-when-no-team-id () "A synced entry with nil team-id renders as \"Personal\"." (should (equal "Personal" (pearl--saved-query-scope-label '(:filter (:open t) :linear-view-id "v" :linear-view-team-id nil) nil)))) (ert-deftest test-pearl-saved-query-scope-label-resolves-team-name-from-teams () "A synced entry with a team-id resolves to that team's name." (let ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering"))))) (should (equal "Engineering" (pearl--saved-query-scope-label '(:filter (:open t) :linear-view-id "v" :linear-view-team-id "team-eng-id") teams))))) (ert-deftest test-pearl-saved-query-scope-label-unknown-team-id-renders-q () "An entry whose team-id doesn't appear in TEAMS renders \"?\"." (let ((teams '(((id . "other-id") (name . "Other"))))) (should (equal "?" (pearl--saved-query-scope-label '(:filter (:open t) :linear-view-id "v" :linear-view-team-id "gone") teams))))) ;;; 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)))) ;;; cross-store duplicate display (Phase 4) (ert-deftest test-pearl-pick-source-candidates-dedupes-same-id-favorite () "A Linear view favorite whose id matches a tracked local view is dropped, leaving the editable local entry as the only one shown." (let* ((favs (list (test-pearl-fav--norm 'view "view-shared" "Shared"))) (saved '(("Shared local" :filter (:open t) :linear-view-id "view-shared" :linear-view-team-id nil))) (cands (pearl--pick-source-candidates favs saved nil)) (labels (mapcar #'car cands))) ;; the favorite is deduped away; only the local tracked entry remains (should (= 1 (length cands))) (should (member "[local → Linear:Personal] Shared local" labels)) (should-not (member "[linear] Shared" labels)))) (ert-deftest test-pearl-pick-source-candidates-keeps-unmirrored-linear-favorite () "A Linear view favorite with no matching tracked local view is kept as [linear]." (let* ((favs (list (test-pearl-fav--norm 'view "view-other" "Other"))) (saved '(("Local" :filter (:open t) :linear-view-id "view-different" :linear-view-team-id nil))) (cands (pearl--pick-source-candidates favs saved nil)) (labels (mapcar #'car cands))) (should (member "[linear] Other" labels)) (should (member "[local → Linear:Personal] Local" labels)))) (ert-deftest test-pearl-pick-source-candidates-same-name-different-store-both-shown () "A local view and a Linear view favorite that merely share a name both appear." (let* ((favs (list (test-pearl-fav--norm 'view "v-id" "My bugs"))) (saved '(("My bugs" :filter (:labels ("bug") :open t)))) (cands (pearl--pick-source-candidates favs saved nil)) (labels (mapcar #'car cands))) (should (= 2 (length cands))) (should (member "[linear] My bugs" labels)) (should (member "[local] My bugs" labels)))) (provide 'test-pearl-favorites) ;;; test-pearl-favorites.el ends here