From d124c33ba4a505c3400b93d2fb33f6a2d8955468 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Mon, 31 Aug 2026 16:07:25 +0800 Subject: [PATCH] feat: close Research Shelf M0 integration --- examples/research-shelf.el | 545 +++++++++++++----------- scripts/benchmark-research-shelf.el | 44 +- scripts/playground-gui-scenarios.el | 38 +- tests/etaf-playground-tests.el | 39 +- tests/playground-gui-scenarios-tests.el | 92 ++++ 5 files changed, 478 insertions(+), 280 deletions(-) create mode 100644 tests/playground-gui-scenarios-tests.el diff --git a/examples/research-shelf.el b/examples/research-shelf.el index af770e8..0e541e0 100644 --- a/examples/research-shelf.el +++ b/examples/research-shelf.el @@ -226,17 +226,17 @@ user's prior local additions cannot collide with the generated dataset." :ui-disabled-border line :ui-button-primary-fg paper :ui-button-primary-bg accent :ui-button-primary-border accent - :ui-button-secondary-fg ink :ui-button-secondary-bg mint + :ui-button-secondary-fg ink :ui-button-secondary-bg mint-bg :ui-button-secondary-border mint :ui-button-ghost-fg ink :ui-button-ghost-bg panel :ui-button-ghost-border line - :ui-checkbox-enabled-fg ink :ui-checkbox-enabled-bg mint + :ui-checkbox-enabled-fg ink :ui-checkbox-enabled-bg mint-bg :ui-checkbox-enabled-border mint :ui-checkbox-disabled-fg muted :ui-checkbox-disabled-bg soft :ui-checkbox-disabled-border line - :ui-grid-border line :ui-grid-selected-fg mint - :ui-grid-selected-bg mint-bg - :ui-grid-error-fg coral :ui-pagination-muted-fg muted + :ui-table-border line :ui-table-selected-fg mint + :ui-table-selected-bg mint-bg + :ui-data-grid-error-fg coral :ui-pagination-muted-fg muted :ui-panel-fg ink :ui-panel-bg panel :ui-panel-border line)))) ;;; --------------------------------------------------------------------------- @@ -433,264 +433,302 @@ constructed. When omitted, initialize it for direct programmatic callers." (or (and entry (plist-get (cdr entry) :label)) fallback))))) (etaf-define-component etaf-research-shelf-filter-button - (&key filter-key fallback ref) + (&key filter-key fallback) "Render one filter Button from inherited model and static filter Context." :setup - (let ((model (etaf-inject 'research-shelf-model nil t)) - (form (etaf-inject 'research-shelf-filters nil t)) - (filter-key (etaf-current-prop :filter-key)) - (fallback (etaf-current-prop :fallback)) - (ref (etaf-current-prop :ref))) - (lambda () - (let ((active (eq filter-key (etaf-value (plist-get model :filter))))) - (etaf-view - (button :label - (let ((label (etaf-research-shelf--filter-label - form filter-key fallback))) - (if active (concat "● " label) label)) - :ref ref :variant (if active 'secondary 'ghost) - :on-press (lambda () - (etaf-dispatch 'etaf-research-shelf-filter - model filter-key)))))))) - -(defun etaf-research-shelf--filter-view (model) - "Return the filter rail for MODEL and inherited filter Context." - (etaf-view - (column - :class "research-shelf-filter-rail" :width 'stretch :min-width 0 - :flex-grow 1 :flex-shrink 1 :flex-basis '(220) - :padding '(1 1) - :border (etaf-theme-token :line) - :color (etaf-theme-token :ink) - :bgcolor (etaf-theme-token :panel) - (label :text "Library" :font-weight 'bold) - (label :text "VIEWS" :color (etaf-theme-token :muted)) - (etaf-research-shelf-filter-button - :filter-key 'all :fallback "All" :ref 'research-shelf-filter-all) - (etaf-research-shelf-filter-button - :filter-key 'reading :fallback "In progress" - :ref 'research-shelf-filter-reading) - (etaf-research-shelf-filter-button - :filter-key 'unread :fallback "Unread" :ref 'research-shelf-filter-unread) - (etaf-research-shelf-filter-button - :filter-key 'finished :fallback "Finished" - :ref 'research-shelf-filter-finished) - (etaf-research-shelf-filter-button - :filter-key 'starred :fallback "★ Starred" - :ref 'research-shelf-filter-starred) - (box :height 1) - (label :text "STORAGE" :color (etaf-theme-token :muted)) - (label :text (file-name-nondirectory - (plist-get model :database-file))) - (button :label "↻ Reload library" :ref 'research-shelf-reload - :variant 'ghost - :on-press (lambda () - (etaf-dispatch 'etaf-research-shelf-reload - model)))))) - -(defun etaf-research-shelf--reading-list-view (model form) - "Return the reading list for MODEL and static MAIN FORM." - (let ((controller (etaf-research-shelf--controller model))) - (etaf-view - (column - :class "research-shelf-list" :width 'stretch :min-width 0 - :flex-grow 4 :flex-shrink 1 :flex-basis '(620) - :padding '(1 2) - :border (etaf-theme-token :line) - :bgcolor (etaf-theme-token :panel) - (flex - :width 'stretch :flex-wrap 'wrap :align-items 'center :gap '(1 (8)) - (column - :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0 - (label :text (etaf-playground-static-value - form :title "Reading queue") :font-weight 'bold) - (label :text (format "%d items · SQLite-backed" - (or (etaf-value (etaf-data-total controller)) 0)) - :color (etaf-theme-token :muted))) - (number-input :label "Rows" - :value (etaf-value (etaf-data-page-size controller)) - :ref 'research-shelf-page-size :min 1 :max 100 - :on-change - (lambda (next) - (etaf-dispatch 'etaf-research-shelf-page-size - model next))) - (button :label "+ Add reading" :ref 'research-shelf-add - :variant 'secondary - :on-press (lambda () - (etaf-dispatch 'etaf-research-shelf-add model)))) - (data-grid - :controller controller - :columns etaf-research-shelf--grid-columns - :row-key #'etaf-research-shelf--item-id - :row-ref (lambda (row) - (intern (format "research-shelf-row-%s" - (etaf-research-shelf--item-id row)))) - :on-row-press - (lambda (row) - (etaf-data-select-one controller - (etaf-research-shelf--item-id row))) - :loading-label "◷ Loading library…" - :error-label "⚠ Could not read the shelf. Use Reload.") - (etaf-pagination :controller controller - :previous-ref 'research-shelf-page-previous - :next-ref 'research-shelf-page-next - :color (etaf-theme-token :ink)))))) - -(defun etaf-research-shelf--detail-view (model form) - "Return the selected record inspector for MODEL and static FORM." - (etaf-view - (column - :class "research-shelf-detail" :width 'stretch :min-width 0 - :flex-grow 2 :flex-shrink 1 :flex-basis '(340) - :padding '(2 2) - :border (etaf-theme-token :line) - :bgcolor (etaf-theme-token :panel) - (label :text (etaf-playground-static-value form :title - "Selected item") - :color (etaf-theme-token :accent)) - (text :font-weight 'bold - (expr :value - (if-let* ((row (etaf-research-shelf--selected model))) - (plist-get row :title) - "Choose a record"))) - (text :color (etaf-theme-token :muted) - (expr :value - (if-let* ((row (etaf-research-shelf--selected model))) - (format "%s · %s" (plist-get row :author) - (plist-get row :kind)) - "Select one row to inspect it."))) - (box :height 1) - (text :font-weight 'bold - (expr :value - (if-let* ((row (etaf-research-shelf--selected model))) - (etaf-research-shelf--status-label (plist-get row :status)) - "○ No selection"))) - (text - (expr :value - (if-let* ((row (etaf-research-shelf--selected model))) - (format "Progress %d%% · Priority %s" - (or (plist-get row :progress) 0) - (plist-get row :priority)) - "Progress — · Priority —"))) - (text :color (etaf-theme-token :accent) - (expr :value - (let* ((row (etaf-research-shelf--selected model)) - (progress (or (and row (plist-get row :progress)) 0)) - (filled (max 0 (min 10 (/ progress 10))))) - (format "%s %s" (make-string filled ?█) - (make-string (- 10 filled) ?░))))) - (box :height 1) - (text :color (etaf-theme-token :muted) - (expr :value - (if-let* ((row (etaf-research-shelf--selected model))) - (concat "“" (or (plist-get row :note) "No note yet.") "”") - "Your notes and actions will appear here."))) - (box :height 1) - (etaf-research-shelf-detail-actions)))) - -(etaf-define-component etaf-research-shelf-detail-actions () - "Render selected-record actions from the inherited Research Shelf model." - :setup - (let ((model (etaf-inject 'research-shelf-model nil t))) - (lambda () - (let* ((row (etaf-research-shelf--selected model)) - (progress (or (and row (plist-get row :progress)) 0)) - (finished (and row (equal (plist-get row :status) "finished"))) - (archived (and row (equal (plist-get row :status) "archived")))) - (etaf-view - (flex - :width 'stretch :flex-wrap 'wrap :gap '(1 (6)) - (button :label "+ 10%" :ref 'research-shelf-progress - :variant 'secondary - :disabled (or (null row) archived (>= progress 100)) - :on-press (lambda () - (etaf-dispatch 'etaf-research-shelf-progress - model))) - (button :label "✓ Finish" :ref 'research-shelf-finish - :variant 'secondary :disabled (or (null row) finished archived) - :on-press (lambda () - (etaf-dispatch 'etaf-research-shelf-finish - model))) - (button :label "★ Star" :ref 'research-shelf-star - :variant 'ghost :disabled (null row) - :on-press (lambda () - (etaf-dispatch 'etaf-research-shelf-star model))) - (button :label "Archive" :ref 'research-shelf-archive - :variant 'ghost :disabled (or (null row) archived) - :on-press (lambda () - (etaf-dispatch 'etaf-research-shelf-archive - model))))))))) + (list :model (etaf-inject 'research-shelf-model nil t) + :form (etaf-inject 'research-shelf-filters nil t)) + :view + (etaf-button + :label + (let* ((model (plist-get (etaf-state) :model)) + (form (plist-get (etaf-state) :form)) + (label (etaf-research-shelf--filter-label + form filter-key fallback))) + (if (eq filter-key (etaf-value (plist-get model :filter))) + (concat "● " label) + label)) + :variant + (if (eq filter-key + (etaf-value + (plist-get (plist-get (etaf-state) :model) :filter))) + 'secondary + 'ghost) + :on-press + (let ((model (plist-get (etaf-state) :model)) + (key filter-key)) + (lambda () + (etaf-dispatch 'etaf-research-shelf-filter model key))))) (etaf-define-component etaf-research-shelf-filter-rail () "Render the Research Shelf filter rail from inherited Context." :setup - (let ((model (etaf-inject 'research-shelf-model nil t))) - (lambda () (etaf-research-shelf--filter-view model)))) + (etaf-inject 'research-shelf-model nil t) + :view + (column :class "research-shelf-filter-rail" + :width 'stretch :min-width 0 + :flex-grow 1 :flex-shrink 1 :flex-basis '(220) + :padding '(1 1) + :border (etaf-theme-token :line) + :color (etaf-theme-token :ink) + :bgcolor (etaf-theme-token :panel) + (text :font-weight 'bold "Library") + (text :color (etaf-theme-token :muted) "VIEWS") + (etaf-research-shelf-filter-button + :filter-key 'all :fallback "All" :ref 'research-shelf-filter-all) + (etaf-research-shelf-filter-button + :filter-key 'reading :fallback "In progress" + :ref 'research-shelf-filter-reading) + (etaf-research-shelf-filter-button + :filter-key 'unread :fallback "Unread" + :ref 'research-shelf-filter-unread) + (etaf-research-shelf-filter-button + :filter-key 'finished :fallback "Finished" + :ref 'research-shelf-filter-finished) + (etaf-research-shelf-filter-button + :filter-key 'starred :fallback "★ Starred" + :ref 'research-shelf-filter-starred) + (box :height 1) + (text :color (etaf-theme-token :muted) "STORAGE") + (text + (expr (file-name-nondirectory (plist-get (etaf-state) :database-file)))) + (etaf-button + :label "↻ Reload library" :ref 'research-shelf-reload :variant 'ghost + :on-press + (let ((model (etaf-state))) + (lambda () + (etaf-dispatch 'etaf-research-shelf-reload model)))))) (etaf-define-component etaf-research-shelf-reading-list () "Render the Research Shelf reading list from inherited Context." :setup - (let ((model (etaf-inject 'research-shelf-model nil t)) - (form (etaf-inject 'research-shelf-library nil t))) - (lambda () (etaf-research-shelf--reading-list-view model form)))) + (list :model (etaf-inject 'research-shelf-model nil t) + :form (etaf-inject 'research-shelf-library nil t)) + :view + (column :class "research-shelf-list" :width 'stretch :min-width 0 + :flex-grow 4 :flex-shrink 1 :flex-basis '(620) + :padding '(1 2) + :border (etaf-theme-token :line) + :bgcolor (etaf-theme-token :panel) + (flex :width 'stretch :flex-wrap 'wrap + :align-items 'center :gap '(1 (8)) + (column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0 + (text :font-weight 'bold + (expr (etaf-playground-static-value + (plist-get (etaf-state) :form) :title "Reading queue"))) + (text :color (etaf-theme-token :muted) + (expr + (format + "%d items · SQLite-backed" + (or (etaf-value + (etaf-data-total + (etaf-research-shelf--controller + (plist-get (etaf-state) :model)))) + 0))))) + (etaf-number-input + :label "Rows" + :value + (etaf-value + (etaf-data-page-size + (etaf-research-shelf--controller + (plist-get (etaf-state) :model)))) + :ref 'research-shelf-page-size :min 1 :max 100 + :on-change + (let ((model (plist-get (etaf-state) :model))) + (lambda (next) + (etaf-dispatch 'etaf-research-shelf-page-size model next)))) + (etaf-button + :label "+ Add reading" :ref 'research-shelf-add :variant 'secondary + :on-press + (let ((model (plist-get (etaf-state) :model))) + (lambda () + (etaf-dispatch 'etaf-research-shelf-add model))))) + (etaf-data-grid + :controller + (etaf-research-shelf--controller (plist-get (etaf-state) :model)) + :columns etaf-research-shelf--grid-columns + :row-key #'etaf-research-shelf--item-id + :row-ref + (lambda (row) + (intern (format "research-shelf-row-%s" + (etaf-research-shelf--item-id row)))) + :on-row-press + (let ((controller + (etaf-research-shelf--controller + (plist-get (etaf-state) :model)))) + (lambda (row) + (etaf-data-select-one controller + (etaf-research-shelf--item-id row)))) + :loading-label "◷ Loading library…" + :error-label "⚠ Could not read the shelf. Use Reload.") + (etaf-pagination + :controller + (etaf-research-shelf--controller (plist-get (etaf-state) :model)) + :previous-ref 'research-shelf-page-previous + :next-ref 'research-shelf-page-next + :color (etaf-theme-token :ink)))) (etaf-define-component etaf-research-shelf-detail-inspector () "Render the Research Shelf detail inspector from inherited Context." :setup - (let ((model (etaf-inject 'research-shelf-model nil t)) - (form (etaf-inject 'research-shelf-detail nil t))) - (lambda () (etaf-research-shelf--detail-view model form)))) + (list :model (etaf-inject 'research-shelf-model nil t) + :form (etaf-inject 'research-shelf-detail nil t)) + :view + (column :class "research-shelf-detail" :width 'stretch :min-width 0 + :flex-grow 2 :flex-shrink 1 :flex-basis '(340) + :padding '(2 2) + :border (etaf-theme-token :line) + :bgcolor (etaf-theme-token :panel) + (text :color (etaf-theme-token :accent) + (expr (etaf-playground-static-value + (plist-get (etaf-state) :form) :title "Selected item"))) + (text :font-weight 'bold + (expr + (if-let* ((row + (etaf-research-shelf--selected + (plist-get (etaf-state) :model)))) + (plist-get row :title) + "Choose a record"))) + (text :color (etaf-theme-token :muted) + (expr + (if-let* ((row + (etaf-research-shelf--selected + (plist-get (etaf-state) :model)))) + (format "%s · %s" (plist-get row :author) (plist-get row :kind)) + "Select one row to inspect it."))) + (box :height 1) + (text :font-weight 'bold + (expr + (if-let* ((row + (etaf-research-shelf--selected + (plist-get (etaf-state) :model)))) + (etaf-research-shelf--status-label (plist-get row :status)) + "○ No selection"))) + (text + (expr + (if-let* ((row + (etaf-research-shelf--selected + (plist-get (etaf-state) :model)))) + (format "Progress %d%% · Priority %s" + (or (plist-get row :progress) 0) + (plist-get row :priority)) + "Progress — · Priority —"))) + (text :color (etaf-theme-token :accent) + (expr + (let* ((row + (etaf-research-shelf--selected + (plist-get (etaf-state) :model))) + (progress (or (and row (plist-get row :progress)) 0)) + (filled (max 0 (min 10 (/ progress 10))))) + (format "%s %s" (make-string filled ?█) + (make-string (- 10 filled) ?░))))) + (box :height 1) + (text :color (etaf-theme-token :muted) + (expr + (if-let* ((row + (etaf-research-shelf--selected + (plist-get (etaf-state) :model)))) + (concat "“" (or (plist-get row :note) "No note yet.") "”") + "Your notes and actions will appear here."))) + (box :height 1) + (etaf-research-shelf-detail-actions))) + +(etaf-define-component etaf-research-shelf-detail-actions () + "Render selected-record actions from the inherited Research Shelf model." + :setup + (etaf-inject 'research-shelf-model nil t) + :view + (flex :width 'stretch :flex-wrap 'wrap :gap '(1 (6)) + (etaf-button + :label "+ 10%" :ref 'research-shelf-progress :variant 'secondary + :disabled + (let* ((row (etaf-research-shelf--selected (etaf-state))) + (progress (or (and row (plist-get row :progress)) 0))) + (or (null row) (equal (plist-get row :status) "archived") + (>= progress 100))) + :on-press + (let ((model (etaf-state))) + (lambda () + (etaf-dispatch 'etaf-research-shelf-progress model)))) + (etaf-button + :label "✓ Finish" :ref 'research-shelf-finish :variant 'secondary + :disabled + (let ((row (etaf-research-shelf--selected (etaf-state)))) + (or (null row) + (not (null (member (plist-get row :status) + '("finished" "archived")))))) + :on-press + (let ((model (etaf-state))) + (lambda () + (etaf-dispatch 'etaf-research-shelf-finish model)))) + (etaf-button + :label "★ Star" :ref 'research-shelf-star :variant 'ghost + :disabled (null (etaf-research-shelf--selected (etaf-state))) + :on-press + (let ((model (etaf-state))) + (lambda () + (etaf-dispatch 'etaf-research-shelf-star model)))) + (etaf-button + :label "Archive" :ref 'research-shelf-archive :variant 'ghost + :disabled + (let ((row (etaf-research-shelf--selected (etaf-state)))) + (or (null row) (equal (plist-get row :status) "archived"))) + :on-press + (let ((model (etaf-state))) + (lambda () + (etaf-dispatch 'etaf-research-shelf-archive model)))))) (etaf-define-component etaf-research-shelf-header () "Render the default `header' slot from App Context." :setup - (let ((model (etaf-inject 'research-shelf-model nil t)) - (root-spec (etaf-inject 'research-shelf-root-spec nil t)) - (header-spec (etaf-inject 'research-shelf-header-spec nil t))) - (lambda () - (etaf-view - (flex - :class "research-shelf-header" :width 'stretch :min-width 0 - :flex-wrap 'wrap :align-items 'center :gap '(1 (10)) - (column - :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0 - (label :text (etaf-playground-static-value - header-spec :eyebrow "Research Shelf") - :color (etaf-theme-token :accent)) - (label :text (etaf-playground-static-value - root-spec :title "Research Shelf") :font-weight 'bold) - (label :text (etaf-playground-static-value - root-spec :subtitle - "A quiet place for unfinished ideas") - :color (etaf-theme-token :muted))) - (column :width 'max-content :min-width 0 - :flex-grow 0 :flex-shrink 0 - (checkbox :label "Dark" :ref 'research-shelf-theme-toggle - :checked (plist-get model :dark) - :on-change (lambda (value) - (setf (etaf-value (plist-get model :dark)) value) - (setf (etaf-value (plist-get model :toast)) - (if value "Dark theme" "Light theme")))))))))) + (list :model (etaf-inject 'research-shelf-model nil t) + :root (etaf-inject 'research-shelf-root-spec nil t) + :header (etaf-inject 'research-shelf-header-spec nil t)) + :view + (flex :class "research-shelf-header" :width 'stretch :min-width 0 + :flex-wrap 'wrap :align-items 'center :gap '(1 (10)) + (column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0 + (text :color (etaf-theme-token :accent) + (expr (etaf-playground-static-value + (plist-get (etaf-state) :header) :eyebrow "Research Shelf"))) + (text :font-weight 'bold + (expr (etaf-playground-static-value + (plist-get (etaf-state) :root) :title "Research Shelf"))) + (text :color (etaf-theme-token :muted) + (expr (etaf-playground-static-value + (plist-get (etaf-state) :root) :subtitle + "A quiet place for unfinished ideas")))) + (column :width 'max-content :min-width 0 + :flex-grow 0 :flex-shrink 0 + (etaf-checkbox + :label "Dark" :ref 'research-shelf-theme-toggle + :checked (plist-get (plist-get (etaf-state) :model) :dark) + :on-change + (let ((model (plist-get (etaf-state) :model))) + (lambda (value) + (setf (etaf-value (plist-get model :dark)) value) + (setf (etaf-value (plist-get model :toast)) + (if value "Dark theme" "Light theme")))))))) (etaf-define-component etaf-research-shelf-footer () "Render the default `footer' slot from App Context." :setup - (let ((model (etaf-inject 'research-shelf-model nil t)) - (footer-spec (etaf-inject 'research-shelf-footer-spec nil t))) - (lambda () - (etaf-view - (flex - :class "research-shelf-footer" :width 'stretch - :flex-wrap 'wrap :gap '(1 (8)) - (column - :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0 - (label :text (plist-get model :toast)) - (label :text (etaf-playground-static-value - footer-spec :label "SQLite · saved locally") - :color (etaf-theme-token :muted))) - (label :text (file-name-nondirectory - (plist-get model :database-file)) - :color (etaf-theme-token :muted))))))) + (list :model (etaf-inject 'research-shelf-model nil t) + :footer (etaf-inject 'research-shelf-footer-spec nil t)) + :view + (flex :class "research-shelf-footer" :width 'stretch + :flex-wrap 'wrap :gap '(1 (8)) + (column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0 + (text + (expr + (etaf-value (plist-get (plist-get (etaf-state) :model) :toast)))) + (text :color (etaf-theme-token :muted) + (expr (etaf-playground-static-value + (plist-get (etaf-state) :footer) + :label "SQLite · saved locally")))) + (text :color (etaf-theme-token :muted) + (expr (file-name-nondirectory + (plist-get (plist-get (etaf-state) :model) :database-file)))))) (etaf-define-component etaf-research-shelf-shell () "Pure responsive layout shell with named content slots. @@ -759,21 +797,16 @@ slot has a reusable built-in fallback for direct composition." (etaf-provide 'research-shelf-footer-spec (etaf-playground-static-child static-form 'research-shelf-footer-spec)) - (lambda () - (etaf-view - ;; These are real ETAF named slot inputs; the `.etaf' manifest remains - ;; inert configuration and never pretends to be View syntax. - (etaf-research-shelf-shell - (slot :name 'header - (etaf-research-shelf-header)) - (slot :name 'filters - (etaf-research-shelf-filter-rail)) - (slot :name 'library - (etaf-research-shelf-reading-list)) - (slot :name 'detail - (etaf-research-shelf-detail-inspector)) - (slot :name 'footer - (etaf-research-shelf-footer))))))) + nil) + :view + ;; These are real ETAF named slot inputs; the `.etaf' manifest remains inert + ;; configuration and never pretends to be View syntax. + (etaf-research-shelf-shell + (slot :name 'header (etaf-research-shelf-header)) + (slot :name 'filters (etaf-research-shelf-filter-rail)) + (slot :name 'library (etaf-research-shelf-reading-list)) + (slot :name 'detail (etaf-research-shelf-detail-inspector)) + (slot :name 'footer (etaf-research-shelf-footer)))) ;;; --------------------------------------------------------------------------- ;;; ROOT / PLAYGROUND REGISTRATION diff --git a/scripts/benchmark-research-shelf.el b/scripts/benchmark-research-shelf.el index dbf9a5f..77cd2f6 100644 --- a/scripts/benchmark-research-shelf.el +++ b/scripts/benchmark-research-shelf.el @@ -670,9 +670,13 @@ ETAF generation and preserve RUNTIME's mounted surface in BUFFER." (unless (etaf-performance-evaluator--visible-match-p buffer "Page 1 / 22") (etaf-dispatch-event runtime 'research-shelf-page-previous 'press)) - (let ((refs '(research-shelf-row-2 research-shelf-row-4 - research-shelf-row-5 research-shelf-row-8 - research-shelf-row-9 research-shelf-row-10))) + ;; Use one actionable reading per sample. Reusing a visible row + ;; eventually reaches 100% during the five warmups and would make a + ;; real disabled button look like a failed mutation. Moving to the + ;; target page is setup work and remains outside the timed action. + (let ((ids '(1 4 6 9 12 13 16 17 20 21 24 25 28 29 32 33 + 36 37 40 41 44 45 48 49 52 53 56 57 60 61 64 + 65 68 69 72))) (push (etaf-performance-evaluator--measure "progress-mutation" @@ -688,9 +692,39 @@ ETAF generation and preserve RUNTIME's mounted surface in BUFFER." (etaf-performance-evaluator--surface-invariant runtime buffer "progress-mutation")) :setup (lambda (index) - (etaf-performance-evaluator--select-ref - runtime (nth (% (1+ index) (length refs)) refs)))) + (let* ((id (nth + (mod (+ index + etaf-performance-evaluator-warmup-count) + (length ids)) + ids)) + (target-page + (1+ (/ (1- id) + etaf-performance-evaluator-page-size))) + (current-page + (or (car + (etaf-performance-evaluator--visible-page + buffer)) + 1))) + (while (< current-page target-page) + (etaf-dispatch-event + runtime 'research-shelf-page-next 'press) + (setq current-page (1+ current-page))) + (while (> current-page target-page) + (etaf-dispatch-event + runtime 'research-shelf-page-previous 'press) + (setq current-page (1- current-page))) + (etaf-performance-evaluator--select-ref + runtime (intern (format "research-shelf-row-%d" id)))))) results)) + ;; Progress setup traverses several pages to keep each action + ;; enabled. Return to the first page before the shared focus and + ;; add/reload scenarios, whose refs intentionally target page one. + (when-let* ((page (etaf-performance-evaluator--visible-page buffer))) + (while (> (car page) 1) + (etaf-dispatch-event + runtime 'research-shelf-page-previous 'press) + (setq page + (etaf-performance-evaluator--visible-page buffer)))) (push (etaf-performance-evaluator--measure diff --git a/scripts/playground-gui-scenarios.el b/scripts/playground-gui-scenarios.el index 8bf3390..6b4b534 100644 --- a/scripts/playground-gui-scenarios.el +++ b/scripts/playground-gui-scenarios.el @@ -14,6 +14,10 @@ (require 'benchmark-research-shelf) (require 'ebox-native-reflow) +(defvar etaf-research-shelf-database-file) +(defvar etaf-research-shelf-fixture-size) +(defvar etaf-research-shelf-page-size) + (defun etaf-playground-gui-scenarios--report (context) "Return CONTEXT target's public Ebox report, or nil before mount." (let ((buffer (etaf-gui-verifier-context-target-buffer context))) @@ -261,8 +265,10 @@ "scroll-reset" (= (window-start) (point-min))))))) (defun etaf-playground-gui-scenarios--research-event-action - (id reference postcondition &optional screenshot) - "Return Research action ID for REFERENCE, POSTCONDITION, and SCREENSHOT." + (id reference postcondition &optional screenshot assertion-name) + "Return Research action ID for REFERENCE and POSTCONDITION. +When non-nil, SCREENSHOT requests visual evidence and ASSERTION-NAME names the +product-specific postcondition in the evidence stream." (etaf-gui-verifier-action-create :id id :execute @@ -304,7 +310,7 @@ "activated-host-focused" (equal reference (etaf-focused-host-ref runtime))) (etaf-gui-verifier-assert - "product-postcondition" + (or assertion-name "product-postcondition") (funcall postcondition context))))) :screenshot screenshot)) @@ -356,6 +362,22 @@ (string-match-p "John Berger · Book" (etaf-playground-gui-scenarios--buffer-text context)))) + (etaf-playground-gui-scenarios--research-event-action + "data-mutation" 'research-shelf-progress + (lambda (context) + (let ((text + (etaf-playground-gui-scenarios--buffer-text context))) + (and (string-match-p "Progress saved" text) + (string-match-p "10%" text)))) + t "progress-persisted") + (etaf-playground-gui-scenarios--research-event-action + "filter-reading" 'research-shelf-filter-reading + (lambda (context) + (let ((text + (etaf-playground-gui-scenarios--buffer-text context))) + (and (string-match-p "Showing Reading" text) + (string-match-p "Ways of Seeing" text)))) + t "reading-filter-applied") (etaf-playground-gui-scenarios--research-event-action "theme-toggle" 'research-shelf-theme-toggle (lambda (context) @@ -374,7 +396,7 @@ (etaf-playground-gui-scenarios--maximize-action)) :completion (lambda (context) - (= (etaf-gui-verifier-context-action-count context) 10)))) + (= (etaf-gui-verifier-context-action-count context) 12)))) (defun etaf-playground-gui-scenarios-ebox (scenario fixture) "Return one Ebox SCENARIO adapter for FIXTURE." @@ -431,6 +453,14 @@ (run-directory (or (getenv "ETAF_GUI_RUN_DIR") (error "ETAF_GUI_RUN_DIR is not configured"))) (fixture (getenv "ETAF_GUI_FIXTURE")) + (_research-fixture + (when (equal scenario "research-shelf") + (setq etaf-research-shelf-database-file + (expand-file-name "research-shelf.sqlite" run-directory) + etaf-research-shelf-fixture-size 256 + etaf-research-shelf-page-size 12) + (when (file-exists-p etaf-research-shelf-database-file) + (delete-file etaf-research-shelf-database-file)))) (adapter (pcase scenario ("research-shelf" diff --git a/tests/etaf-playground-tests.el b/tests/etaf-playground-tests.el index c90340d..1dbd6ad 100644 --- a/tests/etaf-playground-tests.el +++ b/tests/etaf-playground-tests.el @@ -69,6 +69,15 @@ database and mounts a test buffer before running BODY." (when (file-exists-p ,database) (delete-file ,database)))))) +(defun etaf-playground-test--open (buffer) + "Mount Research Shelf in BUFFER with a deterministic headless viewport. + +Text assertions should not depend on whether batch Ebox has a selected +window. GUI verification exercises responsive geometry separately on a real +frame." + (etaf-playground-mount-example + buffer "research-shelf" nil '(:viewport-width 1400 :viewport-height 80))) + (ert-deftest etaf-playground-framework-discovers-generic-file-triplets () "Discover examples from files without a business catalog in the framework." (should (member "research-shelf" etaf-playground-example-names)) @@ -395,11 +404,13 @@ database and mounts a test buffer before running BODY." (replace-match ":title \"Research Shelf (edited)\"" t t) (set-buffer-modified-p t)) (should (etaf-playground-refresh source)) - (should (string-match-p "Research Shelf (edited)" - (etaf-playground-test--text - (etaf-playground-session-preview-buffer - (buffer-local-value 'etaf-playground-session - source)))))))) + (let* ((session (buffer-local-value 'etaf-playground-session source)) + (preview (etaf-playground-session-preview-buffer session)) + (form (etaf-playground-read-static "research-shelf" session))) + (should (equal "Research Shelf (edited)" + (plist-get (cdr form) :title))) + (should (string-match-p "edited" + (etaf-playground-test--text preview))))))) (ert-deftest etaf-playground-refresh-reloads-unsaved-el-companion () "Refreshing a dirty `.el' companion redefines its consumer intentionally." @@ -426,13 +437,11 @@ database and mounts a test buffer before running BODY." (etaf-compiler-clear-cache) (let* ((before (etaf-compiler-statistics)) (instantiations (plist-get before :instantiations)) - (fallbacks (plist-get before :fallbacks)) (cache-entries (plist-get before :static-cache-entries))) - (etaf-playground-open buffer) + (etaf-playground-test--open buffer) (let* ((after (etaf-compiler-statistics)) (blueprint (plist-get after :last-blueprint))) (should (> (plist-get after :instantiations) instantiations)) - (should (>= (plist-get after :fallbacks) fallbacks)) (should (> (plist-get after :static-cache-entries) cache-entries)) (should (> (plist-get blueprint :dynamic-nodes) 0)) (should (> (plist-get blueprint :hole-count) 0)))))) @@ -440,7 +449,7 @@ database and mounts a test buffer before running BODY." (ert-deftest etaf-playground-pair-mounts-sqlite-backed-surface () "Mounting the pair initializes SQLite and renders the real app shell." (etaf-playground-test--with-app (buffer database) - (etaf-playground-open buffer) + (etaf-playground-test--open buffer) (should (etaf-runtime-p (etaf-runtime-for-buffer buffer))) (should (file-exists-p database)) (let ((text (etaf-playground-test--text buffer)) @@ -492,7 +501,7 @@ database and mounts a test buffer before running BODY." (ert-deftest etaf-playground-row-selection-is-repeatable () "Repeated DataGrid row presses replace one selected identity." (etaf-playground-test--with-app (buffer database) - (etaf-playground-open buffer) + (etaf-playground-test--open buffer) (let ((runtime (etaf-runtime-for-buffer buffer))) (dolist (entry '((research-shelf-row-1 research-shelf-row-2 "The best tools make attention feel larger") @@ -526,7 +535,7 @@ database and mounts a test buffer before running BODY." (ert-deftest etaf-playground-row-observer-on-off-is-exact () "Observation preserves one real row mutation and its retained identity." (etaf-playground-test--with-app (buffer database) - (etaf-playground-open buffer) + (etaf-playground-test--open buffer) (let* ((runtime (etaf-runtime-for-buffer buffer)) (surface (lambda () @@ -578,7 +587,7 @@ database and mounts a test buffer before running BODY." (ert-deftest etaf-playground-filters-and-pagination-reload-data () "Filter and pager refs drive the SQLite-backed Data Controller." (etaf-playground-test--with-app (buffer database) - (etaf-playground-open buffer) + (etaf-playground-test--open buffer) (let ((runtime (etaf-runtime-for-buffer buffer))) ;; Move the selected identity through the old page first. The following ;; filter reorders keyed Range items; generated cell Host addresses must @@ -621,7 +630,7 @@ database and mounts a test buffer before running BODY." (ert-deftest etaf-playground-workspace-reflows-at-responsive-widths () "Workspace cards share a row when wide and wrap in document order." (etaf-playground-test--with-app (buffer database) - (etaf-playground-open buffer) + (etaf-playground-test--open buffer) (cl-labels ((line-of (label) (with-current-buffer buffer @@ -652,7 +661,7 @@ database and mounts a test buffer before running BODY." (ert-deftest etaf-playground-mutations-persist-and-refresh () "Add and mutate actions persist through the SQLite source and refresh UI." (etaf-playground-test--with-app (buffer database) - (etaf-playground-open buffer) + (etaf-playground-test--open buffer) (let ((runtime (etaf-runtime-for-buffer buffer))) (etaf-dispatch-event runtime 'research-shelf-row-1 'press) (etaf-dispatch-event runtime 'research-shelf-progress 'press) @@ -675,7 +684,7 @@ database and mounts a test buffer before running BODY." (ert-deftest etaf-playground-theme-and-lifecycle-are-repeatable () "Theme changes and reset/close do not leave a stale mounted runtime." (etaf-playground-test--with-app (buffer database) - (etaf-playground-open buffer) + (etaf-playground-test--open buffer) (let ((runtime (etaf-runtime-for-buffer buffer))) (etaf-dispatch-event runtime 'research-shelf-row-1 'press) (let (dark-output light-output) diff --git a/tests/playground-gui-scenarios-tests.el b/tests/playground-gui-scenarios-tests.el new file mode 100644 index 0000000..c6b1a59 --- /dev/null +++ b/tests/playground-gui-scenarios-tests.el @@ -0,0 +1,92 @@ +;;; playground-gui-scenarios-tests.el --- GUI scenario contracts -*- lexical-binding: t; -*- + +;;; Commentary: + +;; These tests keep the Research Shelf GUI action matrix aligned with the M0a +;; evidence claim without running a graphical Emacs session. + +;;; Code: + +(require 'cl-lib) +(require 'ert) +(add-to-list 'load-path (expand-file-name "../etaf/scripts" default-directory)) +(add-to-list 'load-path (expand-file-name "../ebox-playground" default-directory)) +(require 'playground-gui-scenarios) + +(defvar etaf-research-shelf-database-file) +(defvar etaf-research-shelf-fixture-size) +(defvar etaf-research-shelf-page-size) + +(defun etaf-playground-gui-test--action (scenario id) + "Return SCENARIO action named ID." + (cl-find id (etaf-gui-verifier-scenario-actions scenario) + :key #'etaf-gui-verifier-action-id :test #'equal)) + +(ert-deftest etaf-playground-gui-research-covers-public-action-matrix () + "Research Shelf covers every required public M0a interaction in order." + (let* ((scenario (etaf-playground-gui-scenarios-research)) + (actions (etaf-gui-verifier-scenario-actions scenario))) + (should + (equal + (mapcar #'etaf-gui-verifier-action-id actions) + '("mount" "leave-fullscreen" "resize-narrow" "scroll-down" + "reset-scroll" "resize-wide" "row-select" "data-mutation" + "filter-reading" "theme-toggle" "pagination" + "maximize-frame"))) + (should + (funcall + (etaf-gui-verifier-scenario-completion scenario) + (etaf-gui-verifier--context-create + :scenario scenario :action-count (length actions)))))) + +(ert-deftest etaf-playground-gui-research-names-product-assertions () + "Public filter and mutation actions publish specific passing assertions." + (let ((database (make-temp-file "etaf-gui-research-" nil ".sqlite")) + (buffer " *etaf-gui-research-test*")) + (unwind-protect + (let* ((etaf-research-shelf-database-file database) + (etaf-research-shelf-fixture-size 32) + (etaf-research-shelf-page-size 12) + (scenario (etaf-playground-gui-scenarios-research)) + context) + (ignore etaf-research-shelf-database-file + etaf-research-shelf-fixture-size + etaf-research-shelf-page-size) + (etaf-playground-mount-example + buffer "research-shelf" nil + '(:viewport-width 1400 :viewport-height 80)) + (switch-to-buffer buffer) + (setq context + (etaf-gui-verifier--context-create + :scenario scenario :target-buffer (get-buffer buffer))) + (dolist (entry '(("row-select") + ("data-mutation" . "progress-persisted") + ("filter-reading" . "reading-filter-applied"))) + (let ((id (car entry)) + (assertion-name (cdr entry))) + (let ((action + (etaf-playground-gui-test--action scenario id))) + (should action) + (funcall (etaf-gui-verifier-action-execute action) context) + (should + (funcall + (etaf-gui-verifier-action-settled-p action) context)) + (when assertion-name + (let* ((assertions + (funcall + (etaf-gui-verifier-action-assertions action) + context)) + (assertion + (cl-find + assertion-name assertions + :key (lambda (item) (alist-get 'name item)) + :test #'equal))) + (should assertion) + (should (alist-get 'passed assertion)))))))) + (when (get-buffer buffer) + (etaf-playground-close buffer)) + (when (file-exists-p database) + (delete-file database))))) + +(provide 'playground-gui-scenarios-tests) +;;; playground-gui-scenarios-tests.el ends here