;;; test-pearl-local-views.el --- Tests for local-view create/edit/persistence -*- 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: ;; Phase 2 of the local/Linear views spec: the metadata-preserving writer ;; `pearl--save-local-view', the shared account guard ;; `pearl--require-local-view-account', and the collision resolver ;; `pearl--resolve-local-view-name'. The load-bearing guarantee is that ;; editing a published local view never drops its `:linear-view-*' tracking ;; link. ;;; Code: (require 'test-bootstrap (expand-file-name "test-bootstrap.el")) (defmacro test-pearl-lv--with-store (initial &rest body) "Run BODY with `pearl-local-views' set to INITIAL and Customize persistence stubbed." (declare (indent 1)) `(let ((pearl-local-views ,initial)) (cl-letf (((symbol-function 'customize-save-variable) (lambda (&rest _) nil))) ,@body))) ;;; pearl--save-local-view -- metadata preservation (ert-deftest test-pearl-save-local-view-creates-new-entry () "A new name is saved with its filter and no metadata stamped in legacy mode." (test-pearl-lv--with-store nil (let ((pearl-accounts nil)) (pearl--save-local-view "bugs" '(:labels ("bug") :open t)) (let ((spec (cdr (assoc "bugs" pearl-local-views)))) (should (equal (plist-get spec :filter) '(:labels ("bug") :open t))) (should-not (plist-member spec :account)))))) (ert-deftest test-pearl-save-local-view-preserves-tracking-metadata () "Re-saving a published local view keeps its :linear-view-* and provenance keys." (test-pearl-lv--with-store '(("team open" :filter (:team "ENG" :open t) :linear-view-id "view-123" :linear-view-team-id "team-1" :linear-view-shared t :linear-view-url "https://linear.app/x/view/view-123" :linear-view-synced-at "2026-06-01" :account "acct-a" :copied-from-view-id "src-9")) (pearl--save-local-view "team open" '(:team "ENG" :state "In Progress")) (let ((spec (cdr (assoc "team open" pearl-local-views)))) ;; the edited field changed (should (equal (plist-get spec :filter) '(:team "ENG" :state "In Progress"))) ;; every other key survived (should (equal (plist-get spec :linear-view-id) "view-123")) (should (equal (plist-get spec :linear-view-team-id) "team-1")) (should (eq (plist-get spec :linear-view-shared) t)) (should (equal (plist-get spec :linear-view-url) "https://linear.app/x/view/view-123")) (should (equal (plist-get spec :linear-view-synced-at) "2026-06-01")) (should (equal (plist-get spec :account) "acct-a")) (should (equal (plist-get spec :copied-from-view-id) "src-9"))))) (ert-deftest test-pearl-save-local-view-updates-sort-order-keeping-rest () "Sort/order update in place; an unrelated key is preserved." (test-pearl-lv--with-store '(("q" :filter (:open t) :sort updated :order desc :copied-from-view-id "s")) (pearl--save-local-view "q" '(:open t) 'priority 'asc) (let ((spec (cdr (assoc "q" pearl-local-views)))) (should (eq (plist-get spec :sort) 'priority)) (should (eq (plist-get spec :order) 'asc)) (should (equal (plist-get spec :copied-from-view-id) "s"))))) (ert-deftest test-pearl-save-local-view-stamps-account-when-accounts-active () "A new entry created under `pearl-accounts' is stamped with the active account." (test-pearl-lv--with-store nil (let ((pearl-accounts '(("acct-a" . ignored)))) (cl-letf (((symbol-function 'pearl--current-account-context) (lambda () '(:name "acct-a")))) (pearl--save-local-view "mine" '(:assignee :me :open t)) (should (equal (plist-get (cdr (assoc "mine" pearl-local-views)) :account) "acct-a")))))) ;;; pearl--require-local-view-account (ert-deftest test-pearl-require-account-legacy-is-inert () "With no `pearl-accounts', the guard returns nil and never errors." (let ((pearl-accounts nil)) (should-not (pearl--require-local-view-account "q" '(:filter (:open t)))))) (ert-deftest test-pearl-require-account-matching-passes () "A view tagged to the active account passes and returns the active name." (let ((pearl-accounts '(("acct-a" . x)))) (cl-letf (((symbol-function 'pearl--current-account-context) (lambda () '(:name "acct-a")))) (should (equal (pearl--require-local-view-account "q" '(:account "acct-a" :filter (:open t))) "acct-a"))))) (ert-deftest test-pearl-require-account-mismatch-refuses () "A view tagged to another account signals a user-error." (let ((pearl-accounts '(("acct-a" . x)))) (cl-letf (((symbol-function 'pearl--current-account-context) (lambda () '(:name "acct-a")))) (should-error (pearl--require-local-view-account "q" '(:account "acct-b" :filter (:open t))) :type 'user-error)))) ;;; pearl--resolve-local-view-name -- collision policy (ert-deftest test-pearl-resolve-name-no-collision-returns-name () (test-pearl-lv--with-store '(("other" :filter (:open t))) (should (equal (pearl--resolve-local-view-name "fresh") "fresh")))) (ert-deftest test-pearl-resolve-name-same-as-current-is-not-collision () (test-pearl-lv--with-store '(("q" :filter (:open t))) (should (equal (pearl--resolve-local-view-name "q" "q") "q")))) (ert-deftest test-pearl-resolve-name-collision-replace () (test-pearl-lv--with-store '(("q" :filter (:open t))) (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "Replace"))) (should (equal (pearl--resolve-local-view-name "q") "q"))))) (ert-deftest test-pearl-resolve-name-collision-cancel () (test-pearl-lv--with-store '(("q" :filter (:open t))) (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "Cancel"))) (should-not (pearl--resolve-local-view-name "q"))))) (ert-deftest test-pearl-resolve-name-collision-rename () "Rename prompts a new name; a free name then resolves." (test-pearl-lv--with-store '(("q" :filter (:open t))) (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "Rename")) ((symbol-function 'read-string) (lambda (&rest _) "q2"))) (should (equal (pearl--resolve-local-view-name "q") "q2"))))) ;;; pearl-edit-local-view -- rename carries metadata, drops the old name (ert-deftest test-pearl-edit-local-view-rename-preserves-metadata () "Renaming a published local view moves it under the new name with its link intact." (test-pearl-lv--with-store '(("old" :filter (:open t) :linear-view-id "v-1" :account nil)) (let ((pearl-accounts nil)) (cl-letf (((symbol-function 'pearl--read-yes-no) (lambda (&rest _) nil)) ((symbol-function 'completing-read) (lambda (&rest _) "keep")) ((symbol-function 'read-string) (lambda (&rest _) "new"))) (pearl-edit-local-view "old") (should-not (assoc "old" pearl-local-views)) (let ((spec (cdr (assoc "new" pearl-local-views)))) (should spec) (should (equal (plist-get spec :filter) '(:open t))) (should (equal (plist-get spec :linear-view-id) "v-1"))))))) ;;; account guard on publish and delete -- no Linear call on mismatch (Phase 5) (ert-deftest test-pearl-publish-local-view-refuses-cross-account () "Publishing a cross-account local view errors before any customViewCreate/Update." (let ((pearl-local-views '(("v" :filter (:open t) :account "acct-b"))) (pearl-accounts '(("acct-a" . x) ("acct-b" . y))) (created nil)) (cl-letf (((symbol-function 'pearl--current-account-context) (lambda () '(:name "acct-a"))) ((symbol-function 'pearl--require-account-context) (lambda (&rest _) nil)) ((symbol-function 'pearl--customview-create-async) (lambda (&rest _) (setq created t))) ((symbol-function 'pearl--customview-update-async) (lambda (&rest _) (setq created t)))) (should-error (pearl-publish-local-view "v") :type 'user-error) (should-not created)))) (ert-deftest test-pearl-delete-local-view-refuses-cross-account-no-linear-call () "Deleting a cross-account local view errors before the confirm or customViewDelete." (let ((pearl-local-views '(("v" :filter (:open t) :linear-view-id "vid" :account "acct-b"))) (pearl-accounts '(("acct-a" . x))) (deleted nil) (confirmed nil)) (cl-letf (((symbol-function 'pearl--current-account-context) (lambda () '(:name "acct-a"))) ((symbol-function 'pearl--customview-delete-async) (lambda (&rest _) (setq deleted t))) ((symbol-function 'yes-or-no-p) (lambda (&rest _) (setq confirmed t) t))) (should-error (pearl-delete-local-view "v") :type 'user-error) (should-not deleted) (should-not confirmed)))) (provide 'test-pearl-local-views) ;;; test-pearl-local-views.el ends here