feat: close Research Shelf M0 integration

This commit is contained in:
Kinneyzhang 2026-08-31 16:07:25 +08:00
parent 5cbf949465
commit d124c33ba4
5 changed files with 478 additions and 280 deletions

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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)

View File

@ -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