;;; test-pearl-views.el --- Tests for Linear Custom Views -*- 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 reading and running Linear Custom Views: the cached views list, ;; the server-side `--query-view-async' run, `pearl-run-linear-view', the view ;; branch of refresh, and opening the active view in the browser. HTTP is ;; stubbed. ;;; Code: (require 'test-bootstrap (expand-file-name "test-bootstrap.el")) (require 'testutil-request (expand-file-name "testutil-request.el")) (require 'cl-lib) ;;; --query-view-async (ert-deftest test-pearl-query-view-async-extracts-issues () "Running a view extracts the server-side issue nodes into an ok result." (testutil-linear-with-response '((data (customView (issues (nodes . [((id . "i1") (identifier . "ENG-1") (title . "A"))]) (pageInfo (hasNextPage . :json-false) (endCursor . nil)))))) (let (result) (pearl--query-view-async "view-1" (lambda (r) (setq result r))) (should (eq 'ok (pearl--query-result-status result))) (should (= 1 (length (pearl--query-result-issues result))))))) ;;; --custom-views (cached) (ert-deftest test-pearl-custom-views-caches () "The views list is fetched once and served from cache." (let ((pearl-api-key "test-key") (pearl--cache-views nil) (calls 0)) (cl-letf (((symbol-function 'request) (lambda (_url &rest args) (cl-incf calls) (funcall (plist-get args :success) :data '((data (customViews (nodes . [((id . "v1") (name . "My View"))]) (pageInfo (hasNextPage . :json-false))))))))) (let ((views (pearl--custom-views))) (should (= 1 (length views))) (pearl--custom-views) (should (= 1 calls)))))) (ert-deftest test-pearl-custom-views-query-omits-url () "The Custom Views list query must not select the unsupported `url' field. Linear's `CustomView' type has no `url'; requesting it 400s the whole query and leaves the run-view picker empty (the 2026-06-02 regression)." (should-not (string-match-p "\\burl\\b" (pearl--custom-views-query)))) ;;; --org-url-key (cached) and --view-url (reconstructed) (ert-deftest test-pearl-org-url-key-caches () "The workspace url key is fetched once and served from cache." (let ((pearl-api-key "test-key") (pearl--cache-org-url-key nil) (calls 0)) (cl-letf (((symbol-function 'request) (lambda (_url &rest args) (cl-incf calls) (funcall (plist-get args :success) :data '((data (organization (urlKey . "deepsat")))))))) (should (string= "deepsat" (pearl--org-url-key))) (pearl--org-url-key) (should (= 1 calls))))) (ert-deftest test-pearl-view-url-builds-from-urlkey () "A view's web URL is reconstructed from the workspace url key and view id." (cl-letf (((symbol-function 'pearl--org-url-key) (lambda (&optional _) "deepsat"))) (should (string= "https://linear.app/deepsat/view/abc123" (pearl--view-url "abc123"))))) (ert-deftest test-pearl-view-url-nil-when-no-urlkey () "With no workspace url key, the view URL is nil so callers degrade gracefully." (cl-letf (((symbol-function 'pearl--org-url-key) (lambda (&optional _) nil))) (should-not (pearl--view-url "abc123")))) (ert-deftest test-pearl-view-url-nil-when-empty-view-id () "A nil or empty view id yields no URL rather than a malformed link." (cl-letf (((symbol-function 'pearl--org-url-key) (lambda (&optional _) "deepsat"))) (should-not (pearl--view-url nil)) (should-not (pearl--view-url "")))) ;;; view sort — match the Linear view's configured ordering (ert-deftest test-pearl-view-sort-arg-maps-priority-desc () "A priority ordering with desc direction maps to an IssueSortInput list." (should (equal (vector '(("priority" ("order" . "Descending")))) (pearl--view-sort-arg "priority" "desc")))) (ert-deftest test-pearl-view-sort-arg-honors-asc-direction () "An asc direction maps to Ascending." (should (equal (vector '(("accumulatedStateUpdatedAt" ("order" . "Ascending")))) (pearl--view-sort-arg "accumulatedStateUpdatedAt" "asc")))) (ert-deftest test-pearl-view-sort-arg-defaults-nil-direction-to-descending () "A nil direction defaults to Descending — the field's natural order, and what Linear's UI shows for a priority view with no explicit direction." (should (equal (vector '(("priority" ("order" . "Descending")))) (pearl--view-sort-arg "priority" nil)))) (ert-deftest test-pearl-view-sort-arg-unsupported-ordering-is-nil () "An ordering with no IssueSortInput key yields nil, so the caller falls back to the default sort rather than sending an invalid sort key." (should-not (pearl--view-sort-arg "someExoticOrdering" "asc"))) (ert-deftest test-pearl-view-issues-query-accepts-sort () "The view issues query declares the sort variable and forwards it to the issues connection, so a view runs under a chosen order." (let ((q (pearl--view-issues-query))) (should (string-match-p "\\$sort: \\[IssueSortInput!\\]" q)) (should (string-match-p "sort: \\$sort" q)))) (ert-deftest test-pearl-query-view-async-defaults-sort-to-updated-desc () "With no explicit sort, a view runs under updatedAt-desc rather than Linear's createdAt-desc API default, so its order matches the rest of pearl." (let ((sent 'unset)) (cl-letf (((symbol-function 'pearl--graphql-request-async) (lambda (_q vars success &optional _err) (setq sent (cdr (assoc "sort" vars))) (funcall success '((data (customView (issues (nodes . []) (pageInfo (hasNextPage . :json-false) (endCursor)))))))))) (pearl--query-view-async "v1" #'ignore) (should (equal pearl--default-issue-sort sent))))) ;;; run-view (ert-deftest test-pearl-run-view-renders-with-view-source () "Running a view resolves its id and renders with a view-typed source." (let ((ran-id nil) (rendered-source nil)) (cl-letf (((symbol-function 'pearl--custom-views) (lambda (&optional _force) ;; Nodes carry no `url' -- Linear's CustomView has none; the ;; source URL is reconstructed from the workspace url key. '(((id . "v1") (name . "My View"))))) ((symbol-function 'pearl--org-url-key) (lambda (&optional _) "deepsat")) ((symbol-function 'pearl--query-view-async) (lambda (id cb &optional _sort) (setq ran-id id) (funcall cb (pearl--make-query-result 'ok :issues nil)))) ((symbol-function 'pearl--render-query-result) (lambda (_result source) (setq rendered-source source)))) (pearl-run-linear-view "My View") (should (string= "v1" ran-id)) (should (eq 'view (plist-get rendered-source :type))) (should (string= "v1" (plist-get rendered-source :id))) (should (string= "https://linear.app/deepsat/view/v1" (plist-get rendered-source :url)))))) (ert-deftest test-pearl-run-view-applies-and-stamps-view-sort () "Running a view resolves its configured ordering, passes it to the query, and records it on the source so a later refresh reuses the same order." (let ((passed-sort 'unset) (rendered-source nil)) (cl-letf (((symbol-function 'pearl--custom-views) (lambda (&optional _force) '(((id . "v1") (name . "My View") (viewPreferencesValues (viewOrdering . "priority") (viewOrderingDirection . "desc")))))) ((symbol-function 'pearl--org-url-key) (lambda (&optional _) "deepsat")) ((symbol-function 'pearl--query-view-async) (lambda (_id cb &optional sort) (setq passed-sort sort) (funcall cb (pearl--make-query-result 'ok :issues nil)))) ((symbol-function 'pearl--render-query-result) (lambda (_result source) (setq rendered-source source)))) (pearl-run-linear-view "My View") (should (equal (vector '(("priority" ("order" . "Descending")))) passed-sort)) (should (equal (vector '(("priority" ("order" . "Descending")))) (plist-get rendered-source :sort)))))) ;;; refresh-current-view, view branch (ert-deftest test-pearl-refresh-current-view-runs-view-source () "Refresh on a view source calls the view query, not the filter query." (let ((view-ran nil) (source '(:type view :name "My View" :id "v1" :url "https://x"))) (with-temp-buffer (insert (format "#+title: Linear — My View\n#+LINEAR-SOURCE: %s\n\n" (prin1-to-string source))) (org-mode) (cl-letf (((symbol-function 'pearl--query-view-async) (lambda (id cb &optional _sort) (setq view-ran id) (funcall cb (pearl--make-query-result 'ok :issues nil)))) ((symbol-function 'pearl--merge-query-result) (lambda (&rest _) nil))) (pearl-refresh-current-view) (should (string= "v1" view-ran)))))) (ert-deftest test-pearl-refresh-current-view-passes-stored-sort () "Refreshing a view source forwards the :sort recorded on it, so the refreshed list keeps the order the view was run under." (let ((passed-sort 'unset) (source '(:type view :name "My View" :id "v1" :sort [(("priority" ("order" . "Descending")))]))) (with-temp-buffer (insert (format "#+LINEAR-SOURCE: %s\n\n" (prin1-to-string source))) (org-mode) (cl-letf (((symbol-function 'pearl--query-view-async) (lambda (_id cb &optional sort) (setq passed-sort sort) (funcall cb (pearl--make-query-result 'ok :issues nil)))) ((symbol-function 'pearl--merge-query-result) (lambda (&rest _) nil))) (pearl-refresh-current-view) (should (equal [(("priority" ("order" . "Descending")))] passed-sort)))))) ;;; open-current-view-in-linear (ert-deftest test-pearl-open-current-view-visits-url () "Opening the active view visits the source's url." (let ((visited nil) (source '(:type view :name "My View" :id "v1" :url "https://linear.app/view/v1"))) (with-temp-buffer (insert (format "#+LINEAR-SOURCE: %s\n" (prin1-to-string source))) (org-mode) (cl-letf (((symbol-function 'browse-url) (lambda (u &rest _) (setq visited u)))) (pearl-open-current-view-in-linear) (should (string= "https://linear.app/view/v1" visited)))))) (ert-deftest test-pearl-open-current-view-no-url-errors () "Opening a non-view or url-less source signals a user error." (let ((source '(:type filter :name "My open issues" :filter (:assignee :me)))) (with-temp-buffer (insert (format "#+LINEAR-SOURCE: %s\n" (prin1-to-string source))) (org-mode) (should-error (pearl-open-current-view-in-linear) :type 'user-error)))) ;;; the view query fetches comments too (ert-deftest test-pearl-view-issues-query-requests-comments () "The Custom View query selects capped, newest-first comments by default." (let ((pearl-fetch-comments-in-list t) (pearl-list-comments-count-cap 25)) (should (string-match-p "comments(first: 26, orderBy: createdAt)[[:space:]]*{[[:space:]]*nodes" (pearl--view-issues-query))))) (provide 'test-pearl-views) ;;; test-pearl-views.el ends here