From 6206fde5acfd48f9eba2ab231c1fe1d32717c98e Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 1 Jun 2026 17:46:34 -0500 Subject: feat(views): add create and edit local views with a metadata-preserving writer I added pearl-create-local-view and pearl-edit-local-view, plus the writer and guards the spec calls for. pearl--save-local-view replaces the old rebuild-from-scratch writer. It copies an existing entry and updates only the filter, sort, and order, so a published view keeps its :linear-view-* tracking link and a copied-down view keeps its provenance. A new entry gets the active account stamped under pearl-accounts. pearl--require-local-view-account is the one guard every read or mutate command shares, and pearl-run-local-view now calls it instead of an inline check. pearl--resolve-local-view-name carries the Replace/Rename/Cancel collision policy for create, edit-rename, and copy-down. The ad-hoc builder's save offer routes through the preserving writer and reads "save this as a local view". Edit re-runs the builder when you choose to rebuild and otherwise keeps the stored filter. Full per-dimension pre-seeding of the builder is deferred, since it needs the id-to-name reverse resolution that copy-down's reverse-compile introduces. I filed it as a follow-up. Phase 2 of the views spec. Tests cover metadata preservation, the account guard, the collision policy, and the metadata-preserving rename. Suite, compile, and lint are green. --- tests/test-pearl-adhoc.el | 14 ++-- tests/test-pearl-local-views.el | 171 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 tests/test-pearl-local-views.el (limited to 'tests') diff --git a/tests/test-pearl-adhoc.el b/tests/test-pearl-adhoc.el index 4959840..68a920c 100644 --- a/tests/test-pearl-adhoc.el +++ b/tests/test-pearl-adhoc.el @@ -155,22 +155,22 @@ candidate list, so completion still works the way the framework expects." (should (string= "user-1" (plist-get f :assignee-id))) (should-not (plist-member f :assignee)))) -;;; --save-query +;;; --save-local-view -(ert-deftest test-pearl-save-query-adds-entry () - "Saving a query adds it to the saved-queries variable." +(ert-deftest test-pearl-save-local-view-adds-entry () + "Saving a local view adds it to `pearl-local-views'." (let ((pearl-local-views nil)) (cl-letf (((symbol-function 'customize-save-variable) (lambda (&rest _) nil))) - (pearl--save-query "My filter" '(:open t :labels ("bug"))) + (pearl--save-local-view "My filter" '(:open t :labels ("bug"))) (let ((entry (assoc "My filter" pearl-local-views))) (should entry) (should (equal '(:open t :labels ("bug")) (plist-get (cdr entry) :filter))))))) -(ert-deftest test-pearl-save-query-replaces-same-name () - "Saving under an existing name replaces that entry rather than duplicating." +(ert-deftest test-pearl-save-local-view-replaces-same-name () + "Saving under an existing name updates that entry rather than duplicating." (let ((pearl-local-views '(("Dup" :filter (:open t))))) (cl-letf (((symbol-function 'customize-save-variable) (lambda (&rest _) nil))) - (pearl--save-query "Dup" '(:priority 1)) + (pearl--save-local-view "Dup" '(:priority 1)) (should (= 1 (cl-count "Dup" pearl-local-views :key #'car :test #'string=))) (should (equal '(:priority 1) diff --git a/tests/test-pearl-local-views.el b/tests/test-pearl-local-views.el new file mode 100644 index 0000000..71fb173 --- /dev/null +++ b/tests/test-pearl-local-views.el @@ -0,0 +1,171 @@ +;;; 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"))))))) + +(provide 'test-pearl-local-views) +;;; test-pearl-local-views.el ends here -- cgit v1.2.3