diff --git a/Makefile b/Makefile index 664df2c..57bb559 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ EMACS ?= emacs LOAD_PATH = -L . -L examples -L scripts -L ../ebox -L ../tp -L ../ecss SOURCES = etaf-view.el etaf-compiler.el etaf-component.el etaf-scheduler.el etaf-reactive.el etaf-observer.el etaf-context.el etaf-theme-tp.el etaf-resource.el etaf-data.el etaf-generation.el etaf-host.el etaf-retirement.el etaf-render-port.el etaf-renderer.el etaf-runtime.el etaf-behavior.el etaf-actions.el etaf-events.el etaf-performance.el etaf.el scripts/emacs-gui-verifier.el scripts/benchmark-scheduler-context.el EXAMPLES = examples/etaf-counter-example.el examples/etaf-data-example.el examples/etaf-resource-example.el -TESTS = tests/etaf-tests.el tests/etaf-compiler-tests.el tests/etaf-component-frontends-tests.el tests/etaf-resource-tests.el tests/etaf-data-tests.el tests/etaf-theme-tp-tests.el tests/etaf-examples-tests.el tests/etaf-observer-tests.el tests/etaf-performance-tests.el tests/etaf-gui-verifier-tests.el tests/etaf-m0a-current-characterization-tests.el tests/etaf-interaction-contract-tests.el tests/etaf-m0b-component-manifest-tests.el tests/etaf-render-port-tests.el tests/etaf-generation-tests.el tests/etaf-host-tests.el tests/etaf-retirement-tests.el tests/etaf-scheduler-tests.el tests/etaf-g1-cross-layer-tests.el +TESTS = tests/etaf-tests.el tests/etaf-compiler-tests.el tests/etaf-component-frontends-tests.el tests/etaf-render-view-tests.el tests/etaf-dynamic-components-tests.el tests/etaf-event-forwarding-tests.el tests/etaf-resource-tests.el tests/etaf-data-tests.el tests/etaf-theme-tp-tests.el tests/etaf-examples-tests.el tests/etaf-observer-tests.el tests/etaf-performance-tests.el tests/etaf-gui-verifier-tests.el tests/etaf-m0a-current-characterization-tests.el tests/etaf-interaction-contract-tests.el tests/etaf-m0b-component-manifest-tests.el tests/etaf-render-port-tests.el tests/etaf-generation-tests.el tests/etaf-host-tests.el tests/etaf-retirement-tests.el tests/etaf-scheduler-tests.el tests/etaf-g1-cross-layer-tests.el .PHONY: test compile load checkdoc docs-check metadata-check scheduler-benchmark check clean @@ -15,7 +15,7 @@ compile: clean $(EMACS) -Q --batch $(LOAD_PATH) \ --eval "(setq load-prefer-newer t byte-compile-error-on-warn t byte-compile-warnings '(not obsolete))" \ --eval "(load-file \"etaf.el\")" \ - --eval "(dolist (file '($(foreach file,$(SOURCES) $(EXAMPLES),\"$(file)\"))) (byte-compile-file file))" + --eval "(dolist (file '($(foreach file,$(SOURCES) $(EXAMPLES),\"$(file)\"))) (unless (byte-compile-file file) (error \"Compilation failed: %s\" file)))" load: compile $(EMACS) -Q --batch $(LOAD_PATH) --eval "(setq load-prefer-newer t)" --eval "(require 'etaf)" --eval "(princ \"ETAF load OK\\n\")" diff --git a/README.md b/README.md index f67bb15..a32e73d 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,85 @@ # ETAF -ETAF is a small text-application framework built above the independent [Ebox](../ebox) layout and rendering engine. +ETAF builds text applications from reusable Components above the independent +[Ebox](../ebox) layout and rendering engine. -Its complete public model is: - -```text -Component(props, Scope) → View → Renderer → Ebox Node → Emacs buffer -``` - -Every visible structure uses one form: +Start with `etaf-view` and `etaf-mount`. Properties precede children in +`(name :property value ... child ...)`; property values are ordinary Elisp. +Evaluate the complete example, switch to `*etaf-hello*`, and activate “Say hello”: + ```elisp -(name :property value ... child ...) -``` - -The only child computation bridge is `expr :value`; attribute values are ordinary Elisp expressions. - -```elisp -(etaf-view - (column - (text :font-weight 'bold "Hello") - (text - :color "#687386" - (expr :value (if ready "Ready" "Waiting"))))) -``` - -Define a Component: - -```elisp -(etaf-define-component status-label (&key label) - "Render a status label." - :view - (text :font-weight 'bold (expr :value label))) +;;; -*- lexical-binding: t; -*- +(require 'etaf) (etaf-mount - "*etaf-demo*" - (etaf-view (status-label :label "Connected"))) + "*etaf-hello*" + (etaf-view + (column + (text :font-weight 'bold "Hello") + (box :ref 'hello :role 'button :tab-index 0 + :on-press (lambda () (message "Hello ETAF")) + "Say hello")))) ``` -`etaf-view` is the single public View constructor. Structural forms do not use quote; quote remains ordinary Elisp data syntax, such as `'bold`. A View returned from ordinary Elisp is explicitly constructed with `(etaf-view ...)` inside `expr`. +A Component receives declared props and optional content through slots. Use the +exact name passed to `etaf-define-component`; the registry creates no aliases. +`(expr FORM)` evaluates one child expression. In a structural child position it +may return nil, text, a typed Host or Component View, or a proper sequence of +those values. Inside `text`, an expression must return a string. + + +```elisp +;;; -*- lexical-binding: t; -*- +(require 'etaf) + +(etaf-define-component demo-card (&key title) + :view + (column + (text :font-weight 'bold (expr title)) + (slot) + (slot :name 'footer))) + +(etaf-mount + "*etaf-card*" + (etaf-view + (demo-card :title "Account" + (text "Connected") + (slot :name 'footer (text "Footer"))))) +``` + +Add `:setup` when a Component owns state. It runs once per retained instance; +`:render` uses ordinary Elisp to capture handles before returning `etaf-view`. +The shorter `:view` form compiles the same View model. + + +```elisp +;;; -*- lexical-binding: t; -*- +(require 'etaf) + +(etaf-define-component demo-counter () + :setup (etaf-ref 0) + :render + (let ((count (etaf-state))) + (etaf-view + (column + (text (expr (format "Count: %d" (etaf-value count)))) + (box :ref 'increment :role 'button :tab-index 0 + :on-press (lambda () (cl-incf (etaf-value count))) + "Increment"))))) + +(etaf-mount "*etaf-counter*" (etaf-view (demo-counter))) +``` + +Keep `etaf-value` reads inside the property or `expr` that should update. Event +callbacks capture ordinary lexical locals; call `etaf-state` during rendering. +Use a lexical-binding `.el` file for reusable application code. `etaf-node` is +available for programmatic View builders. Context, Data, Behavior, and named +Actions are optional capabilities; simple callbacks need no Action registration. + +Use exact catalog names such as `etaf-button` after `(require 'etaf-ui)`. +Core does not load `.etaf` files: Playground treats them as inert structure, +with its explicit companion registration handling executable Elisp. ## Performance records @@ -125,6 +166,8 @@ through the retired v1 capability. During development, load the sibling Ebox checkout before ETAF: ```elisp +(add-to-list 'load-path "/path/to/github/ecss") +(add-to-list 'load-path "/path/to/github/tp") (add-to-list 'load-path "/path/to/github/ebox") (add-to-list 'load-path "/path/to/github/etaf") (require 'etaf) diff --git a/README.zh-CN.md b/README.zh-CN.md index 8e849ea..f9339b9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,44 +1,82 @@ # ETAF -ETAF 是构建在独立 [Ebox](../ebox) 布局与渲染引擎之上的小型文本应用框架。 +ETAF 在独立的 [Ebox](../ebox) 布局与渲染引擎上,使用可复用的 Component 构建文本应用。 -完整的公共模型是: - -```text -Component(props, Scope) → View → Renderer → Ebox Node → Emacs buffer -``` - -所有可见结构都使用一种形式: +从 `etaf-view` 和 `etaf-mount` 开始。`(name :property value ... child ...)` +中属性在前、子节点在后,属性值是普通 Elisp。求值下面完整例子,切换到 +`*etaf-hello*`,即可激活 “Say hello”: + ```elisp -(name :property value ... child ...) -``` - -子节点中唯一的计算桥接是 `expr :value`;属性值则是普通 Elisp 表达式。 - -```elisp -(etaf-view - (column - (text :font-weight 'bold "Hello") - (text - :color "#687386" - (expr :value (if ready "Ready" "Waiting"))))) -``` - -定义 Component: - -```elisp -(etaf-define-component status-label (&key label) - "Render a status label." - :view - (text :font-weight 'bold (expr :value label))) +;;; -*- lexical-binding: t; -*- +(require 'etaf) (etaf-mount - "*etaf-demo*" - (etaf-view (status-label :label "Connected"))) + "*etaf-hello*" + (etaf-view + (column + (text :font-weight 'bold "Hello") + (box :ref 'hello :role 'button :tab-index 0 + :on-press (lambda () (message "Hello ETAF")) + "Say hello")))) ``` -`etaf-view` 是唯一的公共 View 构造入口。结构 form 不使用 quote;quote 仍然是普通 Elisp 数据语法,例如 `'bold`。普通 Elisp 返回 View 时,必须在 `expr` 中显式使用 `(etaf-view ...)` 构造它。 +Component 通过声明的 props 接收业务输入,通过 slot 接收内容。调用时使用 +`etaf-define-component` 中的准确名称,注册表不会自动生成 alias。 +`(expr FORM)` 执行一个子节点表达式:结构位置可以返回 nil、字符串、typed +Host 或 Component View,或这些值组成的 proper sequence;`text` 内的表达式必须返回字符串。 + + +```elisp +;;; -*- lexical-binding: t; -*- +(require 'etaf) + +(etaf-define-component demo-card (&key title) + :view + (column + (text :font-weight 'bold (expr title)) + (slot) + (slot :name 'footer))) + +(etaf-mount + "*etaf-card*" + (etaf-view + (demo-card :title "Account" + (text "Connected") + (slot :name 'footer (text "Footer"))))) +``` + +组件拥有状态时才增加 `:setup`,它对每个保留的实例执行一次。 +`:render` 使用普通 Elisp 捕获句柄,再返回 `etaf-view`;更短的 `:view` +形式编译为同一种 View 模型。 + + +```elisp +;;; -*- lexical-binding: t; -*- +(require 'etaf) + +(etaf-define-component demo-counter () + :setup (etaf-ref 0) + :render + (let ((count (etaf-state))) + (etaf-view + (column + (text (expr (format "Count: %d" (etaf-value count)))) + (box :ref 'increment :role 'button :tab-index 0 + :on-press (lambda () (cl-incf (etaf-value count))) + "Increment"))))) + +(etaf-mount "*etaf-counter*" (etaf-view (demo-counter))) +``` + +把 `etaf-value` 放在需要更新的属性或 `expr` 内,保留局部更新边界。 +事件回调捕获普通词法变量,`etaf-state` 在 render 时读取。可复用应用代码放进 +启用 lexical-binding 的 `.el` 文件。程序化构造 View 时也可使用 `etaf-node`。 +Context、Data、Behavior 和命名 Action 按需学习,简单回调不需要注册 Action。 + +加载 `(require 'etaf-ui)` 后,使用 `etaf-button` 等准确目录名称。 +Core 不加载 `.etaf` 文件;Playground 将它们作为 inert 结构,由其显式的 companion +注册入口管理可执行 Elisp。 ## 性能记录面板 @@ -118,6 +156,8 @@ ETAF 为当前 Emacs 进程 snapshot 一个不可变的 v2 render port。按依 开发时先把同级 Ebox 检出目录加入 `load-path`: ```elisp +(add-to-list 'load-path "/path/to/github/ecss") +(add-to-list 'load-path "/path/to/github/tp") (add-to-list 'load-path "/path/to/github/ebox") (add-to-list 'load-path "/path/to/github/etaf") (require 'etaf) diff --git a/docs/architecture.en.md b/docs/architecture.en.md index c8311b5..1c65a2f 100644 --- a/docs/architecture.en.md +++ b/docs/architecture.en.md @@ -132,6 +132,9 @@ the other two clauses are optional: ``` `:view` and `:render` are mutually exclusive and exactly one is required. +`:render` is ordinary Elisp and may return `etaf-view` or a programmatically +built `etaf-node`. Both share View compilation, prop validation, and slot +projection; `:render` adds no second representation or state model. `:setup` and `:styles` are optional and may each appear once. Props are the only declared business inputs; ordinary trailing children and named slots are normalized separately into the Component's slot collection. The Component definition is the current structure/style/behavior boundary. Keep dynamic state, Action callbacks, and lifecycle work in `:setup`; keep static presentation in `:styles`. A future `.etaf` SFC compiler may produce these definitions, but the Runtime does not load `.etaf` files directly. @@ -175,7 +178,7 @@ registration; it never returns a render function. `:key` is stable identity metadata, not a business prop. On a Component call it selects the retained Component instance within the sibling scope; on a Host it is forwarded as the Ebox node key. If a render candidate fails, the Runtime restores the previous instance, handlers, behaviors, and buffer. -In View syntax, canonical Component names may omit the `etaf-` prefix. If the short name would collide with an Elisp function, special form, or Host, the registry assigns a semantic `-view` alias. Ordinary Elisp APIs such as `etaf-value`, `etaf-ref`, and `etaf-mount` always keep their prefix. +Component calls use the exact registered definition name. The registry creates no automatic prefix or `-view` aliases. Official catalog Components use names such as `etaf-button`; an application may explicitly define its own Component under any valid unoccupied name. ## 5. Children and slots @@ -222,6 +225,13 @@ Named slot names are stable non-keyword symbols. Strings, numbers, variables, an Inside a Component, `slot` projects content. In a Component call's child region, `slot :name` contributes content. The compiler uses the same normalized slot representation for both roles. +Authored slot expressions retain their author's props, state, and Context across +the entire projected subtree. A Component created in that subtree owns its own +props, state, styles, and Scope, inheriting Context from the slot author. The +receiver's own fallback, ordinary children, and View-producing callbacks use +the receiver environment instead. Table/Grid cell callbacks keep the same +ordinary rule: their Context is the consuming Table/Grid location. + ## 6. Core Hosts and Ebox ETAF core intentionally provides only minimal, unstyled Hosts: @@ -326,32 +336,50 @@ Runtime events are dispatched through `etaf-dispatch-event`, and focus/hit testi Context is an inherited Component Scope environment: + ```elisp -(etaf-define-component service-provider () - "Provide a reactive service to descendants." - :setup - (let ((service (etaf-ref "demo-service"))) - (etaf-provide 'service service) - service) - :view (slot)) +(require 'etaf) (etaf-define-component service-consumer () "Read the inherited service." :setup (etaf-inject 'service nil t) - :view (text (expr (etaf-value (etaf-state))))) + :view (text (expr (format "Service: %s" (etaf-value (etaf-state)))))) -(etaf-view (service-provider (service-consumer))) +(etaf-define-component service-provider () + "Provide a reactive service to its own child Component." + :setup + (let ((service (etaf-ref "demo-service"))) + (etaf-provide 'service service) + service) + :view (service-consumer)) + +(etaf-mount "*etaf-context*" (etaf-view (service-provider))) ``` -Keys are stable ordinary symbols. The nearest ancestor wins; a missing required key signals `etaf-context-error`. Theme is a Context value containing a property plist: +Keys are stable ordinary symbols. The nearest ancestor wins; a missing required +key signals `etaf-context-error`. Root-authored slots retain the root's empty +Context throughout their subtree. Migration from the former accidental receiver +inheritance requires declaring consumers in the provider's own View or calling +an ordinary View-producing callback there. Projected slots continue to use their +author's environment. Theme follows this same Context ancestry and contains a +property plist: + ```elisp +(require 'etaf) + (etaf-define-component themed-shell () - "Provide default text colors to a subtree." + "Provide semantic colors to its own View." :setup (etaf-theme-provide - '(:color "#F4F6FB" :bgcolor "#202634")) - :view (slot)) + '(:text-color "#F4F6FB" :surface-color "#202634")) + :view + (text :ref 'themed-content + :color (etaf-theme-token :text-color) + :background-color (etaf-theme-token :surface-color) + "Themed content")) + +(etaf-mount "*etaf-theme*" (etaf-view (themed-shell))) ``` Palette resolution remains a Theme concern, not a UI catalog concern. Core diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 78e91c8..a120678 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -128,7 +128,9 @@ ATTRIBUTE = :KEY VALUE :styles (styles RULE...)) ``` -`:view` 和 `:render` 互斥且必须恰好出现一个;`:setup` 与 `:styles` 可选且各最多出现一次。Props 是唯一需要声明的业务输入;普通尾部子节点和命名 slot 会被规范化为 Component 的 slot 集合。 +`:view` 和 `:render` 互斥且必须恰好出现一个。`:render` 是普通 Elisp,可以返回 +`etaf-view` 或程序化构造的 `etaf-node`;两者共用 View 编译、prop 校验与 slot +投影,不增加另一套表示或状态模型。`:setup` 与 `:styles` 可选且各最多出现一次。Props 是唯一需要声明的业务输入;普通尾部子节点和命名 slot 会被规范化为 Component 的 slot 集合。 Component definition 是当前结构/样式/行为边界:动态状态、Action callback 和生命周期工作放进 `:setup`,静态 presentation 放进 `:styles`。未来 `.etaf` SFC compiler 可以生成这些 definition,但 Runtime 不会直接加载 `.etaf` 文件。 @@ -170,7 +172,7 @@ state,不重新运行 setup。setup 负责局部 ref、computed、watch、Effe `:key` 是稳定的 identity metadata,不是业务 prop。放在 Component 调用上时,它选择同级作用域内要保留的 Component instance;放在 Host 上时,它会作为 Ebox node key 向下传递。候选渲染失败时,Runtime 恢复旧 instance、handlers、Behaviors 和 buffer。 -在 View 语法中,Component 的规范名称可以省略 `etaf-` 前缀。如果短名称会与 Elisp 函数、special form 或 Host 冲突,注册表会分配语义明确的 `-view` alias。普通 Elisp API,例如 `etaf-value`、`etaf-ref` 和 `etaf-mount`,始终保留前缀。 +Component 调用使用定义时准确注册的名称,注册表不会自动生成省略前缀或 `-view` alias。官方目录组件使用 `etaf-button` 等名称;应用可以显式使用任何合法且尚未占用的名称定义自己的组件。 ## 5. children 与 slot @@ -217,6 +219,12 @@ children 只是匿名/默认 slot 的便捷写法,不是第二套内容模型 在 Component 内,`slot` 表示投影;在 Component 调用的子节点区,带 `:name` 的 `slot` 表示贡献内容。编译器对两种位置使用同一个规范化 slot 表示。 +Slot 中作者写下的表达式在整个投影子树中保留作者的 props、state 和 Context。 +其中创建的 Component 拥有自己的 props、state、styles 和 Scope,但其 Context +从 slot 作者环境继承。接收方自己定义的 fallback、普通子节点和产生 View 的 +callback 使用接收方环境。Table/Grid 的 cell callback 遵循相同的普通规则: +Context 来自消费它的 Table/Grid 所在位置。 + ## 6. Core Host 与 Ebox ETAF core 只提供最小且无样式的 Host: @@ -318,32 +326,48 @@ Runtime 事件通过 `etaf-dispatch-event` 进入;命中测试和 focus 通过 Context 是继承的 Component Scope 环境: + ```elisp -(etaf-define-component service-provider () - "Provide a reactive service to descendants." - :setup - (let ((service (etaf-ref "demo-service"))) - (etaf-provide 'service service) - service) - :view (slot)) +(require 'etaf) (etaf-define-component service-consumer () "Read the inherited service." :setup (etaf-inject 'service nil t) - :view (text (expr (etaf-value (etaf-state))))) + :view (text (expr (format "Service: %s" (etaf-value (etaf-state)))))) -(etaf-view (service-provider (service-consumer))) +(etaf-define-component service-provider () + "Provide a reactive service to its own child Component." + :setup + (let ((service (etaf-ref "demo-service"))) + (etaf-provide 'service service) + service) + :view (service-consumer)) + +(etaf-mount "*etaf-context*" (etaf-view (service-provider))) ``` -key 使用稳定的普通 symbol。最近的祖先优先,缺失的 required key 触发 `etaf-context-error`。Theme 是一个 Context value,内容是属性 plist: +key 使用稳定的普通 symbol。最近的祖先优先,缺失的 required key 触发 +`etaf-context-error`。在根位置编写的 slot 内容及其子树保留根的空 Context。 +迁移旧版本中意外继承接收方 Context 的用法时,应把消费者写进 provider 自己的 +View,或在那里调用普通的 View-producing callback。投影的 slot 继续使用作者 +环境。Theme 遵循同一套 Context 继承关系,内容是属性 plist: + ```elisp +(require 'etaf) + (etaf-define-component themed-shell () - "Provide default text colors to a subtree." + "Provide semantic colors to its own View." :setup (etaf-theme-provide - '(:color "#F4F6FB" :bgcolor "#202634")) - :view (slot)) + '(:text-color "#F4F6FB" :surface-color "#202634")) + :view + (text :ref 'themed-content + :color (etaf-theme-token :text-color) + :background-color (etaf-theme-token :surface-color) + "Themed content")) + +(etaf-mount "*etaf-theme*" (etaf-view (themed-shell))) ``` Palette 解析属于 Theme,而不是 UI 目录。core 提供 diff --git a/docs/implementation-plan.en.md b/docs/implementation-plan.en.md index 73d742b..e6197f3 100644 --- a/docs/implementation-plan.en.md +++ b/docs/implementation-plan.en.md @@ -18,7 +18,7 @@ The repository is complete for the agreed unified architecture when the mandator | Milestone | Delivered responsibility | Evidence | | --- | --- | --- | -| P0 grammar | Unified View shape, property-first parsing, `etaf-view`, `(expr FORM)`, core Hosts, aliases | `tests/etaf-tests.el` structural and syntax tests | +| P0 grammar | Unified View shape, property-first parsing, `etaf-view`, `(expr FORM)`, core Hosts, exact registered Component names | `tests/etaf-tests.el` structural and syntax tests | | P1 Components | `:view`, `:setup`, props, default/named slots, retained instances, lifecycle, `:key`, raw Ebox escape | Component, slot, mount, prop-update, raw-node, and rollback tests | | P2 Runtime | refs, computed, effects, watches, Scope cleanup, Context, Theme, Behaviors, events, focus, Actions | Mounted event/focus tests, reactive failure rollback, cleanup tests | | P3 presentation | scoped styles, selector matching, Theme precedence, inline text runs, Resource and error boundary | Style, Theme, text-surface, Resource, and error tests | @@ -65,7 +65,9 @@ Do not split a file merely to create a shorter name. Split only when a stable re - `etaf-define-component` chooses exactly one of `:view` or `:render`; `:setup` and `:styles` are optional. -- `:view` and `:render` are mutually exclusive frontends. +- `:view` and `:render` are mutually exclusive frontends for one View model; + ordinary `:render` may return `etaf-view`, preserving lexical scope and the + same compiler prop/slot rules. - `:setup` runs once per retained identity and returns opaque state read with `etaf-state` during either frontend. - Props update render without rerunning setup. diff --git a/docs/implementation-plan.zh.md b/docs/implementation-plan.zh.md index 448fad0..15e1837 100644 --- a/docs/implementation-plan.zh.md +++ b/docs/implementation-plan.zh.md @@ -18,7 +18,7 @@ | 里程碑 | 已交付职责 | 证据 | | --- | --- | --- | -| P0 grammar | 统一 View 形状、属性优先解析、`etaf-view`、`(expr FORM)`、核心 Host、alias | `tests/etaf-tests.el` 结构和语法测试 | +| P0 grammar | 统一 View 形状、属性优先解析、`etaf-view`、`(expr FORM)`、核心 Host、准确注册的 Component 名称 | `tests/etaf-tests.el` 结构和语法测试 | | P1 Component | `:view`、`:setup`、props、默认/命名 slot、retained instance、生命周期、`:key`、raw Ebox 出口 | Component、slot、mount、prop 更新、raw node、rollback 测试 | | P2 Runtime | ref、computed、effect、watch、Scope cleanup、Context、Theme、Behavior、事件、focus、Action | 挂载事件/focus、响应式失败回滚和 cleanup 测试 | | P3 presentation | 作用域样式、selector、Theme 优先级、inline text runs、Resource 和 error boundary | 样式、Theme、文本 surface、Resource、error 测试 | @@ -65,7 +65,8 @@ - `etaf-define-component` 必须在 `:view` 与 `:render` 中恰好选择一个;`:setup` 与 `:styles` 可选。 -- `:view` 与 `:render` 是互斥 frontend。 +- `:view` 与 `:render` 是同一 View 模型的互斥 frontend;普通 `:render` 可返回 + `etaf-view`,保留词法作用域与相同的 compiler prop/slot 规则。 - `:setup` 对每个 retained identity 只运行一次,返回 opaque 状态,并由两个 frontend 中的 `etaf-state` 读取。 - Props 更新只重新 render,不重新运行 setup。 diff --git a/docs/user-guide.en.md b/docs/user-guide.en.md index 2d06445..90a705c 100644 --- a/docs/user-guide.en.md +++ b/docs/user-guide.en.md @@ -7,6 +7,8 @@ ETAF builds text applications from one small vocabulary: `View`, `Component`, pr ETAF depends on the independent Ebox package. During development, put the core checkouts on `load-path` and load the one public ETAF entry: ```elisp +(add-to-list 'load-path "/path/to/github/ecss") +(add-to-list 'load-path "/path/to/github/tp") (add-to-list 'load-path "/path/to/github/ebox") (add-to-list 'load-path "/path/to/github/etaf") (require 'etaf) @@ -67,6 +69,38 @@ that viewport directly instead of immediately rerendering: Use `etaf-mount` whenever a View contains a stateful Component, reactive data, events, or lifecycle. +To request pending work explicitly, call `(etaf-runtime-flush runtime)`. +Its return value is now the **integer committed Ebox revision**, replacing the +previous Ebox-node return type. Busy or batched work may remain pending; the +returned revision identifies the publication currently visible to readers. +Calls inside an active Ebox/TP transaction fail before requesting work, since +that transaction's revision may still be provisional. Ordinary flushes do not +export a tree or force a Root rebuild. + +For a current tree and its matching source facts, request an explicit snapshot: + +```elisp +(let* ((runtime (etaf-runtime-for-buffer "*etaf-hello*")) + (snapshot (etaf-runtime-snapshot runtime))) + (list (plist-get snapshot :revision) + (plist-get snapshot :mount-id) + (ebox-render (plist-get snapshot :input)))) +``` + +The snapshot contains `:input` (a canonical Ebox input), `:revision`, and +`:mount-id` (the identity of this mount). Exporting costs O(N) and detaches +ordinary mutable node payload; it does not drain pending work, evaluate +Components, publish, or increment the revision. The input remains usable after +later commits or unmount. Opaque capabilities such as callbacks keep their +identity; the query does not freeze external capabilities or the display +environment. Unmounted Runtime and active Ebox/TP transaction queries fail. + +The obsolete `etaf-runtime-root-node` getter remains read-compatible through +this O(N) query and returns the current single root. Runtime no longer stores a +root mirror. Migrate consumers to `etaf-runtime-snapshot` so the canonical input +retains the root's matching source facts; do not treat the obsolete getter as a +cheap field read or use it as a mutation target. + The current core has no direct `.etaf` loader. `etaf-define-component` is the structure/style/behavior unit: use its View for structure, `:styles` for static presentation, and `:setup` for retained state, Actions, and lifecycle. A future `.etaf` SFC belongs to a compiler layer that emits this same Component contract; it is not a second Runtime entry point. ## 3. Properties and children @@ -95,7 +129,9 @@ The child region is structural. `expr` is the one explicit bridge for ordinary E ``` `expr` accepts exactly one ordinary Elisp form and no structural children. Its -result can be a string, typed View, proper typed View sequence, or `nil`. +result in structural child positions can be a string, typed Host or Component +View, a proper sequence of these values, or `nil`. Inside a `text` Host an +expression must return a string. `if`, `when`, `cond`, `let`, `mapcar`, and `cl-loop` remain normal Elisp. Quote has one ordinary Elisp meaning: @@ -106,6 +142,11 @@ Quote has one ordinary Elisp meaning: For example, `'bold` is the `:font-weight` symbol, while `'(text "data")` is only data and will not render. A dynamic View must be written as `(etaf-view (text "data"))`. +Spacing follows the layout Host: `row` and `column` use `:item-gap`, for +example `(row :item-gap 1 ...)`; `flex` and `grid` use `:gap`. Changing the Host +also changes which spacing property to use; these are not interchangeable +aliases. For example, migrate `(row :gap 1 ...)` to `(row :item-gap 1 ...)`. + The core `grid` Host is the two-dimensional layout choice: ```elisp @@ -143,20 +184,20 @@ The beginner form is a stateless `:view` Component: (status-label :label "Connected"))) ``` -The canonical Component name may include the `etaf-` prefix: +Use the exact name supplied to `etaf-define-component`: ```elisp -(etaf-view (etaf-status-label :label "Connected")) +(etaf-view (status-label :label "Connected")) ``` -In a View position, ETAF also registers the short alias `status-label`. If a short name conflicts with Elisp, the registry uses a semantic alias ending in `-view`. This alias rule applies only to View names; ordinary functions remain prefixed. +The registry creates no automatic aliases. A Component defined as `etaf-status-label` must be called by that exact name; the `status-label` above is the name explicitly defined in this guide. Official catalog names are `etaf-button`, `etaf-checkbox`, and so on, after requiring `etaf-ui`. The definition macro accepts only these keywords: | Keyword | Meaning | | --- | --- | | `:view` | Declarative View frontend; mutually exclusive with `:render` | -| `:render` | Ordinary-Elisp frontend returning one typed View through `etaf-node` | +| `:render` | Ordinary Elisp returning one typed View, usually with `etaf-view`; programmatic builders may use `etaf-node` | | `:setup` | Optional one-time initialization returning opaque state read with `etaf-state` | | `:styles` | Optional static scoped style declaration | @@ -170,22 +211,23 @@ Use `:setup` when the Component owns local state: (etaf-define-component counter (&key title) "Render a retained counter." :setup - (let ((count (etaf-ref 0))) + (let ((count (etaf-ref 0)) + (initial-title title)) (etaf-on-mounted - (lambda () (message "%s mounted" title))) + (lambda () (message "%s mounted" initial-title))) (etaf-on-unmounted - (lambda () (message "%s unmounted" title))) + (lambda () (message "%s unmounted" initial-title))) count) - :view - (column - (text :font-weight 'bold (expr title)) - (text (expr (format "Count: %d" (etaf-value (etaf-state))))) - (text - :role 'button - :on-press - (let ((count (etaf-state))) - (lambda () (cl-incf (etaf-value count)))) - "Increment"))) + :render + (let ((count (etaf-state)) + (caption title)) + (etaf-view + (column + (text :font-weight 'bold (expr caption)) + (text (expr (format "Count: %d" (etaf-value count)))) + (text :role 'button :tab-index 0 + :on-press (lambda () (cl-incf (etaf-value count))) + "Increment"))))) ``` Setup runs once for the retained instance and returns one opaque state value. @@ -194,6 +236,14 @@ exact value with `etaf-state`. `etaf-on-mounted`, `etaf-on-updated`, and `etaf-on-unmounted` register lifecycle callbacks for that Component instance. Scope disposal automatically stops reactive effects and cleanup. +`:view` and `:render` share compilation, slot projection, and prop validation. +Use ordinary `let`/`let*` to capture state handles or current prop values for +callbacks. `etaf-state` is a render-time accessor, not an event-time accessor. +Keep reactive `etaf-value` reads inside the property or `expr` that needs the +update; extracting a handle does not require reading its value early. Component +code with retained closures belongs in an `.el` file with lexical binding. +Simple local callbacks need no Action definition. + The small reactive API is: ```elisp @@ -256,6 +306,14 @@ Named slots use `:name` and must use a stable non-keyword symbol: The two default-slot shorthands are `(slot)` and `(slot FALLBACK...)`. The normalized spelling is `(slot :name 'default FALLBACK...)`. At a call site, ordinary children fill `default`; a named input uses `(slot :name 'header CHILD...)`. An explicit empty `(slot :name 'header)` suppresses the fallback. Strings, numbers, variables, and runtime expressions are not valid slot names. +Slot expressions keep their author's props, state, and Context throughout the +projected subtree. Components created inside that subtree still own their own +props, state, styles, and Scope; they inherit Context from the slot author's +environment. A receiving Component does not inject its own Context into caller +content. Its fallback, ordinary children, and View-producing callbacks use its +own environment. A Table/Grid cell callback likewise runs in the consuming +Table/Grid's Context. + `expr` may return a typed View or a proper sequence of typed Views at a structural boundary. It never exposes or accepts ETAF's private structs. The same Component can combine a keyed `:for`, a structural expression, and a @@ -311,13 +369,22 @@ Styles are scoped to the Component that authored a View node. A parent rule does Theme is a Context convenience, not another runtime object: + ```elisp +(require 'etaf) + (etaf-define-component themed-shell () - "Provide default text colors to a subtree." + "Provide semantic colors to its own View." :setup (etaf-theme-provide - '(:color "#F4F6FB" :bgcolor "#202634")) - :view (slot)) + '(:text-color "#F4F6FB" :surface-color "#202634")) + :view + (text :ref 'themed-content + :color (etaf-theme-token :text-color) + :background-color (etaf-theme-token :surface-color) + "Themed content")) + +(etaf-mount "*etaf-theme*" (etaf-view (themed-shell))) ``` For a light/dark application palette, keep the semantic roles in one palette @@ -395,12 +462,36 @@ For a reusable installer, reserve `:install` for the cleanup-producing part of t The installer can call `etaf-current-behavior-context` when it needs the current Runtime or Host path. Replacing the Behavior runs the old cleanup before the new state becomes current. Behavior equality keeps function and reactive-value identity with `eq`; a newly-created installer closure is therefore a deliberate replacement, not an accidental reuse. The replacement is staged under the mounted resource registry and becomes authoritative only when its generation commits. -Composition rules are fixed: the Host callback runs before Behavior callbacks, -Behaviors follow declaration order, and a callback error short-circuits the -rest. For non-event attributes the Host wins, then the first Behavior wins. -Duplicate Behavior names fail before installation. Stable installers are reused -and each installed cleanup runs exactly once. Dispatch targets one exact Host; -there is no capture or bubble phase. + + +Root event forwarding is additive: the internal business handler runs first, +then extra wrapper callbacks from inner to outer, then Behaviors in declaration +order. Each declaration runs once. For a Checkbox, `:on-change` still receives +the next boolean before an added `:on-press` observer. A callback error +short-circuits the remaining callbacks; UI rollback does not undo external +business writes. Dispatch targets one exact Host, with no capture or bubble. + +Wrapper `:use` lists concatenate; duplicate Behavior names fail before any +installer runs. Non-event Behavior defaults retain first-wins order after Host +attributes, except `:disabled`, which combines with OR. Inner and outer disabled +inputs are recomputed on every update: callers can further disable a control, +and clearing the outer input enables it only when its inner input is also nil. +Disabled Hosts reject `etaf-dispatch-event` and `etaf-focus` with +`etaf-event-error`. Their input Behaviors are not installed; a committed disable +cleans up installed resources, and re-enabling installs them again. + +Hit testing selects the deepest interaction boundary before checking whether +it is enabled. Clicking a disabled cell button does not activate its parent +row, including when their bounds coincide. Ordinary non-interactive row text +can still select the row; explicitly focusing the row can activate its action. + +Migration: an extra root `:on-*` callback now appends instead of replacing the +existing action. To define a different business action, use the Component's +explicit business callback prop or define a Component with that behavior. +Fallthrough cannot change an existing `:role` or owned aria state such as +`:aria-checked` to a conflicting value; that signals a Component input error. +Expose an intentional semantic variation as a business prop. Caller-provided +`:aria-label` and `:aria-description` can still override accessible text. Use application- or feature-prefixed Action names. Duplicate Action registration is an error. During deliberate reload, wrap the replacement in @@ -423,14 +514,9 @@ Mounted buffers enable `etaf-input-mode` automatically. `TAB` focuses the next H Use Context for a dependency shared across component depth, not for ordinary props: + ```elisp -(etaf-define-component application-shell () - "Provide a service to descendants." - :setup - (let ((service (etaf-ref "demo-service"))) - (etaf-provide 'service service) - service) - :view (slot)) +(require 'etaf) (etaf-define-component service-label () "Read the inherited service." @@ -438,13 +524,28 @@ Use Context for a dependency shared across component depth, not for ordinary pro :view (text (expr (format "Service: %s" (etaf-value (etaf-state)))))) +(etaf-define-component application-shell () + "Provide a service to its own child Component." + :setup + (let ((service (etaf-ref "demo-service"))) + (etaf-provide 'service service) + service) + :view (service-label)) + (etaf-mount "*etaf-context*" - (etaf-view (application-shell (service-label)))) + (etaf-view (application-shell))) ``` Context keys are ordinary stable symbols. The nearest ancestor wins. `etaf-inject` returns its default for an optional dependency and signals `etaf-context-error` for a required missing dependency. A provided ref or computed value keeps its reactive identity. +Migration: root-authored slot content retains the root's empty Context, including +nested Components. It no longer accidentally receives the slot receiver's +providers or Theme. Put a consumer in the provider's own View, as above, or +accept an ordinary View-producing callback and call it there when the consumer +must use the provider's Context. Use slots when content should retain its +author's Context. + ## 10. Data Controllers and DataGrid Data is included in ETAF core. A source implements the small source contract: @@ -675,7 +776,7 @@ rewrite any function. | API family | Main entry points | Use it when | | --- | --- | --- | -| View and Runtime | `etaf-view`, `etaf-render`, `etaf-mount`, `etaf-unmount`, `etaf-runtime-flush` | Build, render, mount, or explicitly flush an application | +| View and Runtime | `etaf-view`, `etaf-render`, `etaf-mount`, `etaf-unmount`, `etaf-runtime-flush`, `etaf-runtime-snapshot` | Build, render, mount, flush, or explicitly export the committed application | | Components | `etaf-define-component`, `etaf-current-prop`, `etaf-current-slots`, `etaf-component-set-styles`, `etaf-component-redefine-run` | Share a View, retain local state, style an authoring surface, or deliberately reload code | | Reactive state | `etaf-ref`, `etaf-value`, `etaf-set-value`, `etaf-computed` | Store or derive state | | Reactive effects | `etaf-watch`, `etaf-watch-effect`, `etaf-effect-scope`, `etaf-scope-run` | Observe state or synchronize external resources | @@ -703,8 +804,8 @@ Most applications need only `etaf-view`, `etaf-mount`, `etaf-define-component`, - Use `etaf-ui` Components for product controls; core Hosts are the structural foundation. - Stop a Data Controller and unmount a Runtime when their owner is no longer needed. -For retained updates, keep the application pair declarative: `.etaf` contains -the static shell and the same-basename `.el` companion owns state, Components, -and actions. Reactive writes are batched into one generation publication; -failed publication is retryable, and a non-converging effect is reported rather -than allowed to keep the UI busy. +Ordinary applications define and mount Components from lexical-binding `.el` +files. The optional Playground uses inert `.etaf` structure plus an explicitly +registered `.el` companion; core does not discover or execute that pair. +Reactive writes within a batch publish one generation. Failed publication is +retryable, and a non-converging effect is reported instead of keeping the UI busy. diff --git a/docs/user-guide.zh.md b/docs/user-guide.zh.md index 665b83c..4d0d121 100644 --- a/docs/user-guide.zh.md +++ b/docs/user-guide.zh.md @@ -7,6 +7,8 @@ ETAF 使用一套很小的词汇构建文本应用:`View`、`Component`、prop ETAF 依赖独立的 Ebox 包。开发时把核心检出目录放入 `load-path`,然后只加载 ETAF 的公共入口: ```elisp +(add-to-list 'load-path "/path/to/github/ecss") +(add-to-list 'load-path "/path/to/github/tp") (add-to-list 'load-path "/path/to/github/ebox") (add-to-list 'load-path "/path/to/github/etaf") (require 'etaf) @@ -67,6 +69,33 @@ Headless host 或已经知道最终布局上下文的调用方,可以通过可 View 含有状态型 Component、响应式数据、事件或生命周期时,使用 `etaf-mount`。 +需要显式请求处理待办更新时,调用 `(etaf-runtime-flush runtime)`。 +它现在返回 **Ebox 已提交 revision 整数**,不再返回旧版的 Ebox 节点。 +Runtime 忙碌或仍处于批处理时,更新可能继续等待;返回值标识此刻读者可见的 +发布版本。活动 Ebox/TP 事务内的调用会在请求更新之前报错,因为事务中的 +revision 可能尚未提交。普通 flush 不导出整棵树,也不强制重建 Root。 + +需要当前树及其配套 source facts 时,显式获取快照: + +```elisp +(let* ((runtime (etaf-runtime-for-buffer "*etaf-hello*")) + (snapshot (etaf-runtime-snapshot runtime))) + (list (plist-get snapshot :revision) + (plist-get snapshot :mount-id) + (ebox-render (plist-get snapshot :input)))) +``` + +快照包含 `:input`(canonical Ebox input)、`:revision` 和 `:mount-id` +(本次挂载的身份)。导出成本为 O(N),会分离节点的普通可变数据;它不处理 +待办更新,不求值 Component,不发布,也不增加 revision。input 在后续提交或 +卸载后仍可使用。callback 等不透明能力保持身份,查询不会冻结这些外部能力或 +显示环境。对未挂载 Runtime 或在活动 Ebox/TP 事务内查询会报错。 + +已废弃的 `etaf-runtime-root-node` getter 通过上述 O(N) 查询保留读取兼容性, +返回当前唯一根节点;Runtime 不再保存根节点镜像。请迁移到 +`etaf-runtime-snapshot`,保留 canonical input 中与根节点配套的 source facts; +不要再把旧 getter 当作低成本字段读取或写入目标。 + 当前 core 不直接加载 `.etaf`。`etaf-define-component` 是结构/样式/行为单元:用 View 定义结构,用 `:styles` 放静态 presentation,用 `:setup` 管理 retained state、Action 和生命周期。未来 `.etaf` SFC 属于把结果编译成同一套 Component 契约的 compiler layer,而不是第二个 Runtime 入口。 ## 3. 属性与子节点 @@ -94,8 +123,9 @@ View 含有状态型 Component、响应式数据、事件或生命周期时, (etaf-view (text :font-style 'italic "Details")))))) ``` -`expr` 只接受一个普通 Elisp form,不能有结构子节点。返回值可以是字符串、typed -View、typed View proper sequence 或 `nil`。`if`、`when`、`cond`、`let`、`mapcar` 和 +`expr` 只接受一个普通 Elisp form,不能有结构子节点。在结构子节点位置,返回值 +可以是字符串、typed Host 或 Component View、这些值组成的 proper sequence 或 `nil`。 +在 `text` Host 内,表达式必须返回字符串。`if`、`when`、`cond`、`let`、`mapcar` 和 `cl-loop` 仍是普通 Elisp。 quote 只有普通 Elisp 的含义: @@ -106,6 +136,11 @@ quote 只有普通 Elisp 的含义: 例如 `'bold` 是 `:font-weight` 的 symbol 值,而 `'(text "data")` 只是数据,不会渲染。动态 View 必须写成 `(etaf-view (text "data"))`。 +间距由布局 Host 决定:`row` 与 `column` 使用 `:item-gap`,例如 +`(row :item-gap 1 ...)`;`flex` 与 `grid` 使用 `:gap`。切换 Host 时也要选择 +对应的间距属性,两者不是可互换的 alias。例如将 `(row :gap 1 ...)` 改为 +`(row :item-gap 1 ...)`。 + 核心 `grid` Host 用于二维布局: ```elisp @@ -143,20 +178,20 @@ quote 只有普通 Elisp 的含义: (status-label :label "Connected"))) ``` -Component 的规范名称可以带 `etaf-` 前缀: +调用时使用传给 `etaf-define-component` 的准确名称: ```elisp -(etaf-view (etaf-status-label :label "Connected")) +(etaf-view (status-label :label "Connected")) ``` -在 View 位置,ETAF 也会注册短 alias `status-label`。如果短名称会与 Elisp 冲突,注册表会使用以 `-view` 结尾的语义 alias。这个规则只作用于 View 名称;普通函数仍然保留前缀。 +注册表不会自动生成 alias。定义为 `etaf-status-label` 的组件必须使用这个准确名称;上面的 `status-label` 是本节自己显式定义的名称。加载 `etaf-ui` 后,官方目录使用 `etaf-button`、`etaf-checkbox` 等准确名称。 定义宏只接受这些关键字: | 关键字 | 作用 | | --- | --- | | `:view` | 声明式 View frontend,与 `:render` 互斥 | -| `:render` | 普通 Elisp frontend,通过 `etaf-node` 返回一个 typed View | +| `:render` | 普通 Elisp 返回一个 typed View,通常使用 `etaf-view`;程序化构造也可使用 `etaf-node` | | `:setup` | 可选的一次性初始化,返回由 `etaf-state` 读取的 opaque 状态 | | `:styles` | 可选的静态作用域样式声明 | @@ -170,22 +205,23 @@ Component 自己拥有状态时使用 `:setup`: (etaf-define-component counter (&key title) "Render a retained counter." :setup - (let ((count (etaf-ref 0))) + (let ((count (etaf-ref 0)) + (initial-title title)) (etaf-on-mounted - (lambda () (message "%s mounted" title))) + (lambda () (message "%s mounted" initial-title))) (etaf-on-unmounted - (lambda () (message "%s unmounted" title))) + (lambda () (message "%s unmounted" initial-title))) count) - :view - (column - (text :font-weight 'bold (expr title)) - (text (expr (format "Count: %d" (etaf-value (etaf-state))))) - (text - :role 'button - :on-press - (let ((count (etaf-state))) - (lambda () (cl-incf (etaf-value count)))) - "Increment"))) + :render + (let ((count (etaf-state)) + (caption title)) + (etaf-view + (column + (text :font-weight 'bold (expr caption)) + (text (expr (format "Count: %d" (etaf-value count)))) + (text :role 'button :tab-index 0 + :on-press (lambda () (cl-incf (etaf-value count))) + "Increment"))))) ``` Setup 对 retained instance 只执行一次,返回一个 opaque 状态值。选定的 `:view` 或 @@ -193,6 +229,12 @@ Setup 对 retained instance 只执行一次,返回一个 opaque 状态值。 `etaf-on-mounted`、`etaf-on-updated` 和 `etaf-on-unmounted` 注册该 Component 的生命周期 callback。Scope 释放时会自动停止响应式 effect 并运行 cleanup。 +`:view` 和 `:render` 共用编译、slot 投影与 prop 校验。用普通 `let`/`let*` +为回调捕获 state 句柄或当前 prop 值;`etaf-state` 只在 render 时读取,不留到 +事件触发时调用。把 `etaf-value` 保留在需要更新的属性或 `expr` 内;提取句柄 +不需要提前读取它的值。含有持久闭包的组件代码放在启用 lexical-binding 的 `.el` +文件中。简单本地回调不需要定义 Action。 + 响应式 API 只有一套模型: ```elisp @@ -255,6 +297,12 @@ callback。Scope 释放时会自动停止响应式 effect 并运行 cleanup。 默认 slot 的两个用户简写是 `(slot)` 和 `(slot FALLBACK...)`。内部统一形式是 `(slot :name 'default FALLBACK...)`。调用处的普通子节点填充 `default`;命名内容写成 `(slot :name 'header CHILD...)`。显式空的 `(slot :name 'header)` 会抑制 fallback。字符串、数字、变量和运行时表达式都不是合法 slot name。 +Slot 中作者写下的表达式在整个投影子树中保留作者的 props、state 和 Context。 +其中创建的 Component 仍有自己的 props、state、styles 和 Scope,其 Context 从 +slot 作者环境继承。接收 slot 的 Component 不会把自己的 Context 注入调用者内容。 +它自己定义的 fallback、普通子节点,以及产生 View 的 callback 使用接收方环境。 +Table/Grid 的 cell callback 同样使用消费它的 Table/Grid 所在位置的 Context。 + 在结构边界,`expr` 可以返回 typed View 或 typed View 的 proper sequence,但它不暴露、 也不接受 ETAF 私有 struct。同一个 Component 可以组合 keyed `:for`、结构表达式和 命名 footer slot: @@ -308,13 +356,22 @@ variant 需要明确覆盖,应使用显式 Host 属性或不同的属性键。 Theme 是 Context 的便捷形式,不是另一个 Runtime 对象: + ```elisp +(require 'etaf) + (etaf-define-component themed-shell () - "Provide default text colors to a subtree." + "Provide semantic colors to its own View." :setup (etaf-theme-provide - '(:color "#F4F6FB" :bgcolor "#202634")) - :view (slot)) + '(:text-color "#F4F6FB" :surface-color "#202634")) + :view + (text :ref 'themed-content + :color (etaf-theme-token :text-color) + :background-color (etaf-theme-token :surface-color) + "Themed content")) + +(etaf-mount "*etaf-theme*" (etaf-view (themed-shell))) ``` 如果应用有亮/暗两套 palette,应把语义 role 集中放在一份 palette plist 中, @@ -390,10 +447,30 @@ Behavior 用来打包可复用的非视觉属性和 cleanup: Installer 需要 Runtime 或 Host path 时,可以调用 `etaf-current-behavior-context`。Behavior 被替换时,旧 cleanup 会在新状态成为当前状态前运行。Behavior equality 对 function 和 reactive value 使用 `eq`;因此新建的 installer closure 会被视为有意替换,而不是错误复用。替换状态先以 mounted resource registry 的 staged resource 保存,generation commit 后才成为 authority。 -组合规则固定:Host callback 先于 Behavior callback,Behavior 按声明顺序运行,callback -error 会 short-circuit 剩余 callback。非事件属性先由 Host 获胜,否则由第一个 -Behavior 获胜(first-wins)。重复 Behavior name 在 installer 前失败;稳定 installer 会复用,每个 -已安装 cleanup exactly-once。dispatch 只命中准确 Host,不存在 capture 或 bubble。 + + +根事件透传采用追加规则:内部业务 handler 最先运行(first),然后是由内到外 +wrapper 附加的 callback,最后是按声明顺序运行的 Behavior。每个声明位置执行 +一次。Checkbox 的 `:on-change` 仍先收到下一个布尔值,附加的 `:on-press` +观察回调随后运行。callback 报错会 short-circuit 剩余回调;UI 回滚不会撤销外部 +业务写入。dispatch 只命中准确 Host,不存在 capture 或 bubble。 + +wrapper 的 `:use` 列表顺序连接,重复 Behavior name 在任何 installer 运行前报错。 +非事件 Behavior 默认值仍由 Host 优先、其余 first-wins;`:disabled` 则取 OR。 +每次更新都重新计算内外禁用输入:调用方可进一步禁用控件;解除外层禁用时,只有 +内部也为 nil 才能启用。禁用 Host 的 `etaf-dispatch-event` 和 `etaf-focus` 会抛出 +`etaf-event-error`。禁用时不安装输入 Behavior;提交禁用时清理已安装资源,重新启用 +时再安装。 + +命中测试先选择最深的交互边界,再检查是否启用。点击禁用 cell 按钮不会激活父行, +即使按钮与行的 bounds 相同。普通非交互行文本仍可选择该行;显式聚焦行后也可触发 +行动作。 + +迁移时注意:附加的根 `:on-*` 现在追加执行,不再覆盖原动作。需要不同业务动作时, +使用组件显式公开的业务 callback prop,或定义具有该行为的组件。透传属性不能把 +已有的 `:role` 或 `:aria-checked` 等归组件所有的 aria 状态改成冲突值,否则报 +Component 输入错误;有意提供语义变体时应公开业务 prop。调用方仍可覆盖 +`:aria-label` 与 `:aria-description` 的可访问性文字。 Action name 使用 application/feature-prefixed symbol;重复 Action 注册默认报错。显式 reload 用 `etaf-action-redefine-run` 包住替换,它只改变未来按 name 的 dispatch,不会 @@ -415,14 +492,9 @@ Focus 和 hit testing 是 Runtime 操作: Context 适合跨多层共享依赖,不适合普通 label: + ```elisp -(etaf-define-component application-shell () - "Provide a service to descendants." - :setup - (let ((service (etaf-ref "demo-service"))) - (etaf-provide 'service service) - service) - :view (slot)) +(require 'etaf) (etaf-define-component service-label () "Read the inherited service." @@ -430,13 +502,27 @@ Context 适合跨多层共享依赖,不适合普通 label: :view (text (expr (format "Service: %s" (etaf-value (etaf-state)))))) +(etaf-define-component application-shell () + "Provide a service to its own child Component." + :setup + (let ((service (etaf-ref "demo-service"))) + (etaf-provide 'service service) + service) + :view (service-label)) + (etaf-mount "*etaf-context*" - (etaf-view (application-shell (service-label)))) + (etaf-view (application-shell))) ``` Context key 是稳定的普通 symbol,最近的祖先优先。`etaf-inject` 对可选依赖返回 default,对必需但缺失的依赖触发 `etaf-context-error`。注入的 ref 或 computed 保留自身响应式 identity。 +迁移:在根位置编写的 slot 内容保留根的空 Context,其中的嵌套 Component 也 +不会意外接收 slot 接收方的 provider 或 Theme。若消费者需要 provider 的 +Context,应像上例一样写在 provider 自己的 View 中;需要外部定制时,可接收 +普通的 View-producing callback 并在该位置调用。内容需要保留作者 Context 时 +使用 slot。 + ## 10. Data Controller 与 DataGrid Data 已经是 ETAF core 能力。Data Source 实现一个小的 source 契约: @@ -657,7 +743,7 @@ observer,不修改任何函数。 | API 家族 | 主要入口 | 何时使用 | | --- | --- | --- | -| View 与 Runtime | `etaf-view`、`etaf-render`、`etaf-mount`、`etaf-unmount`、`etaf-runtime-flush` | 构建、渲染、挂载或显式 flush 应用 | +| View 与 Runtime | `etaf-view`、`etaf-render`、`etaf-mount`、`etaf-unmount`、`etaf-runtime-flush`、`etaf-runtime-snapshot` | 构建、渲染、挂载、flush 或显式导出已提交应用 | | Component | `etaf-define-component`、`etaf-current-prop`、`etaf-current-slots`、`etaf-component-set-styles`、`etaf-component-redefine-run` | 复用 View、保留局部状态、设置 authoring 样式或显式重载代码 | | 响应式状态 | `etaf-ref`、`etaf-value`、`etaf-set-value`、`etaf-computed` | 保存或派生状态 | | 响应式 effect | `etaf-watch`、`etaf-watch-effect`、`etaf-effect-scope`、`etaf-scope-run` | 观察状态或同步外部资源 | @@ -685,7 +771,7 @@ observer,不修改任何函数。 - 产品级控件使用 `etaf-ui` Component;core Host 只是结构基础。 - owner 不再需要时,停止 Data Controller 并卸载 Runtime。 -对于保留式更新,建议保持 pair 结构:`.etaf` 只放静态 shell,同名 `.el` companion -负责 state、Component 和 action。响应式写入会合并为一次 generation publication; -发布失败可以在同一旧状态上重试,non-converging effect 会报告错误,不会让界面 -持续占用事件循环。 +普通应用在启用 lexical-binding 的 `.el` 文件中定义并挂载 Component。可选的 +Playground 使用 inert `.etaf` 结构与显式注册的 `.el` companion;core 不会自动 +发现或执行这组文件。同一 batch 内的响应式写入合并为一次 generation publication; +发布失败可以重试,non-converging effect 会报告错误,不会持续占用事件循环。 diff --git a/etaf-component.el b/etaf-component.el index a48b8b2..5b7c7ef 100644 --- a/etaf-component.el +++ b/etaf-component.el @@ -4,13 +4,14 @@ ;;; Commentary: -;; Components have one public definition boundary with two strict authoring +;; Components have one public definition boundary with two authoring ;; frontends: compiled `:view' DSL and ordinary Elisp `:render'. Optional ;; `:setup' runs once and returns opaque state read through `etaf-state'. ;;; Code: (require 'cl-lib) +(require 'macroexp) (require 'etaf-view) (require 'etaf-compiler) @@ -229,8 +230,11 @@ or: :styles (styles (SELECTOR ATTR ...))) `:setup' runs once per retained identity and returns opaque state. `:view' -is unquoted DSL; `:render' is ordinary Elisp and constructs nodes with -`etaf-node'." +is unquoted DSL; `:render' is ordinary Elisp returning the same typed View, +usually through `etaf-view'. `etaf-node' also constructs Views +programmatically. Both frontends use the same prop and slot rules. Bind state +handles and prop snapshots to lexical locals for later event callbacks; keep +reactive value reads inside the View properties or expressions they update." (declare (indent 2) (debug defun)) (unless (symbolp name) (etaf--component-definition-error @@ -292,10 +296,6 @@ is unquoted DSL; `:render' is ordinary Elisp and constructs nodes with (memq (car setup-form) '(lambda function))) (etaf--component-definition-error "Component %S :setup cannot return a render function" name)) - (when (and saw-render - (etaf--component-form-contains-head-p render-form '(etaf-view))) - (etaf--component-definition-error - "Component %S :render cannot embed the DSL frontend" name)) (let* ((props (etaf--parse-component-props arguments)) (styles-form (etaf--validate-styles-form styles-form name)) (definition-symbol @@ -305,13 +305,18 @@ is unquoted DSL; `:render' is ordinary Elisp and constructs nodes with (let ((etaf--current-component-props etaf--component-props) (etaf--current-component-slots etaf--component-slots) (etaf--component-phase 'render)) - (cl-symbol-macrolet - ,(etaf--component-prop-symbol-macros props) - ,(if saw-view - (let ((etaf--compiling-component-props props)) - (etaf-compiler-expand-view - view-form :projection)) - render-form))))) + ,(let ((etaf--compiling-component-props props)) + ;; Expand every embedded View inside its Component prop + ;; grammar, including Views produced by lexical macros. + ;; Include the prop bindings so they shadow outer symbol + ;; macros while ordinary let/lambda shadowing is preserved. + (macroexpand-all + `(cl-symbol-macrolet + ,(etaf--component-prop-symbol-macros props) + ,(if saw-view + (etaf-compiler-expand-view view-form :projection) + render-form)) + macroexpand-all-environment))))) (setup-lambda (when saw-setup `(lambda (etaf--component-props _etaf--component-slots) diff --git a/etaf-events.el b/etaf-events.el index 884875e..b6df4c8 100644 --- a/etaf-events.el +++ b/etaf-events.el @@ -27,6 +27,7 @@ (declare-function etaf-runtime-handler-entries "etaf-runtime" (runtime)) (declare-function etaf-runtime-host-props-for "etaf-runtime" (runtime host-ref)) (declare-function etaf-runtime-host-props-entries "etaf-runtime" (runtime)) +(declare-function etaf-runtime-host-ancestries "etaf-runtime" (runtime host-refs)) (declare-function etaf-runtime-focus-ref "etaf-runtime" (runtime)) (declare-function etaf-runtime-set-focus-ref "etaf-runtime" (runtime host-ref)) (declare-function etaf-runtime-event-begin "etaf-runtime" (runtime)) @@ -71,6 +72,11 @@ otherwise call the local callback with no arguments." (setq runtime (etaf-runtime-require-mounted runtime)) (let* ((dispatch (lambda () + (when (plist-get (etaf-runtime-host-props-for runtime host-ref) + :disabled) + (signal 'etaf-event-error + (list (format "Cannot dispatch to disabled Host reference: %S" + host-ref)))) (let ((callback (etaf--event-handler runtime host-ref kind))) (unless callback (signal 'etaf-event-error @@ -119,51 +125,81 @@ otherwise call the local callback with no arguments." (t nil))) (defun etaf--activation-candidate-before-p (left right) - "Return non-nil when activation candidate LEFT precedes RIGHT." + "Return non-nil when hit candidate LEFT is inside or smaller than RIGHT." (let ((left-length (nth 2 left)) (right-length (nth 2 right)) (left-start (nth 1 left)) - (right-start (nth 1 right))) - (or (< left-length right-length) - (and (= left-length right-length) - (or (< left-start right-start) - (and (= left-start right-start) - (string< (prin1-to-string (car left)) - (prin1-to-string (car right))))))))) + (right-start (nth 1 right)) + (left-lineage (nth 3 left)) + (right-lineage (nth 3 right))) + (cond + ((and right-lineage (memq (car right-lineage) left-lineage)) t) + ((and left-lineage (memq (car left-lineage) right-lineage)) nil) + (t (or (< left-length right-length) + (and (= left-length right-length) + (< left-start right-start))))))) + +(defun etaf--interaction-boundary-p (props) + "Return non-nil when committed PROPS describe an interaction boundary. +Disabled and callbackless controls still own their hit area. Ordinary text +with only a reference or accessibility label remains part of its parent." + (or (functionp (plist-get props :on-press)) + (plist-get props :disabled) + (numberp (plist-get props :tab-index)) + (member (let ((role (plist-get props :role))) + (if (symbolp role) (symbol-name role) role)) + '("button" "checkbox" "combobox" "link" "menuitem" "option" + "radio" "slider" "spinbutton" "switch" "tab" "textbox" + "treeitem")))) (defun etaf--activation-at-position (runtime position &optional quiet) - "Activate the smallest enabled Host at POSITION in RUNTIME. + "Activate the deepest interaction boundary at POSITION in RUNTIME. When QUIET is non-nil, return nil instead of signaling when no callback owns the position." (let (candidates) - (dolist (entry (etaf-runtime-handler-entries runtime)) - (let ((host-ref (car entry)) (handlers (cdr entry))) - (let* ((press (assq 'press handlers)) - (props (etaf-runtime-host-props-for runtime host-ref)) - (bounds (ebox-host-ref-bounds - (etaf-runtime-buffer runtime) host-ref)) - (start (and bounds (car bounds))) - (end (and bounds (cdr bounds)))) - (when (and press (not (plist-get props :disabled)) - start end (<= start position) (< position end)) - (push (list host-ref start (- end start)) candidates))))) - (setq candidates (sort candidates #'etaf--activation-candidate-before-p)) - (if-let* ((candidate (car candidates))) - (etaf-dispatch-event runtime (car candidate) 'press) + (dolist (entry (etaf-runtime-host-props-entries runtime)) + (let ((host-ref (car entry)) (props (cdr entry))) + (when (etaf--interaction-boundary-p props) + (let* ((bounds (ebox-host-ref-bounds + (etaf-runtime-buffer runtime) host-ref)) + (start (and bounds (car bounds))) + (end (and bounds (cdr bounds)))) + (when (and start end (<= start position) (< position end)) + (push (list host-ref start (- end start)) candidates)))))) + (when (cdr candidates) + (let ((ancestries + (etaf-runtime-host-ancestries runtime (mapcar #'car candidates)))) + (dolist (candidate candidates) + (setcdr (last candidate) (list (gethash (car candidate) ancestries))))) + (setq candidates (cl-stable-sort + candidates #'etaf--activation-candidate-before-p))) + (if-let* ((candidate (car candidates)) + (ref (car candidate)) + ((not (plist-get (etaf-runtime-host-props-for runtime ref) + :disabled))) + ((etaf--event-handler runtime ref 'press))) + (etaf-dispatch-event runtime ref 'press) (unless quiet (user-error "No interactive ETAF Host at point"))))) ;;;###autoload (defun etaf-activate (&optional runtime) - "Dispatch `press' for the smallest enabled Host containing point. + "Dispatch `press' for the focused Host, or the interaction boundary at point. RUNTIME is the mounted Runtime to activate, or nil for the current buffer." (interactive) (setq runtime (etaf-runtime-require-mounted runtime)) - (let ((position (with-current-buffer (etaf-runtime-buffer runtime) - (point)))) - (etaf--activation-at-position runtime position))) + (let ((position (with-current-buffer (etaf-runtime-buffer runtime) (point))) + (focus-ref (etaf-runtime-focus-ref runtime))) + (if (and focus-ref + (equal position (etaf-host-ref-position runtime focus-ref))) + (if (and (not (plist-get + (etaf-runtime-host-props-for runtime focus-ref) :disabled)) + (etaf--event-handler runtime focus-ref 'press)) + (etaf-dispatch-event runtime focus-ref 'press) + (user-error "No interactive ETAF Host at point")) + (etaf--activation-at-position runtime position)))) (defun etaf--focus-candidate-before-p (left right) "Return non-nil when focus candidate LEFT precedes RIGHT." @@ -191,6 +227,30 @@ RUNTIME is the mounted Runtime to activate, or nil for the current buffer." candidates)))))) (sort candidates #'etaf--focus-candidate-before-p))) +(defun etaf-events-call-with-preserved-focus (runtime function) + "Call FUNCTION while retaining RUNTIME's active focus through publication. +Follow a Host's new position only when point started at that focused Host, +the same focus survives, and a new generation actually committed. Manual +point movement and failed candidate publication keep their existing behavior." + (let* ((buffer (etaf-runtime-buffer runtime)) + (focus-ref (etaf-runtime-focus-ref runtime)) + (follow-p + (and focus-ref (buffer-live-p buffer) + (equal (with-current-buffer buffer (point)) + (ebox-host-ref-position buffer focus-ref))))) + (if (not follow-p) + (funcall function) + (let ((generation (etaf-runtime-current-generation runtime))) + (unwind-protect + (funcall function) + (when (and (etaf-runtime-mounted-p runtime) + (buffer-live-p buffer) + (equal focus-ref (etaf-runtime-focus-ref runtime)) + (not (eq generation + (etaf-runtime-current-generation runtime)))) + (when-let* ((position (ebox-host-ref-position buffer focus-ref))) + (with-current-buffer buffer (goto-char position))))))))) + ;;;###autoload (defun etaf-focus (&optional runtime host-ref) "Move focus to HOST-REF in mounted RUNTIME and move point to its position. @@ -202,6 +262,9 @@ When called interactively without arguments, focus the first visible Host." (setq host-ref (nth 3 (car (etaf--focus-candidates runtime)))) (unless host-ref (user-error "No focusable ETAF Host"))) + (when (plist-get (etaf-runtime-host-props-for runtime host-ref) :disabled) + (signal 'etaf-event-error + (list (format "Cannot focus disabled Host reference: %S" host-ref)))) (let ((position (etaf-host-ref-position runtime host-ref))) (unless position (signal 'etaf-event-error diff --git a/etaf-render-port.el b/etaf-render-port.el index f3680e1..6345872 100644 --- a/etaf-render-port.el +++ b/etaf-render-port.el @@ -63,7 +63,8 @@ because it contains v2; ETAF never dispatches through its v1 capability.") (update-function nil :read-only t) (revision-function nil :read-only t) (bootstrap-outcome nil :read-only t) - (provider nil :read-only t)) + (provider nil :read-only t) + (snapshot-function nil :read-only t)) (defun etaf-render-port-route (port) "Return selected PORT route, always `v2'." @@ -94,9 +95,13 @@ because it contains v2; ETAF never dispatches through its v1 capability.") (etaf-render-port--update-function port)) (defun etaf-render-port-revision-function (port) - "Return PORT's committed-revision query function symbol." + "Return PORT's current-revision query function symbol." (etaf-render-port--revision-function port)) +(defun etaf-render-port-snapshot-function (port) + "Return PORT's explicit committed-snapshot query function symbol." + (etaf-render-port--snapshot-function port)) + (defun etaf-render-port-bootstrap-outcome (port) "Return PORT's immutable bootstrap outcome tag." (etaf-render-port--bootstrap-outcome port)) @@ -107,7 +112,7 @@ because it contains v2; ETAF never dispatches through its v1 capability.") (list :reason reason :detail detail))) (defun etaf-render-port--ensure-accessors () - "Require every public Ebox v2 record accessor before reading a provider." + "Require Ebox's public v2 accessors and explicit snapshot query." (dolist (function '(ebox-framework-spi-provider-p @@ -126,7 +131,8 @@ because it contains v2; ETAF never dispatches through its v1 capability.") ebox-framework-spi-operation-argument-schema ebox-framework-spi-operation-result-schema ebox-framework-spi-operation-paired-stage-rollback-p - ebox-framework-spi-initial-observation-reports)) + ebox-framework-spi-initial-observation-reports + ebox-surface-buffer-snapshot)) (unless (fboundp function) (etaf-render-port--bootstrap-error 'missing-provider-accessor function)))) @@ -272,6 +278,7 @@ because it contains v2; ETAF never dispatches through its v1 capability.") :update-function (plist-get (plist-get snapshot :update) :function) :revision-function 'ebox-surface-buffer-revision + :snapshot-function 'ebox-surface-buffer-snapshot :bootstrap-outcome 'valid-v2-selected :provider (plist-get snapshot :provider)))) @@ -338,7 +345,9 @@ FRAMEWORK-STAGE and FRAMEWORK-ROLLBACK are one required callback pair." (ebox-surface-buffer-mounted-p buffer)) (defun etaf-render-port-revision (buffer) - "Return BUFFER's committed Ebox revision, or zero when it is unmounted." + "Return BUFFER's current Ebox revision, or zero when it is unmounted. +During an active TP transaction this can be a provisional revision for the +paired publication stage. Public committed queries must exclude that extent." (let ((buffer (get-buffer buffer))) (if (not (and (buffer-live-p buffer) (ebox-surface-buffer-mounted-p buffer))) @@ -353,6 +362,23 @@ FRAMEWORK-STAGE and FRAMEWORK-ROLLBACK are one required callback pair." revision)) revision)))) +(defun etaf-render-port-snapshot (buffer) + "Export BUFFER's committed canonical input, revision, and mount identity. +Ebox owns this explicit O(N) detached export and rejects unavailable or +transactional reads. The port validates only its envelope and never copies, +publishes, or queries private renderer state." + (let ((snapshot + (funcall + (etaf-render-port-snapshot-function etaf-render-port--selected-port) + buffer))) + (unless (and (proper-list-p snapshot) + (ebox-canonical-input-p (plist-get snapshot :input)) + (integerp (plist-get snapshot :revision)) + (> (plist-get snapshot :revision) 0) + (integerp (plist-get snapshot :mount-id))) + (error "Malformed Ebox committed snapshot")) + snapshot)) + (provide 'etaf-render-port) ;;; etaf-render-port.el ends here diff --git a/etaf-renderer.el b/etaf-renderer.el index 929ac27..f3810c7 100644 --- a/etaf-renderer.el +++ b/etaf-renderer.el @@ -117,6 +117,21 @@ (defvar etaf--rendering-range-p nil "Non-nil while eagerly lowering descendants of one Range item Host.") +(defvar etaf--render-parent-path nil + "Structural path of the current retained semantic parent.") + +(defvar etaf--render-site-counts nil + "Occurrence counts distinguishing reused compiled sites under each parent.") + +(defun etaf--render-site-token (token) + "Qualify repeated TOKEN occurrences within their mounted semantic parent." + (if (or (null token) (null etaf--render-site-counts)) + token + (let* ((key (list etaf--current-semantic-parent-id token)) + (index (gethash key etaf--render-site-counts 0))) + (puthash key (1+ index) etaf--render-site-counts) + (if (zerop index) token (list :site token :occurrence index))))) + (defvar etaf--ebox-source-builder nil "Source builder owned by the current ETAF lowering boundary.") @@ -125,10 +140,8 @@ (unless etaf--ebox-source-builder (signal 'etaf-renderer-error (list "Canonical Ebox input escaped its lowering boundary"))) - (ebox-source-builder-import - etaf--ebox-source-builder - (ebox-canonical-input--source-index input)) - (copy-sequence (ebox-canonical-input--nodes input))) + (ebox-canonical-input-import-roots + input (ebox-canonical-input-roots input) etaf--ebox-source-builder)) (defun etaf--ebox-input-for-nodes (nodes) "Snapshot current source facts for canonical forest NODES." @@ -143,12 +156,11 @@ "Return PROPS' explicit Host reference or one generated for PATH. SITE-TOKEN replaces PATH as the generated call-site identity when non-nil." (or (plist-get props :ref) - (let ((site (or site-token (copy-sequence path)))) + (let ((site (or (and (plist-get props :key) + (list :key (plist-get props :key))) + site-token (copy-sequence path)))) (list 'etaf-host (cond - (etaf--rendering-range-p - (list :range etaf--current-semantic-parent-id - :site site)) (etaf--render-runtime (list :parent etaf--current-semantic-parent-id :site site)) (t @@ -267,12 +279,18 @@ tokens, not Ebox Host properties, and therefore are not materialized here." (props (copy-sequence (etaf--resolve-property-plist (etaf--view-node-props node)))) - (template (etaf--theme-host-defaults defaults name))) + (template (etaf--theme-host-defaults defaults name)) + (present (make-hash-table :test #'eq))) + (cl-loop for (key value) on props by #'cddr + when value do + (puthash (etaf--property-domain-key key) t present)) (while template (let ((key (pop template)) (value (pop template))) - (unless (plist-get props key) - (setq props (etaf--merge-property props key value))))) + (unless (gethash (etaf--property-domain-key key) present) + (setq props (etaf--merge-property props key value)) + (when value + (puthash (etaf--property-domain-key key) t present))))) (etaf--view-node-create :name name :token (etaf--view-node-token node) @@ -494,11 +512,11 @@ SITE-TOKEN supplies the stable generated Host identity when non-nil." :class (plist-get props :class) :declarations declarations :provenance (list :adapter 'etaf-renderer :tag tag)))) - (let ((ebox-canonical--source-builder etaf--ebox-source-builder)) - (ebox-box-create - :layout layout :outer outer :children children - :owned-facts (ebox-canonical-facts-from-declarations tag declarations) - :source-handle source-handle)))) + (ebox-box-create + :layout layout :outer outer :children children + :source-builder etaf--ebox-source-builder + :owned-facts (ebox-canonical-facts-from-declarations tag declarations) + :source-handle source-handle))) (defun etaf--ebox-forest-root (nodes source-identity) "Return one canonical backend root for ordered forest NODES. @@ -624,12 +642,27 @@ multi-root forest; a single material root is returned unchanged." (defun etaf--render-value-list (value path) "Render VALUE at structural PATH into a list of Ebox nodes." (let* ((items (etaf--flatten-view-value value)) - (multiple-p (> (length items) 1))) + (multiple-p (or (proper-list-p value) (> (length items) 1))) + (etaf--render-site-counts + (or etaf--render-site-counts (make-hash-table :test #'equal))) + (keys (make-hash-table :test #'equal))) + ;; Validate the entire sibling set before any Component setup runs. + (dolist (item items) + (let* ((props (cond ((etaf--view-node-p item) + (etaf--view-node-props item)) + ((etaf--component-call-p item) + (etaf--component-call-props item)))) + (key (etaf--resolve-property-value (plist-get props :key)))) + (when key + (when (gethash key keys) + (signal (if etaf--render-runtime + 'etaf-runtime-error 'etaf-renderer-error) + (list (format "View at %S has duplicate sibling key: %S" + path key)))) + (puthash key t keys)))) (cl-loop for item in items for index from 0 - for item-path = (if multiple-p - (append path (list index)) - path) + for item-path = (if multiple-p (append path (list index)) path) append (cond ((stringp item) @@ -670,7 +703,11 @@ multi-root forest; a single material root is returned unchanged." (defun etaf--render-node (node path) "Render normalized Host NODE at structural PATH." - (let* ((node (if (and etaf--render-runtime + (let* ((node (let ((copy (copy-sequence node))) + (setf (etaf--view-node-token copy) + (etaf--render-site-token (etaf--view-node-token node))) + copy)) + (node (if (and etaf--render-runtime (fboundp 'etaf--runtime-behavior-node)) (etaf--runtime-behavior-node etaf--render-runtime node path) @@ -736,6 +773,7 @@ multi-root forest; a single material root is returned unchanged." (etaf--render-value-list children (append path (list :fragment)))) ((or 'box 'row 'column 'flex 'grid) (let ((nodes nil) + (etaf--render-parent-path path) (index 0) (range-child-p nil) (semantic-id @@ -819,7 +857,7 @@ RANGE-CHILD-P preserves the direct material Range parent." ((or 'box 'row 'column 'flex 'grid) (etaf--layout-node name props children range-child-p)) (_ (signal 'etaf-renderer-error - (list (format "Semantic Host requires Step4b lowering: %S" + (list (format "Expected text, box, row, column, flex, or grid Host; received %S" name)))))) ;;;###autoload diff --git a/etaf-runtime.el b/etaf-runtime.el index 7bbab59..f330a55 100644 --- a/etaf-runtime.el +++ b/etaf-runtime.el @@ -39,6 +39,8 @@ (declare-function etaf--ebox-input-for-nodes "etaf-renderer" (nodes)) (declare-function etaf-events-enable-input "etaf-events" (buffer)) (declare-function etaf-events-disable-input "etaf-events" (buffer)) +(declare-function etaf-events-call-with-preserved-focus + "etaf-events" (runtime function)) (declare-function ebox-range-ref-present-p "ebox" (buffer-or-name range-ref)) (declare-function ebox-call-with-render-burst "ebox-buffer-backend" (function &rest arguments)) @@ -47,6 +49,7 @@ (declare-function tp-paint-slot-spec "tp-style" (slot)) (declare-function tp-paint-slot-apply-updates "tp-style" (buffer updates)) (declare-function tp-paint-slot-rollback-updates "tp-style" (journal)) +(declare-function tp-transaction-active-p "tp-reactive" ()) (defvar etaf--render-runtime) (defvar etaf--render-style-stack) (defvar etaf--render-parent-style-stack) @@ -89,8 +92,17 @@ (cl-defstruct (etaf--semantic-host (:constructor etaf--semantic-host-create)) semantic-id identity parent-id component-id child-ids host-ref key name effect-id property-bindings theme-bindings deps context-deps - base-props props-signature content content-parts path site-token style-identity - (composition-version 0)) + ;; Host inputs after Behavior composition and expression resolution, before + ;; scoped style and Theme resolution. + ;; Reactive expressions are retained separately in property-bindings. + base-props + ;; Canonical Ebox declaration projection used to compare backend updates. + props-signature content content-parts path site-token style-identity + (composition-version 0) + ;; Complete committed Host inputs after resolution/augmentation, before + ;; lowering to Ebox. Theme-only updates patch this projection; they must not + ;; rebuild it from the sparse public Host-props index or append Behaviors again. + resolved-props) (cl-defstruct (etaf--semantic-range (:constructor etaf--semantic-range-create)) semantic-id identity effect-id kind parent-id component-id token range-ref @@ -275,7 +287,9 @@ the sequential `etaf--pvec-put' contract." "Mounted ETAF application runtime." buffer root-view - root-node + ;; Retain the old root-node offset for already mounted Runtime records. + ;; This reserved slot has no current-root authority and is never read/written. + reserved-root-node scope scheduler-context root-effect-id @@ -623,6 +637,35 @@ RESOLVED is the current projected paint value." (copy-tree (etaf--generation-index-entries (etaf-runtime-current-generation runtime) 'host-props))) +(defun etaf-runtime-host-ancestries (runtime host-refs) + "Return committed semantic ancestries for HOST-REFS in RUNTIME. +This activation-local lookup visits the retained graph at most once, stopping +when all requested Hosts are found. Callers use it only for overlapping hit +candidates; no second retained identity or layout index is maintained." + (let* ((generation (etaf-runtime-current-generation runtime)) + (wanted (make-hash-table :test #'equal)) + (result (make-hash-table :test #'equal))) + (dolist (ref host-refs) (puthash ref t wanted)) + (catch 'complete + (cl-labels + ((visit + (id ancestors) + (let* ((semantic (etaf--pvec-get + (etaf-generation-semantic-nodes generation) id)) + (lineage (cons id ancestors))) + (when (etaf--semantic-host-p semantic) + (let ((ref (etaf--semantic-host-host-ref semantic))) + (when (gethash ref wanted) + (puthash ref lineage result) + (remhash ref wanted) + (when (zerop (hash-table-count wanted)) + (throw 'complete nil))))) + (dolist (child (etaf--semantic-child-ids semantic)) + (visit child lineage))))) + (when (and generation host-refs) + (visit (etaf-generation-root-semantic-id generation) nil)))) + result)) + (defun etaf--runtime-generation-mirror-projection (generation) "Return exact compatibility mirrors projected from GENERATION." (etaf-generation-project-mirrors @@ -702,7 +745,8 @@ FULL-P preserves the old route's full-root replacement distinction." "Build RUNTIME immutable generation contributions over BASE. FULL-P means candidate tables describe the complete mounted tree." (let (handler-additions prop-additions context-additions theme-additions - behavior-membership context-consumer-additions lifecycle-membership + behavior-membership behavior-removals + context-consumer-additions lifecycle-membership changed-component-ids) (maphash (lambda (key value) (push (cons (copy-tree key) (copy-tree value)) @@ -807,6 +851,11 @@ FULL-P means candidate tables describe the complete mounted tree." (etaf-behavior-spec-name (car state))))) behavior-membership)) (etaf-runtime-candidate-behaviors runtime)) + (maphash + (lambda (identity _state) + (unless (gethash identity (etaf-runtime-candidate-behaviors runtime)) + (push (copy-tree identity) behavior-removals))) + (etaf-runtime-behaviors runtime)) (dolist (identity (cl-delete-duplicates (append (copy-sequence @@ -834,7 +883,7 @@ FULL-P means candidate tables describe the complete mounted tree." (gethash host-ref (etaf-runtime-candidate-host-props runtime))) (copy-sequence (etaf-runtime-candidate-removed-host-refs runtime))) :semantic-removals - (append changed-component-ids + (append changed-component-ids behavior-removals (copy-sequence (etaf-runtime-candidate-removed-semantic-ids runtime))) :depth (1+ (if base-index @@ -967,52 +1016,19 @@ owning Component node." (etaf--semantic-inline-range-component-id semantic)))) (t nil))))) -(defun etaf--runtime-range-owned-by-rendered-component-p - (runtime generation semantic) - "Return non-nil when SEMANTIC is subsumed by a rendered Component. - -RUNTIME's candidate already contains the Component's freshly lowered output -from committed GENERATION in that case, so evaluating the old descendant Range -again would duplicate work and later be discarded before publication. -Root-owned Ranges intentionally return nil because they have no material -Component owner to absorb them." - (let* ((component-id - (cond - ((etaf--semantic-range-p semantic) - (etaf--semantic-range-component-id semantic)) - ((etaf--semantic-slot-range-p semantic) - (etaf--semantic-slot-range-consumer-component-id semantic)) - ((etaf--semantic-inline-range-p semantic) - (etaf--semantic-inline-range-component-id semantic)))) - (component (and component-id - (etaf--pvec-get - (etaf-generation-semantic-nodes generation) - component-id))) - (effect-id (cond - ((etaf--semantic-range-p semantic) - (etaf--semantic-range-effect-id semantic)) - ((etaf--semantic-slot-range-p semantic) - (etaf--semantic-slot-range-effect-id semantic)) - ((etaf--semantic-inline-range-p semantic) - (etaf--semantic-inline-range-effect-id semantic)))) - (semantic-id (cond - ((etaf--semantic-range-p semantic) - (etaf--semantic-range-semantic-id semantic)) - ((etaf--semantic-slot-range-p semantic) - (etaf--semantic-slot-range-semantic-id semantic)) - ((etaf--semantic-inline-range-p semantic) - (etaf--semantic-inline-range-semantic-id semantic)))) - (candidate-effects (etaf-runtime-candidate-effects runtime)) - (candidate-nodes (etaf-runtime-candidate-graph-nodes runtime))) - (and (etaf--semantic-component-p component) - (member (etaf--semantic-component-identity component) - (etaf-runtime-candidate-rendered-identities runtime)) - ;; A Component render only subsumes the old Range once it actually - ;; staged the replacement Range/effect. Context-owned direct Ranges - ;; can remain outside the Component render and must still evaluate - ;; to refresh their Context dependency edges. - (gethash effect-id candidate-effects) - (gethash semantic-id candidate-nodes)))) +(defun etaf--runtime-range-effect-staged-p (runtime generation effect) + "Return non-nil when EFFECT was already recomputed in RUNTIME's candidate. +An ancestor Component or Range can lower this descendant before its queued +committed effect is visited. Fresh lowering stages a new effect record; +carrying an unchanged subtree keeps the exact record from GENERATION and +must not absorb that subtree's independently dirty work." + (let* ((effect-id (etaf--generation-effect-effect-id effect)) + (candidate (gethash effect-id + (etaf-runtime-candidate-effects runtime)))) + (and candidate + (not (eq candidate (etaf--generation-effect generation effect-id))) + (gethash (etaf--generation-effect-semantic-id effect) + (etaf-runtime-candidate-graph-nodes runtime))))) (defun etaf--generation-source-effects (generation source) "Return current effect ids for SOURCE in GENERATION." @@ -1629,18 +1645,21 @@ reading Runtime storage fields." (or (plist-get env :identity) (and component (etaf--semantic-component-identity component)))) (props - (or (plist-get env :props) - (and component (etaf--semantic-component-props component)))) + (if (plist-member env :props) + (plist-get env :props) + (and component (etaf--semantic-component-props component)))) (slots - (or (plist-get env :slots) - (and component (etaf--semantic-component-slots component))))) + (if (plist-member env :slots) + (plist-get env :slots) + (and component (etaf--semantic-component-slots component))))) (if (null component-id) (let ((etaf--current-runtime runtime) (etaf--current-component-instance nil) (etaf--current-component-identity nil) (etaf--current-component-semantic-id nil) (etaf--current-component-props nil) - (etaf--current-component-slots nil)) + (etaf--current-component-slots nil) + (etaf--current-context nil)) (funcall function)) (let ((etaf--current-runtime runtime) (etaf--current-component-instance instance) @@ -1898,10 +1917,12 @@ receive an independent Host effect." (append property-deps theme-deps)) :context-deps property-context-deps :base-props base-props + :resolved-props props :site-token site-token :props-signature backend-props :path path :style-identity etaf--render-style-stack))) + (etaf--runtime-register-host-behaviors runtime record) (unless etaf--rendering-range-p (puthash identity semantic-id (etaf-runtime-candidate-identity-entries runtime))) @@ -2004,20 +2025,6 @@ receive an independent Host effect." (etaf-behavior-spec-attributes left) (etaf-behavior-spec-attributes right)))) -(defun etaf--compose-event-callbacks (primary secondary) - "Compose two event callbacks in declaration order. -PRIMARY is the explicit Host callback and SECONDARY comes from a Behavior. -The composition belongs to the Runtime event layer, so UI Components do not -need to know how Behavior attributes are merged." - (cond - ((not (functionp primary)) secondary) - ((not (functionp secondary)) primary) - (t - (lambda (&rest arguments) - (prog1 - (apply primary arguments) - (apply secondary arguments)))))) - (defun etaf--runtime-install-behavior (runtime spec path props) "Install Behavior SPEC for RUNTIME at PATH and return its state." (let ((install (etaf-behavior-spec-install spec)) @@ -2034,51 +2041,141 @@ need to know how Behavior attributes are merged." (cons spec cleanup))) (defun etaf--runtime-behavior-node (runtime node path) - "Install or update NODE's `:use' Behaviors in RUNTIME and return it merged." + "Resolve NODE's `:use' properties before Host registration in RUNTIME at PATH. +Installers run only after semantic Host identity and final props are known." + (ignore runtime path) (if (not (plist-member (etaf--view-node-props node) :use)) node - (let* ((props (etaf--resolve-property-plist - (etaf--view-node-props node))) - (specs (etaf--runtime-behavior-specs (plist-get props :use))) - (merged (copy-sequence props))) - (dolist (spec specs) - (let* ((identity (list path (etaf-behavior-spec-name spec))) - (old (gethash identity (etaf-runtime-behaviors runtime))) - (same-p (and old - (etaf--runtime-behavior-spec-equal-p - (car old) spec))) - (state (if same-p - old - (etaf--runtime-install-behavior runtime spec path props))) - (resource-key - (if same-p - (and (hash-table-p - (etaf-runtime-behavior-resource-keys runtime)) - (gethash identity - (etaf-runtime-behavior-resource-keys runtime))) - (cons (etaf-runtime-mount-epoch runtime) - (cl-incf (etaf-runtime-next-resource-id runtime)))))) - (puthash identity state (etaf-runtime-candidate-behaviors runtime)) - (puthash identity resource-key - (etaf-runtime-candidate-behavior-resource-keys runtime)) - (let ((attributes (etaf-behavior-spec-attributes (car state)))) - (while attributes - (let ((key (pop attributes)) - (value (pop attributes))) - (if (and (keywordp key) - (string-prefix-p ":on-" (symbol-name key)) - (plist-member merged key)) + (cl-flet ((resolve-semantics + (properties) + (cl-loop for (key value) on properties by #'cddr + append + (list key + (if (etaf--runtime-host-property-effect-p key) + value + (etaf--resolve-property-value value)))))) + (let* ((props (resolve-semantics + (etaf--view-node-props node))) + (specs (etaf--runtime-behavior-specs (plist-get props :use))) + (merged (copy-sequence props))) + ;; Retain resolved specifications as the Host's semantic input. Later + ;; retirement can compare their names without calling constructors again. + (setq merged (plist-put merged :use specs)) + ;; Resolve semantic input before any installer can observe it. Keep + ;; Ebox/class expressions for ordinary Host property binding extraction. + ;; In particular a later Behavior can disable an earlier one, and an + ;; invalid property must fail without acquiring candidate resources. + (dolist (spec specs) + (let ((attributes (resolve-semantics + (etaf-behavior-spec-attributes spec)))) + (etaf--validate-semantic-properties attributes) + (while attributes + (let ((key (pop attributes)) (value (pop attributes))) + (cond + ((or (etaf--event-property-p key) (eq key :disabled) + (etaf--owned-semantic-property-p key)) (setq merged - (plist-put - merged key - (etaf--compose-event-callbacks - (plist-get merged key) value))) - (unless (plist-member merged key) - (setq merged (append merged (list key value)))))))))) - (etaf--view-node-create - :name (etaf--view-node-name node) - :props merged - :children (etaf--view-node-children node))))) + (etaf--merge-host-attrs + merged (list key value) (etaf--view-node-name node) + (etaf-behavior-spec-name spec)))) + ((not (plist-member merged key)) + (setq merged (append merged (list key value))))))))) + (etaf--validate-semantic-properties merged) + (etaf--view-node-create + :name (etaf--view-node-name node) + :token (etaf--view-node-token node) + :props merged + :children (etaf--view-node-children node)))))) + +(defun etaf--runtime-drop-candidate-behavior (runtime identity) + "Remove RUNTIME candidate Behavior IDENTITY, disposing only provisional state." + (let ((state (gethash identity (etaf-runtime-candidate-behaviors runtime))) + (key (gethash identity + (etaf-runtime-candidate-behavior-resource-keys runtime)))) + (unless (eq state (gethash identity (etaf-runtime-behaviors runtime))) + (when-let* ((cleanup (cdr state))) + (etaf--runtime-run-contained-cleanup + runtime 'behavior-rollback identity cleanup)) + (when key (remhash key (etaf-runtime-resource-registry runtime)))) + (remhash identity (etaf-runtime-candidate-behaviors runtime)) + (remhash identity (etaf-runtime-candidate-behavior-resource-keys runtime)))) + +(defun etaf--runtime-register-host-behaviors (runtime host) + "Stage RUNTIME Behavior resources owned by registered semantic HOST. +The existing semantic id, not the current display path or ref, owns lifetime. +Installers receive final Host props including the effective public reference." + (when-let* ((specs (plist-get (etaf--semantic-host-resolved-props host) :use))) + (let* ((id (etaf--semantic-host-semantic-id host)) + (props (etaf--semantic-host-resolved-props host)) + (ref (etaf--semantic-host-host-ref host)) + (generation (etaf-runtime-current-generation runtime)) + (old-host (and generation + (etaf--pvec-get + (etaf-generation-semantic-nodes generation) id))) + (candidate-host + (gethash id (etaf-runtime-candidate-graph-nodes runtime)))) + (dolist (spec specs) + (let* ((identity (list id (etaf-behavior-spec-name spec))) + (old (gethash identity (etaf-runtime-behaviors runtime))) + (candidate (gethash identity + (etaf-runtime-candidate-behaviors runtime))) + (source-host (if (eq candidate old) old-host candidate-host))) + (if (plist-get props :disabled) + (etaf--runtime-drop-candidate-behavior runtime identity) + (let* ((state + (cond + ((and candidate (etaf--semantic-host-p source-host) + (equal ref (etaf--semantic-host-host-ref source-host)) + (etaf--runtime-behavior-spec-equal-p (car candidate) spec)) + candidate) + ((and old (etaf--semantic-host-p old-host) + (equal ref (etaf--semantic-host-host-ref old-host)) + (etaf--runtime-behavior-spec-equal-p (car old) spec)) old) + (t (etaf--runtime-install-behavior + runtime spec (etaf--semantic-host-path host) + (etaf--plist-set props :ref ref))))) + (key + (cond + ((and candidate (eq state candidate)) + (gethash identity + (etaf-runtime-candidate-behavior-resource-keys runtime))) + ((and old (eq state old)) + (gethash identity (etaf-runtime-behavior-resource-keys runtime))) + (t (cons (etaf-runtime-mount-epoch runtime) + (cl-incf (etaf-runtime-next-resource-id runtime))))))) + (unless (eq state candidate) + (etaf--runtime-drop-candidate-behavior runtime identity)) + (puthash identity state (etaf-runtime-candidate-behaviors runtime)) + (puthash identity key + (etaf-runtime-candidate-behavior-resource-keys runtime))))))))) + +(defun etaf--runtime-prune-candidate-behaviors (runtime generation) + "Drop Behaviors absent from RUNTIME's changed Hosts over GENERATION. +Only changed or removed Host ids constrain the candidate map; unaffected +subtrees keep their committed resources without being traversed." + (let ((uses (make-hash-table :test #'equal)) + (missing (make-symbol "etaf-unchanged-behavior-host")) + (nodes (etaf-runtime-candidate-graph-nodes runtime))) + (dolist (id (etaf-runtime-candidate-removed-semantic-ids runtime)) + (when generation + (let ((old (etaf--pvec-get (etaf-generation-semantic-nodes generation) id))) + (when (etaf--semantic-host-p old) + (puthash (etaf--semantic-host-semantic-id old) nil uses))))) + (maphash + (lambda (_id node) + (when (etaf--semantic-host-p node) + (puthash (etaf--semantic-host-semantic-id node) + (mapcar #'etaf-behavior-spec-name + (plist-get (etaf--semantic-host-base-props node) :use)) + uses))) + nodes) + (maphash + (lambda (identity _state) + (let ((names (gethash (car identity) uses missing))) + (when (and (not (eq names missing)) + (not (memq (cadr identity) names))) + (etaf--runtime-drop-candidate-behavior runtime identity)))) + (etaf-runtime-candidate-behaviors runtime)))) (defun etaf--runtime-promote-behaviors (runtime retirement-journal) "Publish RUNTIME Behaviors and enqueue cleanup in RETIREMENT-JOURNAL." @@ -2180,9 +2277,9 @@ need to know how Behavior attributes are merged." (etaf--resolve-property-value (plist-get props :key))))) (when key (etaf--validate-key key)) - (if key - (append (butlast path) (list :key key)) - path))) + (list :parent etaf--current-semantic-parent-id + (if key :key :position) + (or key (nthcdr (length etaf--render-parent-path) path))))) (defun etaf--runtime-owned-slots (slots) "Attach current caller ownership to unowned normalized SLOTS." @@ -2334,7 +2431,6 @@ need to know how Behavior attributes are merged." (transparent-p (and (not (eq old-publication-kind 'material)) (etaf--runtime-transparent-output-p rendered))) - (component-id etaf--current-semantic-parent-id) (range-id (and transparent-p (or old-output-range-id (cl-incf (etaf-runtime-next-semantic-id runtime))))) @@ -2347,87 +2443,24 @@ need to know how Behavior attributes are merged." (nodes (let ((etaf--current-semantic-parent-id (or range-id etaf--current-semantic-parent-id)) + (etaf--render-parent-path (append path (list :view))) (etaf--current-range-item-index (or (and old-range (etaf--semantic-range-item-identity-index old-range)) etaf--current-range-item-index)) - ;; Rendering below a retained Range changes only how this - ;; Component publishes its own output. Descendant Range - ;; anchors remain semantic children and are never folded - ;; into their ancestor's identity. - (etaf--rendering-range-p - (or transparent-p etaf--rendering-range-p))) + ;; A material Component owns its Host identities even + ;; when mounted inside a Range. Its independent render + ;; must find them without the caller's item index. + ;; Transparent output uses its own retained Range index. + (etaf--rendering-range-p transparent-p)) (etaf--render-value-list rendered (append path (list :view)))))) - (when (cl-some (lambda (node) - (memq node etaf--rendered-range-container-nodes)) - nodes) - (setq transparent-p nil)) ;; An unretained/raw slot projection still owns its structure through ;; the Component render target. Keep that existing material boundary ;; until every projection is represented by a semantic slot Range. (when etaf--raw-slot-read-p (setq transparent-p nil)) - (when (and transparent-p - (cl-some - (lambda (child-id) - (let ((child - (gethash child-id - (etaf-runtime-candidate-graph-nodes - runtime)))) - (or - (and (etaf--semantic-component-p child) - (eq - (etaf--semantic-component-publication-kind child) - 'material)) - (and (cl-every #'listp nodes) - (or (etaf--semantic-slot-range-p child) - (and (etaf--semantic-range-p child) - (not (eq - (etaf--semantic-range-kind child) - 'component-output)))))))) - (etaf--runtime-candidate-descendant-ids - runtime - (gethash range-id - (etaf-runtime-candidate-graph-children runtime)))) - ;; A single explicit Host anchor is the reviewed escape - ;; for a retained page/slot Range: material Components may - ;; live below that Host without collapsing the Range back - ;; into its parent Component. The Host remains the sole - ;; visual item and the normal backend identity proof still - ;; validates its subtree. - (not (and (= (length nodes) 1) - (etaf--semantic-host-p - (gethash - (car (gethash range-id - (etaf-runtime-candidate-graph-children - runtime))) - (etaf-runtime-candidate-graph-nodes - runtime)))))) - (unless (null (cdr nodes)) - (signal 'etaf-runtime-error - (list "Transparent sequence containing a material Component requires an explicit Host"))) - (setq transparent-p nil) - (let ((children - (copy-sequence - (gethash range-id - (etaf-runtime-candidate-graph-children runtime))))) - (puthash component-id children - (etaf-runtime-candidate-graph-children runtime)) - (remhash range-id (etaf-runtime-candidate-graph-children runtime)) - (dolist (child-id children) - (when-let* ((child - (gethash child-id - (etaf-runtime-candidate-graph-nodes runtime)))) - (let ((copy (copy-sequence child))) - (cond ((etaf--semantic-component-p copy) - (setf (etaf--semantic-component-parent-id copy) - component-id)) - ((etaf--semantic-host-p copy) - (setf (etaf--semantic-host-parent-id copy) component-id))) - (puthash child-id copy - (etaf-runtime-candidate-graph-nodes runtime))))))) (let ((node (unless transparent-p (etaf--ebox-forest-root @@ -2900,7 +2933,7 @@ need to know how Behavior attributes are merged." runtime (etaf-runtime-current-generation runtime) semantic)) (defun etaf--runtime-carry-committed-subtree (runtime generation semantic) - "Carry RUNTIME SEMANTIC and its GENERATION children during Root traversal." + "Carry RUNTIME SEMANTIC and unstaged GENERATION descendants into a candidate." (let* ((semantic-id (cond ((etaf--semantic-component-p semantic) (etaf--semantic-component-semantic-id semantic)) @@ -2938,6 +2971,8 @@ need to know how Behavior attributes are merged." (etaf--semantic-host-parent-id semantic)))) (puthash identity semantic-id (etaf-runtime-candidate-identity-entries runtime))) + (when (etaf--semantic-host-p semantic) + (etaf--runtime-register-host-behaviors runtime semantic)) (puthash semantic-id semantic (etaf-runtime-candidate-graph-nodes runtime)) (when (and (etaf--semantic-host-p semantic) (etaf--semantic-host-host-ref semantic)) @@ -3010,13 +3045,19 @@ need to know how Behavior attributes are merged." (puthash semantic-id (copy-sequence children) (etaf-runtime-candidate-graph-children runtime)) (dolist (child-id children) - (when-let* ((child (etaf--pvec-get - (etaf-generation-semantic-nodes generation) child-id))) + (when-let* ((child + (and (not (gethash child-id + (etaf-runtime-candidate-graph-nodes runtime))) + (not (memq child-id + (etaf-runtime-candidate-removed-semantic-ids + runtime))) + (etaf--pvec-get + (etaf-generation-semantic-nodes generation) child-id)))) (etaf--runtime-carry-committed-subtree runtime generation child))))) (defun etaf--runtime-render-child-range (runtime expr path &optional kind) - "Lower RUNTIME direct material-child EXPR at PATH as retained Range KIND." - (let* ((token (etaf--expr-token expr)) + "Lower RUNTIME structural child EXPR at PATH as retained Range KIND." + (let* ((token (etaf--render-site-token (etaf--expr-token expr))) (identity (list 'range etaf--current-semantic-parent-id (or token (copy-tree path)))) (old-generation (etaf-runtime-current-generation runtime)) @@ -3026,6 +3067,7 @@ need to know how Behavior attributes are merged." (old (and old-id (etaf--pvec-get (etaf-generation-semantic-nodes old-generation) old-id))) + (path (if old (etaf--semantic-range-path old) path)) (candidate (and old-id (gethash old-id (etaf-runtime-candidate-graph-nodes runtime)))) @@ -3047,6 +3089,11 @@ need to know how Behavior attributes are merged." (copy-sequence (etaf--runtime-range-nodes runtime candidate)))))) (if (and old + ;; A stable site keeps ownership, but a fresh program may close + ;; over a changed keyed item or lexical parent input. + (eq expr + (etaf--generation-effect-target + (etaf--generation-effect old-generation effect-id))) ;; A parent Component may rerender because a preceding static ;; sibling changed while this direct Range did not. Its stable ;; site token and retained artifact are sufficient to reuse the @@ -3080,14 +3127,16 @@ need to know how Behavior attributes are merged." (setq keyed-snapshot (etaf--runtime-keyed-range-snapshot expr)) (setq value - (etaf--runtime-normalize-range-value - ;; Keyed item renderers may intentionally return a - ;; transparent Component span; ordinary direct Expr - ;; ranges still fail closed on Component output. - (if keyed-snapshot - (etaf--keyed-program-outputs expr keyed-snapshot) - (funcall (etaf--expr-thunk expr))) - (not (null keyed-snapshot))))) + (if keyed-snapshot + ;; A key owns a forest, including an empty forest. + ;; Normalize within each item rather than flattening + ;; away the grouping used by incremental node spans. + (mapcar + (lambda (output) + (etaf--runtime-normalize-range-value output path)) + (etaf--keyed-program-outputs expr keyed-snapshot)) + (etaf--runtime-normalize-range-value + (funcall (etaf--expr-thunk expr)) path)))) (puthash identity semantic-id (etaf-runtime-candidate-identity-entries runtime)) (etaf--runtime-candidate-add-child @@ -3098,6 +3147,7 @@ need to know how Behavior attributes are merged." (cl-pushnew (cons (etaf-context-owner-id frame) key) context-deps :test #'equal))) (etaf--current-semantic-parent-id semantic-id) + (etaf--render-parent-path path) (etaf--current-range-item-index (and old (etaf--semantic-range-item-identity-index old))) (etaf--rendering-range-p t) @@ -3297,13 +3347,8 @@ The candidate uses resolved VALUE, DEPS, and NODES." :context-deps context-deps :artifact-key (cons (1+ (etaf-runtime-generation runtime)) effect-id) :item-root-ids item-host-ids :item-identity-index item-index))) - (dolist (item-id all-item-ids) - (let ((item (gethash item-id - (etaf-runtime-candidate-graph-nodes runtime)))) - (unless (etaf--semantic-host-p item) - (signal 'etaf-runtime-error - (list "Slot Range items require Step4b Host/string output"))) - (puthash (etaf--semantic-host-identity item) item-id item-index))) + (etaf--runtime-index-range-item-identities + runtime all-item-ids item-index) (when old (let ((new-set (make-hash-table :test #'eql))) (dolist (item-id all-item-ids) (puthash item-id t new-set)) @@ -3352,6 +3397,7 @@ The candidate uses resolved VALUE, DEPS, and NODES." context-deps :test #'equal))) (etaf--render-runtime runtime) (etaf--current-semantic-parent-id semantic-id) + (etaf--render-parent-path path) (etaf--current-range-item-index old-item-index) (etaf--rendering-range-p t) (etaf--render-style-stack @@ -3375,41 +3421,35 @@ The candidate uses resolved VALUE, DEPS, and NODES." styled) item))) - (defun etaf--runtime-normalize-range-value (value &optional allow-components-p) - "Return normalized RANGE VALUE with nested Expr sites eagerly resolved. -When ALLOW-COMPONENTS-P is non-nil, keyed item boundaries may contain -Component calls whose retained output is owned by the keyed Range." +(defun etaf--runtime-normalize-range-value (value &optional path) + "Normalize structural View VALUE at the current Range boundary PATH. +Host descendants retain their programs for the ordinary renderer, so nested +expressions and keyed lists keep independent dependencies and ownership." (cond ((null value) nil) ((stringp value) (list value)) - ((and allow-components-p - (or (etaf--component-call-p value) - (etaf--slot-projection-p value))) + ((or (etaf--component-call-p value) (etaf--slot-projection-p value)) (list value)) ((etaf--expr-p value) (etaf--runtime-normalize-range-value - (funcall (etaf--expr-thunk value)) allow-components-p)) + (funcall (etaf--expr-thunk value)) path)) ((etaf--view-node-p value) (if (eq (etaf--view-node-name value) 'fragment) (cl-mapcan (lambda (child) (etaf--runtime-normalize-range-value - child allow-components-p)) + child path)) (etaf--view-node-children value)) - (let ((copy (copy-sequence value))) - (setf (etaf--view-node-children copy) - (cl-loop for child in (etaf--view-node-children value) - append - (etaf--runtime-normalize-range-value - child allow-components-p))) - (list copy)))) + (list value))) ((proper-list-p value) (cl-mapcan (lambda (child) (etaf--runtime-normalize-range-value - child allow-components-p)) + child path)) value)) (t (signal 'etaf-runtime-error - (list "Direct material expr requires Step4b output"))))) + (list (format "Component %S View at %S expected nil, string, typed View, or proper sequence; received %S" + (or (car-safe etaf--current-component-identity) 'root) + path value)))))) (defun etaf--runtime-keyed-range-snapshot (expr) "Return EXPR's validated keyed Range snapshot, or nil." @@ -3447,6 +3487,7 @@ Component calls whose retained output is owned by the keyed Range." (defun etaf--runtime-render-range-items (value path expr snapshot) "Render normalized Range VALUE at PATH using optional keyed SNAPSHOT. +With SNAPSHOT, VALUE retains one normalized forest per logical item. EXPR supplies the key function. Keyed item paths encode stable keys rather than transient positions, so reordering changes geometry without changing any generated descendant identity. Return a plist containing flat backend NODES @@ -3550,10 +3591,25 @@ ITEM-ROOT-GROUPS and ITEM-NODE-COUNTS describe aligned item spans." semantic-id identity-index))))) (defun etaf--runtime-candidate-descendant-ids (runtime roots) - "Return ROOTS and all candidate semantic descendants in RUNTIME." - (let ((queue (copy-sequence roots)) result) + "Complete and return ROOTS' reachable candidate semantic graph in RUNTIME." + (let ((queue (copy-sequence roots)) + (generation (etaf-runtime-current-generation runtime)) + result) (while queue (let ((semantic-id (pop queue))) + ;; Local Component reuse may leave descendants implicit in the base + ;; generation. A Range owns an explicit subtree for indexing and + ;; removal, including descendants across material Component boundaries. + (when (and generation + (not (gethash semantic-id + (etaf-runtime-candidate-graph-nodes runtime))) + (not (memq semantic-id + (etaf-runtime-candidate-removed-semantic-ids + runtime)))) + (when-let* ((retained + (etaf--pvec-get + (etaf-generation-semantic-nodes generation) semantic-id))) + (etaf--runtime-carry-committed-subtree runtime generation retained))) (push semantic-id result) (setq queue (nconc queue @@ -3611,6 +3667,9 @@ raw Emacs properties; Text presentation is projected by Ebox." (if candidate (cons semantic-id (etaf--semantic-inline-range-output candidate)) (if (and old (not etaf--rendering-component-effect-p) + (eq expr + (etaf--generation-effect-target + (etaf--generation-effect generation effect-id))) (not (gethash effect-id (etaf-runtime-dirty-effect-ids runtime)))) (progn @@ -3681,14 +3740,18 @@ raw Emacs properties; Text presentation is projected by Ebox." (push (car entry) parts) (push (cdr entry) strings))) ((etaf--view-node-p value) (signal 'etaf-runtime-error - (list "Text payload cannot contain a View node"))) + (list (format "Text at %S cannot contain a View node: %S" + current-path value)))) ((proper-list-p value) (let ((index 0)) (dolist (item value) (walk item (append current-path (list index)) inherited) (cl-incf index)))) (t (signal 'etaf-runtime-error - (list "Inline text requires Step4b output")))))) + (list (format "Component %S Text at %S expected string or nil; received %S" + (or (car-safe etaf--current-component-identity) + 'root) + current-path value))))))) (walk values path surface)) (list (apply #'concat (nreverse strings)) (nreverse parts)))) @@ -3800,6 +3863,36 @@ cleanup after ORDERING-PREFIX instead of running them inline." (etaf-runtime-candidate-behaviors runtime) nil (etaf-runtime-candidate-behavior-resource-keys runtime) nil)) +(defun etaf--runtime-retire-detached-components + (runtime old generation retirement-journal) + "Retire RUNTIME Components detached from OLD in committed GENERATION. +RETIREMENT-JOURNAL owns hooks and Scope disposal after successful publication." + (let (removed) + (dolist (semantic-id + (delete-dups + (copy-sequence (etaf-runtime-candidate-removed-semantic-ids runtime)))) + (unless (etaf--pvec-get (etaf-generation-semantic-nodes generation) + semantic-id) + (let ((semantic (etaf--pvec-get (etaf-generation-semantic-nodes old) + semantic-id))) + (when (etaf--semantic-component-p semantic) + (push semantic removed))))) + (cl-loop + for semantic in (sort removed + (lambda (left right) + (> (length (etaf--semantic-component-path left)) + (length (etaf--semantic-component-path right))))) + for index from 0 + for key = (etaf--semantic-component-resource-key semantic) + for identity = (etaf--semantic-component-identity semantic) + for instance = (gethash key (etaf-runtime-resource-registry runtime)) + when instance do + (when (eq instance (gethash identity (etaf-runtime-instances runtime))) + (remhash identity (etaf-runtime-instances runtime))) + (remhash key (etaf-runtime-resource-registry runtime)) + (etaf--runtime-dispose-instance + instance t retirement-journal (list 0 index))))) + (defun etaf--runtime-promote (runtime retirement-journal) "Promote RUNTIME candidate and enqueue removals in RETIREMENT-JOURNAL." (let (removed added existing) @@ -3908,7 +4001,8 @@ cleanup after ORDERING-PREFIX instead of running them inline." (defun etaf--runtime-render-root (runtime) "Lower RUNTIME's cached root View." (let ((etaf--current-semantic-parent-id - (etaf-runtime-root-range-id runtime))) + (etaf-runtime-root-range-id runtime)) + (etaf--render-parent-path '(root))) (etaf--render-value-list (etaf-runtime-root-view-cache runtime) '(root)))) @@ -3998,6 +4092,8 @@ Generation, including its effects and Host contributions." (defun etaf--runtime-build-generation (runtime &optional base) "Build RUNTIME generation, point-copying candidate owners from BASE." (etaf--runtime-record-detached-candidate-subtrees runtime base) + (unless (etaf-runtime-candidate-full-rebuild-p runtime) + (etaf--runtime-prune-candidate-behaviors runtime base)) (let* ((full-p (or (null base) (etaf-runtime-root-dirty-p runtime))) (generation-id (1+ (etaf-runtime-generation runtime))) (metrics (or (etaf-runtime-candidate-generation-metrics runtime) @@ -4121,7 +4217,7 @@ Generation, including its effects and Host contributions." (setf (etaf--semantic-component-artifact-key semantic) (cons generation-id effect-id))) (push (cons semantic-id semantic) node-updates) - (unless (gethash identity identity-index) + (unless (eql semantic-id (gethash identity identity-index)) (unless identity-copy (setq identity-index (copy-hash-table identity-index) identity-copy t)) @@ -4149,6 +4245,14 @@ Generation, including its effects and Host contributions." (push (cons (cdr resource-key) resource-key) resource-updates)))) (etaf-runtime-candidate-behaviors runtime)) + (maphash + (lambda (identity old-key) + (unless (equal old-key + (gethash identity + (etaf-runtime-candidate-behavior-resource-keys + runtime))) + (push (cons (cdr old-key) nil) resource-updates))) + (etaf-runtime-behavior-resource-keys runtime)) (dolist (effect-id (etaf-runtime-candidate-removed-effect-ids runtime)) (remove-effect effect-id)) (dolist (semantic-id (etaf-runtime-candidate-removed-semantic-ids runtime)) @@ -4165,6 +4269,10 @@ Generation, including its effects and Host contributions." semantic-id)))) (dolist (effect-id (semantic-effect-ids old-node)) (remove-effect effect-id)) + (when (etaf--semantic-component-p old-node) + (push (cons (cdr (etaf--semantic-component-resource-key old-node)) + nil) + resource-updates)) (when (and (etaf--semantic-host-p old-node) (etaf--semantic-host-host-ref old-node)) (let ((host-ref (etaf--semantic-host-host-ref old-node))) @@ -4180,7 +4288,7 @@ Generation, including its effects and Host contributions." (push (cons semantic-id nil) node-updates)))) (maphash (lambda (identity semantic-id) - (unless (gethash identity identity-index) + (unless (eql semantic-id (gethash identity identity-index)) (unless identity-copy (setq identity-index (copy-hash-table identity-index) identity-copy t)) @@ -4704,32 +4812,38 @@ RESOURCE-JOURNAL and ROUTE-JOURNAL remain opaque owner-local entries." (descendants (etaf--runtime-generation-descendant-ids generation (etaf--semantic-component-child-ids semantic))) - found) - (dolist (semantic-id descendants) - (when-let* ((slot-range - (let ((node (etaf--pvec-get + (slot-ranges + (cl-loop for semantic-id in descendants + for node = (etaf--pvec-get (etaf-generation-semantic-nodes generation) - semantic-id))) - (and (etaf--semantic-slot-range-p node) node)))) + semantic-id) + when (and (etaf--semantic-slot-range-p node) + (eql (etaf--semantic-slot-range-consumer-component-id + node) + (etaf--semantic-component-semantic-id semantic))) + collect node)) + found) + ;; Descendant Components own their projections. A missing input must + ;; re-run this consumer's ordinary View to select its declared fallback. + (when (cl-every (lambda (slot-range) + (assq (etaf--semantic-slot-range-name slot-range) slots)) + slot-ranges) + (dolist (slot-range slot-ranges) (setq found t) (let* ((name (etaf--semantic-slot-range-name slot-range)) (entry (assq name slots)) - (content (and entry (cdr entry))) + (content (cdr entry)) (old-effect (etaf--generation-effect generation (etaf--semantic-slot-range-effect-id slot-range))) (old-target (etaf--generation-effect-target old-effect)) (owner-id - (if entry - (and (etaf--slot-content-p content) - (etaf--slot-content-owner-component-id content)) - (etaf--semantic-slot-range-consumer-component-id slot-range))) + (and (etaf--slot-content-p content) + (etaf--slot-content-owner-component-id content))) (children - (if entry - (if (etaf--slot-content-p content) - (etaf--slot-content-children content) - content) - (plist-get old-target :children))) + (if (etaf--slot-content-p content) + (etaf--slot-content-children content) + content)) (target (list :children children :owner-id owner-id :owner-input @@ -4763,77 +4877,70 @@ RESOURCE-JOURNAL and ROUTE-JOURNAL remain opaque owner-local entries." (copy-sequence (etaf--semantic-host-property-bindings base))) (host-props (copy-tree - (or (and property-bindings - (etaf--semantic-host-base-props base)) - (etaf--generation-index-lookup - (etaf-runtime-current-generation runtime) - 'host-props host-ref) - nil))) + (if property-bindings + (etaf--semantic-host-base-props base) + ;; The public contribution index contains only semantic/event + ;; Hosts. Anonymous layout Hosts retain their complete styled + ;; input here so a Theme paint delta cannot erase geometry. + (etaf--semantic-host-resolved-props base)))) (theme-bindings (copy-sequence (etaf--semantic-host-theme-bindings base))) - deps context-deps) - (dolist (binding property-bindings) - (let (local-deps local-context-deps value) - (let ((evaluate - (lambda () - (let ((etaf--runtime-dependency-collector - (lambda (source) - (cl-pushnew source local-deps :test #'eq))) - (etaf--context-inject-recorder - (lambda (frame key) - (cl-pushnew - (cons (etaf-context-owner-id frame) key) - local-context-deps :test #'equal))) - (etaf--active-effect nil) - (etaf--render-phase-p t)) - (setq value - (etaf--resolve-property-value - (etaf--host-property-binding-expression - binding))))))) - (if-let* ((component-id - (etaf--semantic-host-component-id base))) - (etaf--runtime-call-with-component-env - runtime component-id evaluate) - (funcall evaluate))) - (setq host-props - (plist-put host-props - (etaf--host-property-binding-property binding) - value)) - (dolist (source local-deps) - (cl-pushnew source deps :test #'eq)) - (dolist (dependency local-context-deps) - (cl-pushnew dependency context-deps :test #'equal)))) - (let ((next-base-props (and property-bindings (copy-tree host-props)))) - (if property-bindings - (let* ((node - (etaf--view-node-create - :name (etaf--semantic-host-name base) - :token (etaf--semantic-host-site-token base) - :props host-props :children nil)) - (etaf--render-style-stack - (copy-tree (etaf--semantic-host-style-identity base))) - (styled (etaf--runtime-style-node - node (etaf--semantic-host-path base))) - (themed (etaf--apply-theme-defaults styled)) - (theme-result - (etaf--resolve-theme-property-plist - (etaf--view-node-props themed)))) - (setq host-props (nth 0 theme-result) - theme-bindings (nth 1 theme-result)) - (dolist (source (nth 2 theme-result)) - (cl-pushnew source deps :test #'eq))) - (dolist (binding theme-bindings) - (let* ((property (etaf--theme-property-binding-property binding)) - (source (etaf--theme-property-binding-source binding)) - (token (etaf--theme-property-binding-token binding)) - (resolved - (etaf--theme-token-resolve-from-source token source)) - (value - (etaf--runtime-theme-paint-value - runtime property token source resolved))) - (setq host-props (plist-put host-props property value)) - (when (or (etaf-ref-p source) (etaf-computed-p source)) - (cl-pushnew source deps :test #'eq))))) + deps context-deps next-base-props) + ;; Property expressions and style/Theme transforms are one Host render + ;; evaluation. Share its owner, write guard, paint slots and dependency + ;; collectors; no user transform may escape into publication scope. + (etaf--runtime-call-with-component-env + runtime (etaf--semantic-host-component-id base) + (lambda () + (let ((etaf--render-runtime runtime) + (etaf--runtime-dependency-collector + (lambda (source) (cl-pushnew source deps :test #'eq))) + (etaf--context-inject-recorder + (lambda (frame key) + (cl-pushnew (cons (etaf-context-owner-id frame) key) + context-deps :test #'equal))) + (etaf--active-effect nil) + (etaf--render-phase-p t)) + (dolist (binding property-bindings) + (setq host-props + (plist-put host-props + (etaf--host-property-binding-property binding) + (etaf--resolve-property-value + (etaf--host-property-binding-expression binding))))) + (setq next-base-props + (if property-bindings + (copy-tree host-props) + (etaf--semantic-host-base-props base))) + (if property-bindings + (let* ((node + (etaf--view-node-create + :name (etaf--semantic-host-name base) + :token (etaf--semantic-host-site-token base) + :props host-props :children nil)) + (etaf--render-style-stack + (copy-tree (etaf--semantic-host-style-identity base))) + (styled (etaf--runtime-style-node + node (etaf--semantic-host-path base))) + (themed (etaf--apply-theme-defaults styled)) + (theme-result + (etaf--resolve-theme-property-plist + (etaf--view-node-props themed)))) + (setq host-props (nth 0 theme-result) + theme-bindings (nth 1 theme-result)) + (dolist (source (nth 2 theme-result)) + (cl-pushnew source deps :test #'eq))) + (dolist (binding theme-bindings) + (let* ((property (etaf--theme-property-binding-property binding)) + (source (etaf--theme-property-binding-source binding)) + (token (etaf--theme-property-binding-token binding)) + (resolved + (etaf--theme-token-resolve-from-source token source)) + (value + (etaf--runtime-theme-paint-value + runtime property token source resolved))) + (setq host-props (plist-put host-props property value)) + (when (or (etaf-ref-p source) (etaf-computed-p source)) + (cl-pushnew source deps :test #'eq)))))))) (let ((backend-props (etaf--merge-property (etaf--ebox-properties host-props @@ -4843,6 +4950,7 @@ RESOURCE-JOURNAL and ROUTE-JOURNAL remain opaque owner-local entries." (setf (etaf--semantic-host-property-bindings candidate) property-bindings (etaf--semantic-host-theme-bindings candidate) theme-bindings (etaf--semantic-host-base-props candidate) next-base-props + (etaf--semantic-host-resolved-props candidate) host-props (etaf--semantic-host-props-signature candidate) backend-props (etaf--semantic-host-deps candidate) (nreverse deps) (etaf--semantic-host-context-deps candidate) @@ -4859,7 +4967,7 @@ RESOURCE-JOURNAL and ROUTE-JOURNAL remain opaque owner-local entries." :semantic-id (etaf--semantic-host-semantic-id candidate) :deps (etaf--semantic-host-deps candidate)) (etaf-runtime-candidate-effects runtime)) - candidate)))) + candidate))) (defun etaf--runtime-render-dirty-component (runtime semantic) "Render one input-ready SEMANTIC into RUNTIME candidate." @@ -5020,10 +5128,10 @@ RESOURCE-JOURNAL and ROUTE-JOURNAL remain opaque owner-local entries." (list candidate committed-input input))))) (defun etaf--runtime-render-keyed-range - (runtime effect range component instance) + (runtime effect range) "Render EFFECT's changed items in keyed RANGE for RUNTIME. -COMPONENT and INSTANCE supply the retained owner environment. Return nil when -the range is not eligible for keyed incremental rendering." +The caller supplies the retained owner environment. Return nil when the +range is not eligible for keyed incremental rendering." (let* ((expr (etaf--generation-effect-target effect)) (snapshot-function (and (etaf--expr-p expr) (etaf--expr-range-snapshot expr))) @@ -5050,24 +5158,6 @@ the range is not eligible for keyed incremental rendering." (lambda (frame key) (cl-pushnew (cons (etaf-context-owner-id frame) key) context-deps :test #'equal))) - (etaf--current-runtime runtime) - (etaf--current-component-instance instance) - (etaf--current-component-state - (etaf--component-instance-state instance)) - (etaf--current-component-setup-defined-p - (not (null (etaf--component-spec-setup - (etaf--component-instance-spec instance))))) - (etaf--current-component-setup-complete-p - (etaf--component-instance-setup-complete-p instance)) - (etaf--component-phase 'render) - (etaf--current-component-identity - (etaf--semantic-component-identity component)) - (etaf--current-component-props - (etaf--semantic-component-props component)) - (etaf--current-component-slots - (etaf--semantic-component-slots component)) - (etaf--current-context - (etaf--semantic-component-context-frame component)) (etaf--active-effect nil) (etaf--render-phase-p t)) (setq snapshot (etaf--runtime-keyed-range-snapshot expr))) @@ -5111,30 +5201,13 @@ the range is not eligible for keyed incremental rendering." (cl-pushnew (cons (etaf-context-owner-id frame) key) context-deps :test #'equal))) (etaf--render-runtime runtime) - (etaf--current-runtime runtime) - (etaf--current-component-instance instance) - (etaf--current-component-state - (etaf--component-instance-state instance)) - (etaf--current-component-setup-defined-p - (not (null (etaf--component-spec-setup - (etaf--component-instance-spec instance))))) - (etaf--current-component-setup-complete-p - (etaf--component-instance-setup-complete-p instance)) - (etaf--component-phase 'render) - (etaf--current-component-identity - (etaf--semantic-component-identity component)) - (etaf--current-component-props - (etaf--semantic-component-props component)) - (etaf--current-component-slots - (etaf--semantic-component-slots component)) - (etaf--current-context - (etaf--semantic-component-context-frame component)) (etaf--current-component-semantic-id (etaf--semantic-range-component-id range)) (etaf--current-semantic-parent-id (etaf--semantic-range-semantic-id range)) (etaf--current-range-item-index (etaf--semantic-range-item-identity-index range)) + (etaf--render-parent-path (etaf--semantic-range-path range)) (etaf--rendering-range-p t) (etaf--active-effect nil) (etaf--render-phase-p t) @@ -5207,7 +5280,7 @@ the range is not eligible for keyed incremental rendering." (value (etaf--runtime-normalize-range-value (funcall item-function item context) - t)) + (etaf--semantic-range-path range))) (rendered (etaf--render-value-list value @@ -5244,89 +5317,53 @@ the range is not eligible for keyed incremental rendering." (defun etaf--runtime-render-dirty-range (runtime effect range) "Evaluate RUNTIME dirty RANGE EFFECT without running its Component owner." - (let* ((generation (etaf-runtime-current-generation runtime)) - (base-component - (etaf--pvec-get (etaf-generation-semantic-nodes generation) - (etaf--semantic-range-component-id range))) - (component - (or (gethash (etaf--semantic-component-identity base-component) - (etaf-runtime-candidate-semantic-nodes runtime)) - base-component)) - (instance - (gethash (etaf--semantic-component-resource-key component) - (etaf-runtime-resource-registry runtime))) - (builder (ebox-source-builder-create)) - deps context-deps value nodes keyed-snapshot - item-root-groups item-node-counts) + (let ((builder (ebox-source-builder-create)) + deps context-deps value nodes keyed-snapshot + item-root-groups item-node-counts) (let ((etaf--ebox-source-builder builder)) (let ((keyed - (etaf--runtime-render-keyed-range - runtime effect range component instance))) - (if keyed - (setq deps (plist-get keyed :deps) - context-deps (plist-get keyed :context-deps) - value (plist-get keyed :value) - nodes (plist-get keyed :nodes) - keyed-snapshot (plist-get keyed :snapshot) - item-root-groups (plist-get keyed :item-root-groups) - item-node-counts (plist-get keyed :item-node-counts)) - (let ((collector - (lambda (source) (cl-pushnew source deps :test #'eq)))) - (let ((etaf--runtime-dependency-collector collector) - (etaf--context-inject-recorder - (lambda (frame key) - (cl-pushnew (cons (etaf-context-owner-id frame) key) - context-deps :test #'equal))) - (etaf--current-runtime runtime) - (etaf--current-component-instance instance) - (etaf--current-component-state - (etaf--component-instance-state instance)) - (etaf--current-component-setup-defined-p - (not (null (etaf--component-spec-setup - (etaf--component-instance-spec instance))))) - (etaf--current-component-setup-complete-p - (etaf--component-instance-setup-complete-p instance)) - (etaf--component-phase 'render) - (etaf--current-component-identity - (etaf--semantic-component-identity component)) - (etaf--current-component-props - (etaf--semantic-component-props component)) - (etaf--current-component-slots - (etaf--semantic-component-slots component)) - (etaf--current-context - (etaf--semantic-component-context-frame component)) - (etaf--active-effect nil) - (etaf--render-phase-p t)) - (setq value - (etaf--runtime-normalize-range-value - (funcall (etaf--expr-thunk - (etaf--generation-effect-target effect)))))) - ;; Lowering may resolve property expressions on Component calls - ;; produced by the Range. Keep the lexical Component environment for - ;; both normalization and lowering; otherwise prop symbol macros read - ;; an empty dynamic environment during an independent Range update. - (etaf--runtime-call-with-component-env - runtime (etaf--semantic-range-component-id range) - (lambda () - (let ((etaf--runtime-dependency-collector collector) - (etaf--context-inject-recorder - (lambda (frame key) - (cl-pushnew (cons (etaf-context-owner-id frame) key) - context-deps :test #'equal))) - (etaf--render-runtime runtime) - (etaf--current-component-semantic-id - (etaf--semantic-range-component-id range)) - (etaf--current-semantic-parent-id - (etaf--semantic-range-semantic-id range)) - (etaf--current-range-item-index - (etaf--semantic-range-item-identity-index range)) - (etaf--rendering-range-p t) - (etaf--active-effect nil) - (etaf--render-phase-p t) - (etaf--render-style-stack - (copy-tree (etaf--semantic-range-caller-style-stack range)))) - (setq nodes (etaf--render-value-list - value (etaf--semantic-range-path range)))))))) + (etaf--runtime-call-with-component-env + runtime (etaf--semantic-range-component-id range) + (lambda () + (etaf--runtime-render-keyed-range runtime effect range))))) + (if keyed + (setq deps (plist-get keyed :deps) + context-deps (plist-get keyed :context-deps) + value (plist-get keyed :value) + nodes (plist-get keyed :nodes) + keyed-snapshot (plist-get keyed :snapshot) + item-root-groups (plist-get keyed :item-root-groups) + item-node-counts (plist-get keyed :item-node-counts)) + ;; A root-owned expression has no Component. The same environment + ;; helper handles it and preserves lexical props through lowering. + (etaf--runtime-call-with-component-env + runtime (etaf--semantic-range-component-id range) + (lambda () + (let ((etaf--runtime-dependency-collector + (lambda (source) (cl-pushnew source deps :test #'eq))) + (etaf--context-inject-recorder + (lambda (frame key) + (cl-pushnew (cons (etaf-context-owner-id frame) key) + context-deps :test #'equal))) + (etaf--render-runtime runtime) + (etaf--current-semantic-parent-id + (etaf--semantic-range-semantic-id range)) + (etaf--render-parent-path (etaf--semantic-range-path range)) + (etaf--current-range-item-index + (etaf--semantic-range-item-identity-index range)) + (etaf--rendering-range-p t) + (etaf--active-effect nil) + (etaf--render-phase-p t) + (etaf--render-style-stack + (copy-tree (etaf--semantic-range-caller-style-stack range)))) + (setq value + (etaf--runtime-normalize-range-value + (funcall (etaf--expr-thunk + (etaf--generation-effect-target effect))) + (etaf--semantic-range-path range)) + nodes + (etaf--render-value-list + value (etaf--semantic-range-path range))))))) (let* ((item-root-ids (copy-sequence (gethash (etaf--semantic-range-semantic-id range) @@ -5799,6 +5836,8 @@ RENDERED-IDENTITIES names the Component render participants." (etaf-runtime-candidate-graph-nodes runtime)) base-semantic))))) (if (and effect semantic + (not (memq (etaf--generation-effect-semantic-id effect) + (etaf-runtime-candidate-removed-semantic-ids runtime))) (etaf--runtime-scheduled-semantic-live-p old effect base-semantic)) (progn @@ -5806,8 +5845,7 @@ RENDERED-IDENTITIES names the Component render participants." runtime old effect-id) (unless (and (memq (etaf--generation-effect-kind effect) '(range fragment slot inline)) - (etaf--runtime-range-owned-by-rendered-component-p - runtime old base-semantic)) + (etaf--runtime-range-effect-staged-p runtime old effect)) (pcase (etaf--generation-effect-kind effect) ('component-input (etaf--runtime-evaluate-component-input runtime semantic) @@ -5921,13 +5959,15 @@ RENDERED-IDENTITIES names the Component render participants." (cl-remove-if (lambda (change) (let* ((range (car change)) + (component-id + (etaf--semantic-backend-range-container-component-id range)) (component - (etaf--pvec-get - (etaf-generation-semantic-nodes old) - (etaf--semantic-backend-range-container-component-id - range)))) - (member (etaf--semantic-component-identity component) - backend-component-identities))) + (and component-id + (etaf--pvec-get + (etaf-generation-semantic-nodes old) component-id)))) + (and component + (member (etaf--semantic-component-identity component) + backend-component-identities)))) range-changes)) (setq range-changes (etaf--runtime-normalize-range-changes runtime old range-changes)) @@ -5960,6 +6000,7 @@ RENDERED-IDENTITIES names the Component render participants." (dolist (effect-id fallback-component-effect-ids) (puthash effect-id t (etaf-runtime-dirty-effect-ids runtime))) (etaf--runtime-dispose-created-candidate runtime) + (etaf--runtime-rollback-behaviors runtime) (etaf--runtime-clear-candidate runtime) (cl-return-from etaf--runtime-component-overlay :root-fallback))) (setq candidate-generation (etaf--runtime-build-generation runtime old) @@ -6101,6 +6142,13 @@ RENDERED-IDENTITIES names the Component render participants." (run-step 'lifecycle-preparation (lambda () + ;; Local Component and Range updates acquire the same + ;; Behavior resources as Root renders. Promote them before + ;; candidate disposal and before mounted hooks may enqueue + ;; another update, preserving reuse and teardown ownership. + (etaf--runtime-promote-behaviors runtime retirement) + (etaf--runtime-retire-detached-components + runtime old candidate-generation retirement) (dolist (instance (etaf-runtime-candidate-created runtime)) (setf (etaf--component-instance-mounted-p instance) t)) (dolist (entry (etaf--generation-index-entries @@ -6225,8 +6273,7 @@ RENDERED-IDENTITIES names the Component render participants." (lambda () (etaf--runtime-install-generation-mirrors runtime candidate-generation t))) - (setf (etaf-runtime-root-node runtime) root-node - (etaf-runtime-root-dirty-p runtime) nil) + (setf (etaf-runtime-root-dirty-p runtime) nil) (etaf--runtime-clear-dirty-effects runtime) (unwind-protect (progn @@ -6295,14 +6342,55 @@ backend anchor proof failed; ordinary root turns keep their artifact reuse." (defun etaf--runtime-request-flush (runtime) "Flush RUNTIME within one optional Runtime operation boundary." (etaf--runtime-with-operation (runtime 'flush "reactive flush") - (etaf--runtime-request-flush-now runtime))) + (if (fboundp 'etaf-events-call-with-preserved-focus) + (etaf-events-call-with-preserved-focus + runtime (lambda () (etaf--runtime-request-flush-now runtime))) + (etaf--runtime-request-flush-now runtime)))) ;;;###autoload (defun etaf-runtime-flush (&optional runtime) - "Flush mounted RUNTIME immediately and return its root Ebox node." + "Request mounted RUNTIME's pending work and return its committed revision. +This returns an integer, not an Ebox node. Busy or batched work may remain +pending; the result identifies the currently committed Ebox publication. +An active Ebox/TP transaction is rejected before requesting work, because +its current revision may still be provisional. No tree is exported. +Use `etaf-runtime-snapshot' for an explicit snapshot." (let ((runtime (etaf-runtime-require-mounted runtime))) + (when (tp-transaction-active-p) + (signal 'etaf-runtime-error + (list "Cannot flush inside an active Ebox/TP transaction"))) (etaf--runtime-request-flush runtime) - (etaf-runtime-root-node runtime))) + (etaf-render-port-revision (etaf-runtime-buffer runtime)))) + +;;;###autoload +(defun etaf-runtime-snapshot (&optional runtime) + "Export mounted RUNTIME's currently committed Ebox snapshot. +Return a plist with `:input' (a canonical Ebox input), `:revision', and +`:mount-id'. The export costs O(N), detaches mutable node payload, and keeps +opaque callback identities. It neither drains pending work nor evaluates +Components, publishes, or increments the revision. Unmounted Runtimes and +reads inside an Ebox/TP transaction signal an error." + (let ((runtime (etaf-runtime-require-mounted runtime))) + (etaf-render-port-snapshot (etaf-runtime-buffer runtime)))) + +(eval-and-compile + ;; Hot reload must also remove the former cl-defstruct accessor's inliner + ;; and setter metadata. Future callers must execute the explicit query. + (dolist (property '(compiler-macro gv-expander gv-setter + side-effect-free pure document-generalized-variable)) + (cl-remprop 'etaf-runtime-root-node property))) + +(defun etaf-runtime-root-node (runtime) + "Return RUNTIME's current root through an explicit detached snapshot. +This obsolete read-compatible getter costs O(N); no root mirror is stored. +Use `etaf-runtime-snapshot' to retain the root's matching source facts." + (declare (obsolete etaf-runtime-snapshot "2026-09-06")) + (let ((roots (ebox-canonical-input-roots + (plist-get (etaf-runtime-snapshot runtime) :input)))) + (unless (and roots (null (cdr roots))) + (signal 'etaf-runtime-error + (list "Mounted Ebox snapshot must contain one root"))) + (car roots))) (defun etaf--runtime-mount-now (buffer-or-name view observer &optional scheduler-context) diff --git a/etaf-view.el b/etaf-view.el index 7748c3b..af1feef 100644 --- a/etaf-view.el +++ b/etaf-view.el @@ -260,11 +260,41 @@ disposing the old Runtime.") (etaf--resolve-property-value root-value) (etaf--resolve-property-value caller-value))) +(defun etaf--compose-event-callbacks (primary secondary) + "Compose PRIMARY then SECONDARY, preserving the primary return value. +An error stops the chain. This shared value operation lives with View +composition so pure render and Runtime Behavior use the same contract." + (dolist (callback (list primary secondary)) + (unless (or (null callback) (functionp callback)) + (etaf--component-error "Event callback must be a function or nil: %S" + callback))) + (cond + ((null primary) secondary) + ((null secondary) primary) + (t + (lambda (&rest arguments) + (prog1 (apply primary arguments) + (apply secondary arguments)))))) + +(defun etaf--owned-semantic-property-p (property) + "Return non-nil when fallthrough PROPERTY must preserve root semantics." + (or (eq property :role) + (and (etaf--aria-property-p property) + (not (memq property '(:aria-label :aria-description)))))) + +(defun etaf--merge-use-input (root-value caller-value) + "Concatenate possibly lazy ROOT-VALUE and CALLER-VALUE Behavior sources. +Runtime validates all names before installing any resources." + (let ((root (etaf--resolve-property-value root-value)) + (caller (etaf--resolve-property-value caller-value))) + (append (if (proper-list-p root) root (list root)) + (if (proper-list-p caller) caller (list caller))))) + (defun etaf--merge-host-attrs (props attrs &optional tag component-name) "Merge caller ATTRS into root Host PROPS for TAG. -Visual Ebox attributes override Component defaults. Class tokens merge. -Conflicting Runtime metadata is rejected so fallthrough cannot silently alter -Component semantics. COMPONENT-NAME labels diagnostics." +Non-nil visual attributes override defaults; class and Behavior sources merge. +Callbacks append, disabled combines with OR, and owned semantic conflicts +are rejected. COMPONENT-NAME labels diagnostics." (let ((result (copy-sequence props)) (tail attrs)) (while tail @@ -282,10 +312,36 @@ Component semantics. COMPONENT-NAME labels diagnostics." (etaf--plist-set result :class (etaf--merge-class-input (plist-get result :class) value)))) - (style-p + ((etaf--event-property-p key) (setq result - (append (etaf--plist-remove-domain result key) - (list key value)))) + (etaf--plist-set + result key + (etaf--compose-event-callbacks + (etaf--resolve-property-value (plist-get result key)) + (etaf--resolve-property-value value))))) + ((eq key :use) + (setq result + (etaf--plist-set + result key (etaf--merge-use-input (plist-get result key) value)))) + ((eq key :disabled) + (let ((inner (etaf--resolve-property-value (plist-get result key))) + (outer (etaf--resolve-property-value value))) + (unless (and (memq inner '(nil t)) (memq outer '(nil t))) + (etaf--component-error "Component %S :disabled must be boolean" + component-name)) + (setq result (etaf--plist-set result key (or inner outer))))) + (style-p + (when (etaf--resolve-property-value value) + (setq result + (append (etaf--plist-remove-domain result key) + (list key value))))) + ((and (etaf--owned-semantic-property-p key) + (plist-member result key) + (not (equal (etaf--resolve-property-value (plist-get result key)) + (etaf--resolve-property-value value)))) + (etaf--component-error + "Component %S root Host %S owns %S; conflicting fallthrough value %S" + component-name tag key value)) ((plist-member result key) (setq result (etaf--plist-set result key value))) (t @@ -306,7 +362,11 @@ COMPONENT-NAME identifies the forwarding owner for diagnostics." (let ((key (pop tail)) (value (pop tail))) (if (memq key declared) - (setq props (etaf--plist-set props key value)) + ;; Consuming a declared prop must retain the inner declaration + ;; and the outer subscription, and must not forward either twice. + (setq props + (etaf--merge-host-attrs props (list key value) + nil component-name)) (setq forwarded (etaf--merge-host-attrs forwarded (list key value) nil component-name))))) @@ -745,7 +805,9 @@ FORM uses one grammar for Hosts and Component calls: (NAME :PROPERTY VALUE ... CHILD ...) Properties must come first and children must come last. Property values are -ordinary Elisp expressions. `(expr FORM)' is text interpolation only." +ordinary Elisp expressions. In structural child positions, `(expr FORM)' +returns nil, strings, typed Views, or proper sequences of those values. +Inside a `text' Host, an expression must return one string." (declare (indent 1) (debug (form))) (if (fboundp 'etaf-compiler-expand-view) (etaf-compiler-expand-view form :projection) diff --git a/tests/etaf-component-frontends-tests.el b/tests/etaf-component-frontends-tests.el index 79803c6..33a2f91 100644 --- a/tests/etaf-component-frontends-tests.el +++ b/tests/etaf-component-frontends-tests.el @@ -112,13 +112,13 @@ nil) :view (text (expr label))) -(etaf-define-component etaf-test-g6b-provider () +(etaf-define-component etaf-test-g6b-provider (&key count on-press) :setup (progn (etaf-provide 'g6b-message "Context") (etaf-theme-provide '(:color "#34D399")) nil) - :view (column (slot))) + :view (column (etaf-test-g6b-context-action :count count :on-press on-press))) (etaf-define-component etaf-test-g6b-context-action (&key count on-press) :render @@ -219,9 +219,7 @@ (etaf-define-component invalid-neither () :setup nil) (etaf-define-component invalid-reserved (&key key) :view (box)) (etaf-define-component invalid-setup-view () - :setup (etaf-node 'box nil nil) :view (box)) - (etaf-define-component invalid-render-dsl () - :render (etaf-view (box))))) + :setup (etaf-node 'box nil nil) :view (box)))) (should-error (macroexpand definition) :type 'etaf-component-definition-error))) @@ -558,7 +556,7 @@ (when-let* ((live (get-buffer buffer))) (kill-buffer live))))) (ert-deftest etaf-component-frontends-compose-context-theme-style-and-behavior () - "A DSL provider and code Component share Context, Theme, style, and events." + "Test child inheritance and composition; separate tests cover slot authors." (let ((buffer " *etaf-g6b-composition*") (count (etaf-ref 0))) (unwind-protect @@ -568,13 +566,12 @@ (lambda () (etaf-view (etaf-test-g6b-provider - (etaf-test-g6b-context-action :count (etaf-value count) :on-press (let ((source count)) (lambda () (setf (etaf-value source) - (1+ (etaf-value source)))))))))) + (1+ (etaf-value source))))))))) (let* ((runtime (etaf-runtime-for-buffer buffer)) (face (etaf-test-g6b--face-at buffer "Context 0")) (props @@ -661,18 +658,13 @@ (when-let* ((live (get-buffer buffer))) (kill-buffer live))))) (let ((buffer " *etaf-g6b-attrs-role*")) (unwind-protect - (progn - (etaf-mount + (should-error + (etaf-mount buffer (etaf-view (etaf-test-g6b-attr-role :role 'navigation :ref 'g6b-attrs-role))) - (should - (eq 'navigation - (plist-get - (etaf-runtime-host-props-for - (etaf-runtime-for-buffer buffer) 'g6b-attrs-role) - :role)))) + :type 'etaf-component-call-error) (when-let* ((runtime (etaf-runtime-for-buffer buffer))) (etaf-unmount runtime)) (when-let* ((live (get-buffer buffer))) (kill-buffer live))))) diff --git a/tests/etaf-docs-tests.el b/tests/etaf-docs-tests.el index ae57828..c062bb9 100644 --- a/tests/etaf-docs-tests.el +++ b/tests/etaf-docs-tests.el @@ -63,6 +63,123 @@ (while t (push (read (current-buffer)) forms)) (end-of-file (nreverse forms)))))) +(defun etaf-docs-test--marked-examples (contents) + "Extract named executable documentation examples from CONTENTS." + (with-temp-buffer + (insert contents) + (goto-char (point-min)) + (let (examples) + (while (re-search-forward + "^\n```elisp\n" nil t) + (let ((name (match-string 1)) + (start (point))) + (unless (re-search-forward "^```$" nil t) + (error "Unclosed executable example %s" name)) + (push (cons name (buffer-substring-no-properties + start (match-beginning 0))) + examples))) + (nreverse examples)))) + +(defun etaf-docs-test--run-fresh (source assertions) + "Run exact example SOURCE and ASSERTIONS in a fresh core-only Emacs." + (let ((script (make-temp-file "etaf-readme-" nil ".el"))) + (unwind-protect + (progn + (with-temp-file script + (insert source "\n") + (prin1 assertions (current-buffer)) + (insert "\n(should-not (featurep 'etaf-ui))\n" + "(should-not (featurep 'etaf-playground))\n")) + (with-temp-buffer + (let ((status + (apply #'call-process + (expand-file-name invocation-name invocation-directory) + nil (current-buffer) nil "-Q" "--batch" + (append + (cl-loop for directory in '("." "../ebox" "../tp" + "../ecss") + append (list "-L" (expand-file-name + directory + etaf-docs-test--root))) + (list "--eval" + (prin1-to-string + '(progn + (require 'ert) + (require 'jka-compr) + (setq load-suffixes '(".el" ".elc") + load-prefer-newer t))) + "-l" script))))) + (unless (equal status 0) + (ert-fail (format "Documentation child exited %S:\n%s" + status (buffer-string))))))) + (delete-file script)))) + +(ert-deftest etaf-docs-readme-examples-mount-and-dispatch-in-fresh-emacs () + "Both READMEs must work from their exact requires in isolated processes." + (dolist (file '("README.md" "README.zh-CN.md")) + (let ((examples (etaf-docs-test--marked-examples + (etaf-docs-test--read file)))) + (should (equal '("hello" "card" "counter") (mapcar #'car examples))) + (dolist (example examples) + (etaf-docs-test--run-fresh + (cdr example) + (pcase (car example) + ("hello" + '(let ((runtime (etaf-runtime-for-buffer "*etaf-hello*"))) + (should runtime) + (should (equal "Hello ETAF" + (etaf-dispatch-event runtime 'hello 'press))) + (etaf-unmount runtime))) + ("card" + '(let ((runtime (etaf-runtime-for-buffer "*etaf-card*"))) + (should runtime) + (with-current-buffer "*etaf-card*" + (should (string-match-p + "Account[[:space:]]+Connected[[:space:]]+Footer" + (buffer-string)))) + (etaf-unmount runtime))) + ("counter" + '(let ((runtime (etaf-runtime-for-buffer "*etaf-counter*"))) + (should runtime) + (etaf-dispatch-event runtime 'increment 'press) + (with-current-buffer "*etaf-counter*" + (should (string-match-p "Count: 1" (buffer-string)))) + (etaf-unmount runtime))))))))) + +(ert-deftest etaf-docs-context-and-theme-examples-mount-in-fresh-emacs () + "Run exact Context and Theme examples without prior definitions or imports." + (dolist (file '("docs/user-guide.en.md" "docs/user-guide.zh.md" + "docs/architecture.en.md" "docs/architecture.zh.md")) + (let ((examples (etaf-docs-test--marked-examples + (etaf-docs-test--read file)))) + (should (equal '("context" "theme") + (sort (mapcar #'car examples) #'string<))) + (dolist (example examples) + (etaf-docs-test--run-fresh + (cdr example) + (pcase (car example) + ("context" + '(let ((runtime (etaf-runtime-for-buffer "*etaf-context*"))) + (should runtime) + (with-current-buffer "*etaf-context*" + (should (string-match-p "Service: demo-service" + (buffer-string)))) + (etaf-unmount runtime))) + ("theme" + '(let* ((runtime (etaf-runtime-for-buffer "*etaf-theme*")) + (props (gethash 'themed-content + (etaf-runtime-host-props runtime)))) + (should runtime) + (should (equal "#F4F6FB" (plist-get props :color))) + (should (equal "#202634" (plist-get props :background-color))) + (with-current-buffer "*etaf-theme*" + (goto-char (point-min)) + (should (search-forward "Themed content" nil t)) + (let ((face (get-text-property (1- (point)) 'face))) + (should (string-match-p "#F4F6FB" (format "%S" face))) + (should (string-match-p "#202634" (format "%S" face))))) + (etaf-unmount runtime))))))))) + (defun etaf-docs-test--mounted-collection-probe (forms) "Load exact documentation FORMS and mount the collection composition." (etaf-component-redefine-run @@ -120,7 +237,12 @@ (let ((contents (etaf-docs-test--read file))) (should-not (string-match-p - (regexp-opt '("etaf-template" "etaf-create-app")) contents)) + (regexp-opt '("etaf-template" "etaf-create-app" "expr :value" + "ETAF also registers the short alias" + "registry assigns a semantic" + "ETAF 也会注册短 alias" + "注册表会分配语义明确的")) + contents)) (should-not (string-match-p "(text \"[^\"]+\" :" contents)))) (let ((guide (etaf-docs-test--read "docs/user-guide.en.md"))) (dolist (token '("etaf-view" "etaf-mount" "etaf-define-component" @@ -216,6 +338,25 @@ (should (alist-get 'root-shape-forwarding-guarantee entry)) (should (eq t (alist-get 'mounted-validation entry)))))) +(defun etaf-docs-test--snapshot-probe (forms) + "Run exact snapshot FORMS after one local publication in the documented app." + (let ((label (etaf-ref "Before")) + (buffer-name "*etaf-hello*")) + (should-not (get-buffer buffer-name)) + (unwind-protect + (progn + (etaf-mount buffer-name (etaf-view (text (expr (etaf-value label))))) + (setf (etaf-value label) "Current") + (let ((result (eval (cons 'progn forms) t))) + (should (= 3 (length result))) + (should (and (integerp (nth 0 result)) (> (nth 0 result) 0))) + (should (integerp (nth 1 result))) + (should (equal "Current" (substring-no-properties (nth 2 result)))))) + (when-let* ((runtime (etaf-runtime-for-buffer buffer-name))) + (etaf-unmount runtime)) + (when-let* ((buffer (get-buffer buffer-name))) + (kill-buffer buffer))))) + (ert-deftest etaf-docs-executable-suite-is-fail-closed () "Classify exact blocks before macroexpansion, loading, or mounted smoke." (let ((fixture (expand-file-name @@ -249,11 +390,13 @@ (pcase (plist-get record :probe) ('collection-composition (etaf-docs-test--mounted-collection-probe forms)) + ('runtime-snapshot + (etaf-docs-test--snapshot-probe forms)) ('nil (dolist (form forms) (eval form t))) (_ (ert-fail "Unknown safe documentation probe"))))))))))) (ert-deftest etaf-source-uses-only-public-ebox-names () - "Keep the ETAF implementation independent of Ebox private functions." + "Reject Ebox private functions, fields and dynamic construction context." (dolist (file (append (directory-files etaf-docs-test--root t "\\.el\\'") @@ -262,7 +405,9 @@ t "\\.el\\'"))) (with-temp-buffer (insert-file-contents file) - (should-not (re-search-forward "ebox--" nil t))))) + ;; Include module-qualified names such as canonical input accessors; + ;; checking only the facade prefix misses those boundary violations. + (should-not (re-search-forward "\\_ (etaf-render-port-revision buffer) revision)))))) + +(ert-deftest etaf-dynamic-components-flush-busy-returns-committed-revision () + "A busy flush returns the last publication and never implicitly exports." + (let ((label (etaf-ref "Before"))) + (etaf-dynamic-test--with-runtime (etaf-view (text (expr (etaf-value label)))) + (let ((revision (etaf-render-port-revision buffer))) + (cl-letf (((symbol-function 'etaf-runtime-snapshot) + (lambda (&rest _) (ert-fail "Flush exported Runtime"))) + ((symbol-function 'etaf-render-port-snapshot) + (lambda (&rest _) (ert-fail "Flush exported Ebox"))) + ((symbol-function 'etaf--runtime-render-root-turn) + (lambda (&rest _) (ert-fail "Flush rebuilt Root")))) + (should (= revision (etaf-runtime-flush runtime))) + (unwind-protect + (progn + (setf (etaf-runtime-event-depth runtime) 1) + (setf (etaf-value label) "After") + (should (= revision (etaf-runtime-flush runtime))) + (should (etaf-runtime-pending-p runtime)) + (should (equal "Before" (etaf-dynamic-test--text buffer)))) + (setf (etaf-runtime-event-depth runtime) 0)) + (should (> (etaf-runtime-flush runtime) revision)) + (should (equal "After" (etaf-dynamic-test--text buffer))) + (should (= (etaf-render-port-revision buffer) + (etaf-runtime-flush runtime)))))))) + +(ert-deftest etaf-dynamic-components-removed-slot-selects-fallback () + (let ((source (etaf-ref (etaf-node 'etaf-dynamic-test-slot-inner + nil '("Supplied"))))) + (etaf-dynamic-test--with-runtime (lambda () (etaf-value source)) + (should (equal "Supplied" (etaf-dynamic-test--text buffer))) + (setf (etaf-value source) + (etaf-node 'etaf-dynamic-test-slot-inner nil nil)) + (should (equal "FALLBACK" (etaf-dynamic-test--text buffer)))))) + +(ert-deftest etaf-dynamic-components-empty-candidate-props-and-slots () + "Slot programs observe present empty inputs in their caller's environment." + (let* ((children (list "Supplied")) + (source (etaf-ref (etaf-node 'etaf-dynamic-test-slot-outer + '(:label "A") children)))) + (etaf-dynamic-test--with-runtime (lambda () (etaf-value source)) + (should (string-match-p "A.*HAS-SLOT" (etaf-dynamic-test--text buffer))) + (setf (etaf-value source) + (etaf-node 'etaf-dynamic-test-slot-outer nil children)) + (should (string-match-p "EMPTY.*HAS-SLOT" (etaf-dynamic-test--text buffer))) + (setf (etaf-value source) + (etaf-node 'etaf-dynamic-test-slot-outer nil nil)) + (should (string-match-p "EMPTY.*NO-SLOT" (etaf-dynamic-test--text buffer))) + (etaf-dispatch-event runtime 'etaf-dynamic-slot-snapshot 'press) + (should (equal '((nil nil)) etaf-dynamic-test-actions))))) + +(ert-deftest etaf-dynamic-components-empty-candidate-inputs-rollback () + "Rejected empty inputs leave the published caller snapshot intact." + (let* ((original (etaf-node 'etaf-dynamic-test-slot-outer + '(:label "A") '("Supplied"))) + (source (etaf-ref original))) + (etaf-dynamic-test--with-runtime (lambda () (etaf-value source)) + (let ((generation (etaf-runtime-current-generation runtime)) + (published (with-current-buffer buffer (buffer-string)))) + (cl-letf (((symbol-function 'etaf--runtime-swap-generation) + (lambda (&rest _) (error "Reject empty input candidate")))) + (should-error + (setf (etaf-value source) + (etaf-node 'etaf-dynamic-test-slot-outer nil nil)))) + (should (eq generation (etaf-runtime-current-generation runtime))) + (should (equal-including-properties + published (with-current-buffer buffer (buffer-string)))) + (etaf-dispatch-event runtime 'etaf-dynamic-slot-snapshot 'press) + (should (equal '(("A" t)) etaf-dynamic-test-actions)) + (setf (etaf-value source) original) + (setf (etaf-value source) + (etaf-node 'etaf-dynamic-test-slot-outer nil nil)) + (should (string-match-p "EMPTY.*NO-SLOT" (etaf-dynamic-test--text buffer))))))) + +(ert-deftest etaf-dynamic-components-root-slot-context-matches-component-author () + "Root-owned slots use root Context while ordinary factories use consumers." + (dolist (wrapped '(nil t)) + (let ((label (etaf-ref "A")) + (cell (lambda () + (etaf-view (etaf-dynamic-test-context-reader))))) + (etaf-dynamic-test--with-runtime + (if wrapped + (etaf-view (etaf-dynamic-test-context-author :label label :cell cell)) + (etaf-view + (etaf-dynamic-test-context-provider :cell cell + (text (expr (concat (etaf-inject 'etaf-dynamic-slot-context "ROOT") + ":" (etaf-value label)))) + (etaf-dynamic-test-context-reader)))) + (should (string-match-p "\\`ROOT:A[[:space:]]+ROOT[[:space:]]+INNER[[:space:]]*\\'" + (etaf-dynamic-test--text buffer))) + (setf (etaf-value label) "B") + (should (string-match-p "\\`ROOT:B[[:space:]]+ROOT[[:space:]]+INNER[[:space:]]*\\'" + (etaf-dynamic-test--text buffer))) + (let ((generation (etaf-runtime-current-generation runtime)) + (published (with-current-buffer buffer (buffer-string)))) + (should-error (setf (etaf-value label) 42)) + (should (eq generation (etaf-runtime-current-generation runtime))) + (should (equal-including-properties + published (with-current-buffer buffer (buffer-string))))) + (setf (etaf-value label) "C") + (should (string-match-p "\\`ROOT:C[[:space:]]+ROOT[[:space:]]+INNER[[:space:]]*\\'" + (etaf-dynamic-test--text buffer))))))) + +(ert-deftest etaf-dynamic-components-retain-singleton-sequence-state () + (let* ((item (etaf-dynamic-test--counter "A")) + (source (etaf-ref item))) + (etaf-dynamic-test--with-runtime + (etaf-view (etaf-dynamic-test-owner :source source)) + (let ((state (cdar etaf-dynamic-test-setups))) + (setf (etaf-value state) 3) + (setf (etaf-value source) (list item (etaf-dynamic-test--counter "B"))) + (should (= 2 (length etaf-dynamic-test-setups))) + (should (string-match-p "A:3" (etaf-dynamic-test--text buffer))) + (setf (etaf-value source) item) + (should (= 2 (length etaf-dynamic-test-setups))) + (should (equal '("B") etaf-dynamic-test-disposals)) + (should (string-match-p "A:3" (etaf-dynamic-test--text buffer))) + (setf (etaf-value source) nil) + (should (equal '("A" "B") etaf-dynamic-test-disposals)) + (should (equal 1 (cl-count '("A" . unmounted) + etaf-dynamic-test-hooks :test #'equal))))))) + +(ert-deftest etaf-dynamic-components-fragment-composes-material-components () + (let ((source (etaf-ref (etaf-node 'etaf-dynamic-test-pair nil nil)))) + (etaf-dynamic-test--with-runtime + (etaf-view (etaf-dynamic-test-owner :source source)) + (should (= 2 (length etaf-dynamic-test-setups))) + (setf (etaf-value (cdr (assoc "A" etaf-dynamic-test-setups))) 3) + (should (string-match-p "A:3" (etaf-dynamic-test--text buffer))) + (should (string-match-p "B:0" (etaf-dynamic-test--text buffer))) + (setf (etaf-value source) nil) + (should (= 2 (length etaf-dynamic-test-disposals)))))) + +(ert-deftest etaf-dynamic-components-material-child-props-update-retains-hosts () + (let* ((label (etaf-ref "A")) + (source (etaf-ref (etaf-view (etaf-dynamic-test-wrapper + :label (etaf-value label)))))) + (etaf-dynamic-test--with-runtime + (etaf-view (etaf-dynamic-test-owner :source source)) + (setf (etaf-value (cdar etaf-dynamic-test-setups)) 4) + (setf (etaf-value label) "B") + (should (= 1 (length etaf-dynamic-test-setups))) + (should-not etaf-dynamic-test-disposals) + (should (string-match-p "B:4" (etaf-dynamic-test--text buffer)))))) + +(ert-deftest etaf-dynamic-components-keyed-first-update-retains-programmatic-hosts () + "Initial and incremental keyed items use the same descendant positions." + (let ((items (etaf-ref '((one . "A"))))) + (etaf-dynamic-test--with-runtime + (etaf-view + (column + (etaf-dynamic-test-programmatic-keyed-item + :for (entry (etaf-value items)) :key (car entry) :entry entry))) + (let* ((ref (caar (etaf-runtime-handler-entries runtime))) + (ancestry (gethash ref (etaf-runtime-host-ancestries runtime (list ref))))) + (etaf-dispatch-event runtime ref 'press) + (should (string-match-p "A:1" (etaf-dynamic-test--text buffer))) + (dolist (label '("B" "C")) + (setf (etaf-value items) (list (cons 'one label))) + (should (= 1 (length etaf-dynamic-test-setups))) + (should-not etaf-dynamic-test-disposals) + (should (equal ref (caar (etaf-runtime-handler-entries runtime)))) + (should (equal ancestry + (gethash ref (etaf-runtime-host-ancestries runtime (list ref))))) + (should (string-match-p (concat label ":1") + (etaf-dynamic-test--text buffer)))) + (etaf-dispatch-event runtime ref 'press) + (should (equal '("C" "A") etaf-dynamic-test-actions)) + (should (string-match-p "C:2" (etaf-dynamic-test--text buffer))))))) + +(defun etaf-dynamic-test--keyed-fragment-view (source &optional stateful) + "Return keyed forests from SOURCE, optionally containing STATEFUL counters." + (etaf-view + (column + (fragment :for (entry (etaf-value source)) :key (car entry) + (expr + (mapcar + (lambda (label) + (cond + ((eq label 'fail) (etaf-node 'etaf-dynamic-test-failure nil nil)) + (stateful (etaf-dynamic-test--counter label)) + (t (etaf-node 'text nil (list label))))) + (cdr entry))))))) + +(ert-deftest etaf-dynamic-components-keyed-fragment-mount-matches-pure () + "Empty, single and multiple roots remain grouped by their logical key." + (dolist (items '(((only)) ((only "A")) ((only "A" "B")) + ((empty) (pair "A" "B") (single "C")))) + (let* ((source (etaf-ref items)) + (view (etaf-dynamic-test--keyed-fragment-view source)) + (pure (substring-no-properties (ebox-render (etaf-render view))))) + (etaf-dynamic-test--with-runtime view + (should (equal pure (etaf-dynamic-test--text buffer))) + (dolist (next '(((only)) ((only "one")) ((only "left" "right")) + ((only)))) + (setf (etaf-value source) next) + (should + (equal + (substring-no-properties + (ebox-render (etaf-render + (etaf-dynamic-test--keyed-fragment-view source)))) + (etaf-dynamic-test--text buffer)))))))) + +(ert-deftest etaf-dynamic-components-keyed-fragment-spans-retain-state () + "Per-key forests retain state as items grow, shrink, reorder or fail." + (let ((source (etaf-ref '((empty) (pair "A" "B") (single "C"))))) + (etaf-dynamic-test--with-runtime + (etaf-dynamic-test--keyed-fragment-view source t) + (let* ((generation (etaf-runtime-current-generation runtime)) + (effect-id (car (etaf--generation-source-effects generation source))) + (range (etaf--generation-effect-semantic generation effect-id)) + (spans (etaf--semantic-range-keyed-item-node-span-index range))) + ;; Total output cardinality happens to equal item count here. Each + ;; logical key must still own its own zero-, two-, or one-node span. + (should (equal '(0 . 0) (gethash 'empty spans))) + (should (equal '(0 . 2) (gethash 'pair spans))) + (should (equal '(2 . 1) (gethash 'single spans)))) + (should (= 3 (length etaf-dynamic-test-setups))) + (setf (etaf-value (cdr (assoc "A" etaf-dynamic-test-setups))) 7) + (let* ((refs (mapcar #'car (etaf-runtime-handler-entries runtime))) + (ancestries (etaf-runtime-host-ancestries runtime refs)) + (a-ref + (cl-find-if + (lambda (ref) + (let ((bounds (etaf-host-ref-bounds runtime ref))) + (with-current-buffer buffer + (string-match-p + "A:7" (buffer-substring-no-properties + (car bounds) (cdr bounds)))))) + refs))) + (should a-ref) + (setf (etaf-value source) '((single "C") (pair "A" "B") (empty))) + (should (equal '("C:0" "A:7" "B:0") + (split-string (etaf-dynamic-test--text buffer)))) + (should (= 3 (length etaf-dynamic-test-setups))) + (setf (etaf-value source) '((single "C") (pair "A2" "B2") (empty "D"))) + (should (= 4 (length etaf-dynamic-test-setups))) + (dolist (ref refs) + (should (equal (gethash ref ancestries) + (gethash ref (etaf-runtime-host-ancestries runtime refs))))) + (setf (etaf-value source) '((single "C") (pair "A3") (empty "D"))) + (should (equal '("B") etaf-dynamic-test-disposals)) + (setf (etaf-value source) '((single "C") (pair "A4" "B3") (empty "D"))) + (should (= 5 (length etaf-dynamic-test-setups))) + (should (equal '("C:0" "A4:7" "B3:0" "D:0") + (split-string (etaf-dynamic-test--text buffer)))) + (let ((generation (etaf-runtime-current-generation runtime)) + (published (with-current-buffer buffer (buffer-string)))) + (should-error + (setf (etaf-value source) + '((new "N") (pair "A5" fail) (single "C") (empty "D")))) + (should (eq generation (etaf-runtime-current-generation runtime))) + (should (equal-including-properties + published (with-current-buffer buffer (buffer-string)))) + (should (member "N" etaf-dynamic-test-disposals)) + (should-not (member '("N" . mounted) etaf-dynamic-test-hooks))) + (setf (etaf-value source) '((single "C") (pair "A4" "B3") (empty "D"))) + (should (equal (gethash a-ref ancestries) + (gethash a-ref + (etaf-runtime-host-ancestries runtime (list a-ref))))) + (etaf-dispatch-event runtime a-ref 'press) + (should (equal '("A4") etaf-dynamic-test-actions)) + (should (string-match-p "A4:8" (etaf-dynamic-test--text buffer))) + (setf (etaf-value source) '((single "C") (empty "D"))) + (should-not (etaf-runtime-handler-for runtime a-ref)) + (setf (etaf-value source) nil) + (should (equal "" (etaf-dynamic-test--text buffer))) + (should (equal '("A" "B" "B3" "C" "D" "N") + (sort (copy-sequence etaf-dynamic-test-disposals) + #'string<))))))) + +(ert-deftest etaf-dynamic-components-root-owned-expression-updates () + (let ((source (etaf-ref nil))) + (etaf-dynamic-test--with-runtime + (etaf-view (column (expr (etaf-value source)))) + (setf (etaf-value source) (etaf-dynamic-test--counter "root")) + (setf (etaf-value (cdar etaf-dynamic-test-setups)) 2) + (should (string-match-p "root:2" (etaf-dynamic-test--text buffer))) + (setf (etaf-value source) nil) + (should (equal '("root") etaf-dynamic-test-disposals))))) + +(ert-deftest etaf-dynamic-components-remount-then-update-reuses-new-instance () + (let ((source (etaf-ref (etaf-dynamic-test--counter "A" 'a)))) + (etaf-dynamic-test--with-runtime + (etaf-view (etaf-dynamic-test-owner :source source)) + (setf (etaf-value source) nil) + (setf (etaf-value source) (etaf-dynamic-test--counter "A" 'a)) + (let ((state (cdar etaf-dynamic-test-setups))) + (should (= 2 (length etaf-dynamic-test-setups))) + (setf (etaf-value state) 5) + (setf (etaf-value source) (etaf-dynamic-test--counter "A2" 'a)) + (should (= 2 (length etaf-dynamic-test-setups))) + (should (string-match-p "A2:5" (etaf-dynamic-test--text buffer))))))) + +(ert-deftest etaf-dynamic-components-keyed-reorder-remove-and-duplicate () + (let ((source (etaf-ref (list (etaf-dynamic-test--counter "A" 'a) + (etaf-dynamic-test--counter "B" 'b))))) + (etaf-dynamic-test--with-runtime + (etaf-view (etaf-dynamic-test-owner :source source)) + (let ((state-a (cdr (assoc "A" etaf-dynamic-test-setups)))) + (setf (etaf-value state-a) 7) + (setf (etaf-value source) + (list (etaf-dynamic-test--counter "B2" 'b) + (etaf-dynamic-test--counter "A2" 'a))) + (should (= 2 (length etaf-dynamic-test-setups))) + (should (string-match-p "A2:7" (etaf-dynamic-test--text buffer))) + (let ((generation (etaf-runtime-current-generation runtime)) + (text (etaf-dynamic-test--text buffer))) + (should-error + (setf (etaf-value source) + (list (etaf-dynamic-test--counter "bad" 'a) + (etaf-dynamic-test--counter "duplicate" 'a))) + :type 'etaf-runtime-error) + (should (eq generation (etaf-runtime-current-generation runtime))) + (should (equal text (etaf-dynamic-test--text buffer)))))))) + +(ert-deftest etaf-dynamic-components-reused-description-isolates-instances () + (let* ((item (etaf-dynamic-test--counter "shared-description")) + (source (etaf-ref (list item item)))) + (etaf-dynamic-test--with-runtime + (etaf-view (etaf-dynamic-test-owner :source source)) + (should (= 2 (length etaf-dynamic-test-setups))) + (let ((states (mapcar #'cdr etaf-dynamic-test-setups))) + (should-not (eq (car states) (cadr states))) + (setf (etaf-value (car states)) 8) + (should (string-match-p "shared-description:8" (etaf-dynamic-test--text buffer))) + (should (string-match-p "shared-description:0" (etaf-dynamic-test--text buffer))))))) + +(defun etaf-dynamic-test--helper (source) + "Return a reused compiled Host and Expr site reading SOURCE." + (etaf-view (column (expr (etaf-value source))))) + +(defun etaf-dynamic-test--expr-helper (source) + "Return a reused expression site with no intervening Host." + (etaf-view (expr (etaf-value source)))) + +(defun etaf-dynamic-test--deferred-helper (left right calls) + "Return independent LEFT/RIGHT text programs counted in CALLS." + (etaf-view + (column + (text (expr (progn (cl-incf (aref calls 0)) (etaf-value left)))) + (text (expr (progn (cl-incf (aref calls 1)) (etaf-value right))))))) + +(defun etaf-dynamic-test--lexical-helper (value) + "Return inline and structural programs capturing the current VALUE." + (etaf-view + (column + (text :ref 'lexical-inline (expr value)) + (column + (expr (etaf-node 'text (list :ref 'lexical-structural) (list value))))))) + +(ert-deftest etaf-dynamic-components-parent-range-retargets-lexical-programs () + "Fresh lexical inputs update stable child Range owners and roll back errors." + (let ((source (etaf-ref "Before"))) + (etaf-dynamic-test--with-runtime + (etaf-view (column (expr (etaf-dynamic-test--lexical-helper + (etaf-value source))))) + (let* ((generation (etaf-runtime-current-generation runtime)) + (inline-ancestry + (gethash 'lexical-inline + (etaf-runtime-host-ancestries runtime '(lexical-inline)))) + (structural-ancestry + (gethash 'lexical-structural + (etaf-runtime-host-ancestries runtime '(lexical-structural)))) + (published (with-current-buffer buffer (buffer-string)))) + (should (string-match-p "Before[[:space:]]+Before" + (etaf-dynamic-test--text buffer))) + (should-error (setf (etaf-value source) 42)) + (should (eq generation (etaf-runtime-current-generation runtime))) + (should (equal-including-properties + published (with-current-buffer buffer (buffer-string)))) + (setf (etaf-value source) "After") + (should (string-match-p "After[[:space:]]+After" + (etaf-dynamic-test--text buffer))) + (dolist (entry (list (cons 'lexical-inline inline-ancestry) + (cons 'lexical-structural structural-ancestry))) + (should (equal (cdr entry) + (gethash (car entry) + (etaf-runtime-host-ancestries + runtime (list (car entry))))))))))) + +(defun etaf-dynamic-test--batch-lexical-helper (value source calls kind) + "Return KIND programs combining lexical VALUE and reactive SOURCE." + (etaf-node + 'column nil + (append + (when (memq kind '(inline both)) + (list + (etaf-view + (text :ref 'batch-inline + (expr (progn + (cl-incf (aref calls 1)) + (concat value "/" (etaf-value source)))))))) + (when (memq kind '(structural both)) + (list + (etaf-view + (column + (expr + (progn + (cl-incf (aref calls 2)) + (etaf-node 'text (list :ref 'batch-structural) + (list (concat value "/" (etaf-value source))))))))))))) + +(defun etaf-dynamic-test--check-parent-child-batch (kind) + "Check KIND nested programs for both parent/child notification orders." + (dolist (child-first '(nil t)) + (let ((outer (etaf-ref "A")) + (inner (etaf-ref "1")) + (calls (vector 0 0 0))) + (etaf-dynamic-test--with-runtime + (etaf-view + (column + (expr + (progn + (cl-incf (aref calls 0)) + (when (etaf-value outer) + (etaf-dynamic-test--batch-lexical-helper + (etaf-value outer) inner calls kind)))))) + (let* ((refs (pcase kind + ('inline '(batch-inline)) + ('structural '(batch-structural)) + (_ '(batch-inline batch-structural)))) + (ancestries (etaf-runtime-host-ancestries runtime refs)) + (version (etaf-runtime-generation runtime))) + (cl-labels + ((check-output + (value) + (should + (equal (split-string (etaf-dynamic-test--text buffer)) + (make-list (length refs) value)))) + (update + (parent child) + (etaf-reactive-call-with-batch + (lambda () + (if child-first + (setf (etaf-value inner) child + (etaf-value outer) parent) + (setf (etaf-value outer) parent + (etaf-value inner) child)))))) + (check-output "A/1") + (update "B" "2") + (check-output "B/2") + (should (= (1+ version) (etaf-runtime-generation runtime))) + (should (equal calls + (pcase kind + ('inline [2 2 0]) + ('structural [2 0 2]) + (_ [2 2 2])))) + (dolist (ref refs) + (should (equal (gethash ref ancestries) + (gethash ref + (etaf-runtime-host-ancestries runtime refs))))) + (let ((generation (etaf-runtime-current-generation runtime)) + (published (with-current-buffer buffer (buffer-string)))) + (should-error (update 42 "3")) + (should (eq generation (etaf-runtime-current-generation runtime))) + (should (equal-including-properties + published (with-current-buffer buffer (buffer-string))))) + (update "C" "4") + (check-output "C/4") + (let ((parent-calls (aref calls 0))) + (setf (etaf-value inner) "5") + (check-output "C/5") + (should (= parent-calls (aref calls 0)))) + (let ((child-calls (list (aref calls 1) (aref calls 2)))) + (update nil "6") + (should (equal "" (etaf-dynamic-test--text buffer))) + (should (equal child-calls + (list (aref calls 1) (aref calls 2))))))))))) + +(ert-deftest etaf-dynamic-components-parent-child-batch-inline () + "Inline descendants use the fresh parent closure once in a shared batch." + (etaf-dynamic-test--check-parent-child-batch 'inline)) + +(ert-deftest etaf-dynamic-components-parent-child-batch-structural () + "Structural descendants use one coherent candidate in a shared batch." + (etaf-dynamic-test--check-parent-child-batch 'structural)) + +(ert-deftest etaf-dynamic-components-parent-child-batch-mixed () + "Mixed descendants retain ownership through batches, rollback and removal." + (etaf-dynamic-test--check-parent-child-batch 'both)) + +(ert-deftest etaf-dynamic-components-helper-expr-keeps-dependencies-local () + "Direct and structural-expression use retain the same child dependencies." + (dolist (wrapped '(nil t)) + (let ((left (etaf-ref "Left")) + (right (etaf-ref "Right")) + (calls (vector 0 0))) + (etaf-dynamic-test--with-runtime + (if wrapped + (etaf-view + (column (expr (etaf-dynamic-test--deferred-helper + left right calls)))) + (etaf-dynamic-test--deferred-helper left right calls)) + (should (equal calls [1 1])) + (setf (etaf-value left) "LEFT") + (should (equal calls [2 1])) + (should (string-match-p "LEFT[[:space:]]+Right" + (etaf-dynamic-test--text buffer))) + (setf (etaf-value right) "RIGHT") + (should (equal calls [2 2])) + (let ((generation (etaf-runtime-current-generation runtime)) + (published (with-current-buffer buffer (buffer-string)))) + (should-error (setf (etaf-value left) 42)) + (should (eq generation (etaf-runtime-current-generation runtime))) + (should (equal-including-properties + published (with-current-buffer buffer (buffer-string)))) + (should (= 2 (aref calls 1)))))))) + +(defun etaf-dynamic-test--keyed-helper (left right) + "Return two independent keyed Component ranges over LEFT and RIGHT." + (etaf-view + (column + (etaf-dynamic-test-counter :for (item (etaf-value left)) :key item :label "A") + (etaf-dynamic-test-counter :for (item (etaf-value right)) :key item :label "B")))) + +(ert-deftest etaf-dynamic-components-helper-expr-keeps-keyed-scopes () + "Equal keys at distinct helper sites own separate state and disposal." + (dolist (wrapped '(nil t)) + (let ((left (etaf-ref '(1))) + (right (etaf-ref '(1)))) + (etaf-dynamic-test--with-runtime + (if wrapped + (etaf-view + (column (expr (etaf-dynamic-test--keyed-helper left right)))) + (etaf-dynamic-test--keyed-helper left right)) + (should (= 2 (length etaf-dynamic-test-setups))) + (let ((state-a (cdr (assoc "A" etaf-dynamic-test-setups))) + (state-b (cdr (assoc "B" etaf-dynamic-test-setups)))) + (should-not (eq state-a state-b)) + (setf (etaf-value state-a) 7) + (should (string-match-p "A:7[[:space:]]+B:0" + (etaf-dynamic-test--text buffer))) + (setf (etaf-value left) nil) + (should (equal '("A") etaf-dynamic-test-disposals)) + (setf (etaf-value state-b) 8) + (should (string-match-p "B:8" (etaf-dynamic-test--text buffer))) + (should (= 2 (length etaf-dynamic-test-setups)))))))) + +(ert-deftest etaf-dynamic-components-reused-expression-sites-remain-independent () + (let ((left (etaf-ref (etaf-dynamic-test--counter "left"))) + (right (etaf-ref (etaf-dynamic-test--counter "right")))) + (etaf-dynamic-test--with-runtime + (etaf-node 'column nil + (list (etaf-dynamic-test--expr-helper left) + (etaf-dynamic-test--expr-helper right))) + (should (= 2 (length etaf-dynamic-test-setups))) + (setf (etaf-value left) nil) + (should (equal '("left") etaf-dynamic-test-disposals)) + (should (string-match-p "right:0" (etaf-dynamic-test--text buffer)))))) + +(ert-deftest etaf-dynamic-components-helper-sites-isolate-hosts-and-ranges () + (let ((left (etaf-ref (etaf-dynamic-test--counter "left"))) + (right (etaf-ref (etaf-dynamic-test--counter "right")))) + (etaf-dynamic-test--with-runtime + (etaf-node 'column nil + (list (etaf-dynamic-test--helper left) + (etaf-dynamic-test--helper right))) + (should (= 2 (length etaf-dynamic-test-setups))) + (setf (etaf-value left) nil) + (should (equal '("left") etaf-dynamic-test-disposals)) + (should (string-match-p "right:0" (etaf-dynamic-test--text buffer)))))) + +(ert-deftest etaf-dynamic-components-invalid-output-has-public-context () + (let ((source (etaf-ref nil))) + (etaf-dynamic-test--with-runtime + (etaf-view (etaf-dynamic-test-owner :source source)) + (let ((condition (should-error (setf (etaf-value source) '(text "raw")) + :type 'etaf-runtime-error))) + (should (string-match-p "etaf-dynamic-test-owner" (error-message-string condition))) + (should (string-match-p "View" (error-message-string condition))) + (should-not (string-match-p "Step4" (error-message-string condition))))))) + +(ert-deftest etaf-dynamic-components-nested-shapes-and-type-replacement () + (let ((source (etaf-ref nil))) + (etaf-dynamic-test--with-runtime + (etaf-view (etaf-dynamic-test-owner :source source)) + (dolist (shape + (list "bare" + (etaf-view (text "host")) + (etaf-view + (fragment (text "fragment") + (expr (etaf-dynamic-test--counter "nested")))))) + (setf (etaf-value source) shape)) + (should (string-match-p "fragment" (etaf-dynamic-test--text buffer))) + (should (string-match-p "nested:0" (etaf-dynamic-test--text buffer))) + (setf (etaf-value source) (etaf-dynamic-test--counter "direct" 'same)) + (should (equal '("nested") etaf-dynamic-test-disposals)) + (setf (etaf-value source) + (etaf-node 'etaf-dynamic-test-pure + '(:label "replacement" :key same) nil)) + (should (equal '("direct" "nested") etaf-dynamic-test-disposals)) + (should (string-match-p "replacement" (etaf-dynamic-test--text buffer)))))) + +(ert-deftest etaf-dynamic-components-fragment-keeps-nested-range-boundaries () + (let* ((left (etaf-ref "left")) + (right (etaf-ref "right")) + (source (etaf-ref (etaf-node 'etaf-dynamic-test-range-pair + (list :left left :right right) nil)))) + (etaf-dynamic-test--with-runtime + (etaf-view (etaf-dynamic-test-owner :source source)) + (let* ((generation (etaf-runtime-current-generation runtime)) + (pair (cl-loop for identity being the hash-keys of + (etaf-generation-identity-index generation) + when (eq (car-safe identity) 'etaf-dynamic-test-range-pair) + return (etaf--generation-semantic generation identity)))) + (should (eq 'transparent (etaf--semantic-component-publication-kind pair)))) + (setf (etaf-value left) "LEFT") + (should (string-match-p "LEFT" (etaf-dynamic-test--text buffer))) + (setf (etaf-value right) "RIGHT") + (should (string-match-p "RIGHT" (etaf-dynamic-test--text buffer)))))) + +(ert-deftest etaf-dynamic-components-two-apps-own-their-scopes () + (let* ((item (etaf-dynamic-test--counter "A")) + (source (etaf-ref item)) + (view (etaf-view (etaf-dynamic-test-owner :source source)))) + (etaf-dynamic-test--with-runtime view + (let ((other (generate-new-buffer " *etaf-dynamic-other*")) + (first-state (cdar etaf-dynamic-test-setups))) + (unwind-protect + (progn + (etaf-mount other view) + (should (= 2 (length etaf-dynamic-test-setups))) + (setf (etaf-value first-state) 4) + (should (string-match-p "A:4" (etaf-dynamic-test--text buffer))) + (should (string-match-p "A:0" (etaf-dynamic-test--text other))) + (etaf-unmount (etaf-runtime-for-buffer other)) + (should (= 1 (length etaf-dynamic-test-disposals))) + (setf (etaf-value first-state) 5) + (should (string-match-p "A:5" (etaf-dynamic-test--text buffer)))) + (etaf-dynamic-test--close other)))))) + +(etaf-define-component etaf-dynamic-test-failure () + :render (error "dynamic sibling render failure")) + +(defun etaf-dynamic-test--first-handler (runtime) + "Return a committed press target from RUNTIME." + (caar (etaf--generation-index-entries + (etaf-runtime-current-generation runtime) 'handlers))) + +(ert-deftest etaf-dynamic-components-failure-restores-state-handlers-and-scopes () + (let ((source (etaf-ref (list (etaf-dynamic-test--counter "A" 'a))))) + (etaf-dynamic-test--with-runtime + (etaf-view (etaf-dynamic-test-owner :source source)) + (let ((generation (etaf-runtime-current-generation runtime)) + (target (etaf-dynamic-test--first-handler runtime)) + (registry-count (hash-table-count (etaf-runtime-resource-registry runtime)))) + (should-error + (setf (etaf-value source) + (list (etaf-dynamic-test--counter "B" 'a) + (etaf-dynamic-test--counter "new" 'new) + (etaf-node 'etaf-dynamic-test-failure nil nil))) + :type 'error) + (should (eq generation (etaf-runtime-current-generation runtime))) + (should (= registry-count (hash-table-count (etaf-runtime-resource-registry runtime)))) + (should (equal '("new") etaf-dynamic-test-disposals)) + (should-not (member '("new" . mounted) etaf-dynamic-test-hooks)) + ;; Restore the external source before dispatch, whose ordinary batch + ;; end legitimately retries any still-dirty business value. + (setf (etaf-value source) (list (etaf-dynamic-test--counter "A" 'a))) + (etaf-dispatch-event runtime target :on-press) + (should (equal '("A") etaf-dynamic-test-actions)) + (should (string-match-p "A:1" (etaf-dynamic-test--text buffer))))))) + +(defun etaf-dynamic-test--keyed-host (key label) + "Return one compiled keyed Host with a nested stateful LABEL Component." + (etaf-view (column :key key (etaf-dynamic-test-counter :label label)))) + +(ert-deftest etaf-dynamic-components-keyed-host-reorder-retains-descendants () + (let ((source (etaf-ref (list (etaf-dynamic-test--keyed-host 'a "A") + (etaf-dynamic-test--keyed-host 'b "B"))))) + (etaf-dynamic-test--with-runtime + (etaf-view (etaf-dynamic-test-owner :source source)) + (let* ((generation (etaf-runtime-current-generation runtime)) + (host-id + (cl-loop for identity being the hash-keys of + (etaf--semantic-range-item-identity-index + (cl-loop for identity being the hash-keys of + (etaf-generation-identity-index generation) + when (eq (car-safe identity) 'range) + return (etaf--generation-semantic generation identity))) + using (hash-values semantic-id) + when (equal (plist-get (cddr identity) :key) 'a) + return semantic-id)) + (host-ref (etaf--semantic-host-host-ref + (etaf--pvec-get (etaf-generation-semantic-nodes generation) host-id)))) + (setf (etaf-value (cdr (assoc "A" etaf-dynamic-test-setups))) 6) + (setf (etaf-value source) + (list (etaf-dynamic-test--keyed-host 'b "B") + (etaf-dynamic-test--keyed-host 'a "A"))) + (should (= 2 (length etaf-dynamic-test-setups))) + (should (string-match-p "A:6" (etaf-dynamic-test--text buffer))) + (should-not etaf-dynamic-test-disposals) + (should (equal host-ref + (etaf--semantic-host-host-ref + (etaf--pvec-get + (etaf-generation-semantic-nodes + (etaf-runtime-current-generation runtime)) host-id)))))))) + +(ert-deftest etaf-dynamic-components-pure-stateless-output-matches-mounted () + (let* ((view (etaf-view + (column (expr (list (etaf-view (etaf-dynamic-test-pure :label "A")) + (etaf-view (text "B"))))))) + (pure (substring-no-properties (ebox-render (etaf-render view))))) + (etaf-dynamic-test--with-runtime view + (should (equal pure (etaf-dynamic-test--text buffer)))))) + +(etaf-define-component etaf-dynamic-test-theme-alias (&key defaults property color) + :setup (etaf-theme-provide defaults) + :render (etaf-node 'box (list property color) (list "theme"))) + +(ert-deftest etaf-dynamic-components-theme-default-respects-property-alias () + (dolist (case '((:bgcolor :background-color "#abcdef") + (:background-color :bgcolor "#abcdef") + (:bgcolor :background-color nil))) + (let* ((defaults (etaf-ref (list (car case) "#123456"))) + (property (cadr case)) + (color (caddr case))) + (etaf-dynamic-test--with-runtime + (etaf-view (etaf-dynamic-test-theme-alias + :defaults defaults :property property :color color)) + (should (string-match-p "theme" (etaf-dynamic-test--text buffer))) + (setf (etaf-value defaults) (list (car case) "#654321")) + (should (string-match-p "theme" (etaf-dynamic-test--text buffer))))))) + +(etaf-define-component etaf-dynamic-test-theme-layout + (&key palette behavior on-press) + :setup (etaf-theme-provide palette) + :styles (styles ("&" :padding (1 2)) + (".content" :item-gap 3)) + :view + (column :width '(100) :height 8 :overflow 'scroll + :color (etaf-theme-token :fg) + :bgcolor (etaf-theme-token :bg) + (row :class "content" + (text :ref 'theme-layout-first :role 'button :disabled nil + :use behavior :on-press on-press + :color (etaf-theme-token :fg) "First") + (text :ref 'theme-layout-second "Second")))) + +(ert-deftest etaf-dynamic-components-theme-preserves-anonymous-host-layout () + "Theme-only effects retain full Host geometry without semantic attributes." + (let ((palette (etaf-ref '(:fg "#111111" :bg "#ffffff")))) + (etaf-dynamic-test--with-runtime + (etaf-view (etaf-dynamic-test-theme-layout :palette palette)) + (let* ((state (ebox--buffer-render-state buffer)) + (source-index (plist-get state :source-index)) + (root (plist-get state :root-node)) + (geometry + (cl-loop for (property value) on + (ebox-tree-node-source-declarations source-index root) + by #'cddr + unless (memq property '(ebox/color ebox/background-color)) + append (list property value))) + (text (etaf-dynamic-test--text buffer)) + (bounds (etaf-host-ref-bounds runtime 'theme-layout-first))) + (should (equal (plist-get geometry 'ebox/width) '(100))) + (should (equal (plist-get geometry 'ebox/padding-inline-start) 2)) + (dolist (next '((:fg "#eeeeee" :bg "#111111") + (:fg "#111111" :bg "#ffffff"))) + (setf (etaf-value palette) next) + (let* ((next-state (ebox--buffer-render-state buffer)) + (next-root (plist-get next-state :root-node)) + (next-declarations + (ebox-tree-node-source-declarations + (plist-get next-state :source-index) next-root))) + (should + (equal geometry + (cl-loop for (property value) on next-declarations by #'cddr + unless (memq property + '(ebox/color ebox/background-color)) + append (list property value)))) + (should (equal text (etaf-dynamic-test--text buffer))) + (should (equal bounds + (etaf-host-ref-bounds runtime 'theme-layout-first))) + (should + (equal + (with-current-buffer buffer + (cl-some + (lambda (spec) (and (listp spec) (plist-get spec :foreground))) + (cdr (assq (tp-paint-slot-face (plist-get next-root :color)) + face-remapping-alist)))) + (plist-get next :fg))))))))) + +(ert-deftest etaf-dynamic-components-theme-retains-composed-callbacks () + "Theme deltas and rollback keep callbacks once and retain Behavior resources." + (let* ((palette (etaf-ref '(:fg "#111111" :bg "#ffffff"))) + (installs 0) (cleanups 0) (calls nil) + (behavior + (etaf-behavior-create + 'theme-layout + :on-press (lambda () (push 'behavior calls)) + :install (lambda () + (cl-incf installs) + (lambda () (cl-incf cleanups))))) + (captured 'original) + (on-press (let ((value captured)) + (lambda () (push value calls))))) + (etaf-dynamic-test--with-runtime + (etaf-view (etaf-dynamic-test-theme-layout + :palette palette :behavior behavior :on-press on-press)) + (setq captured 'changed) + (dolist (next '((:fg "#eeeeee" :bg "#111111") + (:fg "#111111" :bg "#ffffff"))) + (setf (etaf-value palette) next) + (setq calls nil) + (etaf-dispatch-event runtime 'theme-layout-first 'press) + (should (equal calls '(behavior original))) + (should (= installs 1)) + (should (= cleanups 0)) + (let ((props (etaf-runtime-host-props-for runtime 'theme-layout-first))) + (should (eq (plist-get props :role) 'button)) + (should-not (plist-get props :disabled)))) + (cl-letf (((symbol-function 'etaf--runtime-swap-generation) + (lambda (&rest _) (error "Reject Theme candidate")))) + (should-error + (setf (etaf-value palette) '(:fg "#eeeeee" :bg "#111111")))) + (setq calls nil) + (etaf-dispatch-event runtime 'theme-layout-first 'press) + (should (equal calls '(behavior original))) + (should (= installs 1)) + (should (= cleanups 0))) + (should (= cleanups 1)))) + +(provide 'etaf-dynamic-components-tests) +;;; etaf-dynamic-components-tests.el ends here diff --git a/tests/etaf-event-forwarding-tests.el b/tests/etaf-event-forwarding-tests.el new file mode 100644 index 0000000..be64c51 --- /dev/null +++ b/tests/etaf-event-forwarding-tests.el @@ -0,0 +1,789 @@ +;;; etaf-event-forwarding-tests.el --- Composable Host interaction tests -*- lexical-binding: t; -*- + +;; SPDX-License-Identifier: GPL-3.0-or-later + +;;; Commentary: +;; Exercise fallthrough, committed disabled state, and interaction boundaries. + +;;; Code: + +(require 'ert) +(require 'etaf) + +(defvar etaf-forward-test--trace nil) +(defvar etaf-forward-test--inner-use nil) +(defvar etaf-forward-test--middle-use nil) +(defvar etaf-forward-test--inner-disabled nil) + +(defun etaf-forward-test--record (item) + "Append ITEM to the current interaction trace." + (setq etaf-forward-test--trace (append etaf-forward-test--trace (list item)))) + +(defun etaf-forward-test--dispose (buffer) + "Unmount and kill test BUFFER." + (when-let* ((runtime (etaf-runtime-for-buffer buffer))) + (etaf-unmount runtime)) + (when-let* ((live (get-buffer buffer))) (kill-buffer live))) + +(etaf-define-component etaf-forward-test-leaf (&key on-press use disabled ref) + :view (text :ref ref :role 'button :tab-index 0 :disabled disabled + :on-press on-press :use use "Press")) + +(etaf-define-component etaf-forward-test-middle () + :view (etaf-forward-test-leaf + :ref 'internal + :disabled (if (etaf-ref-p etaf-forward-test--inner-disabled) + (etaf-value etaf-forward-test--inner-disabled) + etaf-forward-test--inner-disabled) + :use etaf-forward-test--inner-use + :on-press (lambda () (etaf-forward-test--record 'inner)))) + +(etaf-define-component etaf-forward-test-outer () + :view (etaf-forward-test-middle + :use etaf-forward-test--middle-use + :on-press (lambda () (etaf-forward-test--record 'middle)))) + +(etaf-define-component etaf-forward-test-business (&key on-change) + :view (text :role 'checkbox :aria-checked nil + :on-press (let ((change on-change)) + (lambda () + (etaf-forward-test--record 'business) + (funcall change t))) + "Toggle")) + +(etaf-define-component etaf-forward-test-layout (&key width classes use renders) + :render + (progn + (cl-incf (aref renders 0)) + (if (eq use 'absent) + (etaf-view (box :ref 'layout :width (etaf-value width) + :class (etaf-value classes) (text "Text"))) + (etaf-view (box :ref 'layout :width (etaf-value width) + :class (etaf-value classes) :use use (text "Text")))))) + +(ert-deftest etaf-forward-behavior-keeps-host-property-dependencies-local () + "Omitted, empty, and active `:use' preserve the same Host update boundary." + (let ((installs 0) (cleanups 0) installed-props) + (dolist (use (list 'absent nil (etaf-focusable) + (etaf-behavior-create + 'layout-probe + :install + (lambda () + (cl-incf installs) + (setq installed-props + (etaf-behavior-context-host-props + (etaf-current-behavior-context))) + (lambda () (cl-incf cleanups)))))) + (let ((buffer (generate-new-buffer " *etaf-behavior-layout*")) + (width (etaf-ref 10)) + (classes (etaf-ref '(first))) + (renders (vector 0))) + (unwind-protect + (progn + (etaf-mount + buffer (etaf-node 'etaf-forward-test-layout + (list :width width :classes classes + :use use :renders renders) nil)) + (let ((runtime (etaf-runtime-for-buffer buffer))) + (should (equal renders [1])) + (setf (etaf-value width) 12) + (should (= 12 (plist-get (etaf-runtime-host-props-for runtime 'layout) + :width))) + (should (equal renders [1])) + (setf (etaf-value classes) '(second)) + (should (equal '(second) + (plist-get (etaf-runtime-host-props-for runtime 'layout) + :class))) + (should (equal renders [1])) + (let ((generation (etaf-runtime-current-generation runtime))) + (cl-letf (((symbol-function 'etaf--runtime-swap-generation) + (lambda (&rest _) (error "Reject layout candidate")))) + (should-error (setf (etaf-value width) 14))) + (should (eq generation (etaf-runtime-current-generation runtime))) + (should (= 12 (plist-get (etaf-runtime-host-props-for runtime 'layout) + :width))) + (should (equal renders [1]))))) + (etaf-forward-test--dispose buffer)))) + (should (= installs 1)) + (should (= cleanups 1)) + (should (= 10 (plist-get installed-props :width))) + (should (equal '(first) (plist-get installed-props :class))) + (should (eq 'layout (plist-get installed-props :ref))))) + +(ert-deftest etaf-forward-declared-events-and-use-compose-once () + "Consume declared callback/use props once through two root wrappers." + (let ((buffer " *etaf-forward-events*") + (etaf-forward-test--trace nil) + (etaf-forward-test--inner-use + (etaf-behavior-create + 'inner :on-press (lambda () (etaf-forward-test--record 'behavior-inner)))) + (etaf-forward-test--middle-use + (etaf-behavior-create + 'middle :on-press (lambda () (etaf-forward-test--record 'behavior-middle))))) + (unwind-protect + (progn + (etaf-mount + buffer + (etaf-view + (etaf-forward-test-outer + :ref 'target + :on-press (lambda () (etaf-forward-test--record 'outer)) + :use (etaf-behavior-create + 'outer :on-press + (lambda () (etaf-forward-test--record 'behavior-outer)))))) + (let ((runtime (etaf-runtime-for-buffer buffer))) + (etaf-dispatch-event runtime 'target 'press) + (should (equal etaf-forward-test--trace + '(inner middle outer behavior-inner + behavior-middle behavior-outer))) + (should-not (etaf-runtime-handler-for runtime 'internal)))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-disabled-or-recomputes-and-guards-public-input () + "Outer nil never enables an internally disabled Host; later inputs recover." + (let* ((buffer " *etaf-forward-disabled*") + (outer-disabled (etaf-ref nil)) + (inner-disabled (etaf-ref t)) + (etaf-forward-test--inner-disabled inner-disabled) + (etaf-forward-test--trace nil)) + (unwind-protect + (progn + (etaf-mount + buffer + (lambda () + (etaf-view (etaf-forward-test-middle + :ref 'target :disabled (etaf-value outer-disabled))))) + (let ((runtime (etaf-runtime-for-buffer buffer))) + (should (plist-get (etaf-runtime-host-props-for runtime 'target) + :disabled)) + (should-error (etaf-dispatch-event runtime 'target 'press) + :type 'etaf-event-error) + (should-error (etaf-focus runtime 'target) :type 'etaf-event-error) + (setf (etaf-value inner-disabled) nil) + (etaf-dispatch-event runtime 'target 'press) + (should (equal etaf-forward-test--trace '(inner))) + (setf (etaf-value outer-disabled) t) + (should-error (etaf-dispatch-event runtime 'target 'press) + :type 'etaf-event-error) + (setf (etaf-value outer-disabled) nil) + (etaf-dispatch-event runtime 'target 'press) + (should (equal etaf-forward-test--trace '(inner inner))))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-host-attribute-ownership () + "Presentation defaults and caller labels coexist with owned role/state." + (let* ((root '(:color "red" :class "base" :role button :aria-checked nil + :tab-index 0 :aria-label "Default" :ref fallback)) + (merged (etaf--merge-host-attrs + root '(:color nil :class (caller base) :tab-index 2 + :aria-label "Caller" :ref target) 'text 'example))) + (should (equal "red" (plist-get merged :color))) + (should (equal '("base" "caller") (plist-get merged :class))) + (should (= 2 (plist-get merged :tab-index))) + (should (equal "Caller" (plist-get merged :aria-label))) + (should (eq 'target (plist-get merged :ref))) + (should-error (etaf--merge-host-attrs root '(:role navigation) 'text 'example) + :type 'etaf-component-call-error) + (should-error (etaf--merge-host-attrs root '(:aria-checked t) 'text 'example) + :type 'etaf-component-call-error))) + +(ert-deftest etaf-forward-business-conversion-precedes-subscriptions () + "A consumed change prop remains the business conversion for one press." + (let ((buffer " *etaf-forward-conversion*") + (etaf-forward-test--trace nil) (value nil)) + (unwind-protect + (progn + (etaf-mount + buffer + (etaf-view + (etaf-forward-test-business + :ref 'target + :on-change (lambda (next) + (setq value next) + (etaf-forward-test--record 'change)) + :on-press (lambda () (etaf-forward-test--record 'outer)) + :use (etaf-behavior-create + 'observer :on-press + (lambda () (etaf-forward-test--record 'behavior)))))) + (etaf-dispatch-event (etaf-runtime-for-buffer buffer) 'target 'press) + (should value) + (should (equal etaf-forward-test--trace + '(business change outer behavior)))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-wrapper-duplicate-behaviors-fail-before-install () + "Do not lose duplicate names when declared use props consume fallthrough." + (let* ((buffer " *etaf-forward-duplicate-use*") + (installs 0) + (behavior (etaf-behavior-create + 'duplicate :install (lambda () (cl-incf installs) nil))) + (etaf-forward-test--inner-use behavior)) + (unwind-protect + (progn + (should-error + (etaf-mount buffer (etaf-view (etaf-forward-test-middle :use behavior))) + :type 'etaf-behavior-error) + (should (zerop installs))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-failed-callback-stops-outer-subscriptions () + "An inner callback error stops outer subscriptions and Behaviors." + (let ((buffer " *etaf-forward-callback-failure*") + (etaf-forward-test--trace nil)) + (unwind-protect + (progn + (etaf-mount + buffer + (etaf-view + (etaf-forward-test-business + :ref 'target :on-change (lambda (_) (error "change failed")) + :on-press (lambda () (etaf-forward-test--record 'outer)) + :use (etaf-behavior-create + 'observer :on-press + (lambda () (etaf-forward-test--record 'behavior)))))) + (should-error + (etaf-dispatch-event (etaf-runtime-for-buffer buffer) 'target 'press)) + (should (equal etaf-forward-test--trace '(business)))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-behavior-final-props-precede-any-installer () + "Installers receive final merged semantic props, after validation." + (let ((buffer " *etaf-forward-behavior-props*") (installs 0) (observed nil)) + (unwind-protect + (let ((first + (etaf-behavior-create + 'first :install + (lambda () + (cl-incf installs) + (setq observed + (etaf-behavior-context-host-props + (etaf-current-behavior-context))) + nil)))) + (etaf-mount + buffer + (etaf-view + (text :ref 'target + :use (list first + (etaf-behavior-create 'label :aria-label "Final")) + "Target"))) + (should (= installs 1)) + (should (equal "Final" (plist-get observed :aria-label))) + (etaf-unmount (etaf-runtime-for-buffer buffer)) + (setq installs 0) + (should-error + (etaf-mount + buffer + (etaf-view + (text :ref 'target + :use (list first + (etaf-behavior-create 'invalid :disabled 'wrong)) + "Target"))) + :type 'etaf-renderer-error) + (should (zerop installs))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-disabled-behavior-never-installs () + "Final disabled state, including Behavior defaults, precedes installation." + (let ((buffer " *etaf-forward-behavior-disabled*") (installs 0)) + (unwind-protect + (progn + (etaf-mount + buffer + (etaf-view + (text :ref 'target :disabled nil + :use (list + (etaf-behavior-create + 'first :install (lambda () (cl-incf installs) nil)) + (etaf-behavior-create 'disabled :disabled t)) + "Disabled"))) + (should (zerop installs)) + (should (plist-get (etaf-runtime-host-props-for + (etaf-runtime-for-buffer buffer) 'target) + :disabled))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-behavior-disabled-transitions-and-rollback () + "Commit disables clean up once; failed disables preserve the old resource." + (let ((buffer " *etaf-forward-behavior-transition*") + (mode (etaf-ref 'enabled)) + (installs 0) (cleanups 0)) + (unwind-protect + (let ((behavior (etaf-behavior-create + 'resource :install + (lambda () (cl-incf installs) + (lambda () (cl-incf cleanups)))))) + (etaf-mount + buffer + (lambda () + (etaf-view + (column + (text :ref 'target :use behavior + :disabled (not (eq (etaf-value mode) 'enabled)) "Target") + (text (expr (if (eq (etaf-value mode) 'failed) + (error "later sibling failed") "OK"))))))) + (should (= installs 1)) + (should-error (setf (etaf-value mode) 'failed)) + (should (= cleanups 0)) + (should-not (plist-get (etaf-runtime-host-props-for + (etaf-runtime-for-buffer buffer) 'target) + :disabled)) + (setf (etaf-value mode) 'disabled) + (should (= cleanups 1)) + (setf (etaf-value mode) 'enabled) + (should (= installs 2)) + (etaf-unmount (etaf-runtime-for-buffer buffer)) + (should (= cleanups 2))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-failed-enable-disposes-only-candidate-behavior () + "A failed enable cleans its new resource while committed Host stays disabled." + (let ((buffer " *etaf-forward-enable-rollback*") + (mode (etaf-ref 'disabled)) (installs 0) (cleanups 0)) + (unwind-protect + (let ((behavior + (etaf-behavior-create + 'resource :install + (lambda () (cl-incf installs) + (lambda () (cl-incf cleanups)))))) + (etaf-mount + buffer + (etaf-view + (column + (text :ref 'target :use behavior + :disabled (eq (etaf-value mode) 'disabled) "Target") + (text (expr (if (eq (etaf-value mode) 'failed) + (error "later sibling failed") "OK")))))) + (should (zerop installs)) + (should-error (setf (etaf-value mode) 'failed)) + (should (= installs 1)) + (should (= cleanups 1)) + (should (plist-get (etaf-runtime-host-props-for + (etaf-runtime-for-buffer buffer) 'target) + :disabled)) + (setf (etaf-value mode) 'enabled) + (should (= installs 2)) + (etaf-unmount (etaf-runtime-for-buffer buffer)) + (should (= cleanups 2))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-component-overlay-promotes-behavior-lifetime () + "A locally enabled Component owns its installed Behavior through teardown." + (let ((buffer " *etaf-forward-overlay-behavior*") + (disabled (etaf-ref t)) (installs 0) (cleanups 0)) + (unwind-protect + (let ((behavior + (etaf-behavior-create + 'resource :install + (lambda () (cl-incf installs) + (lambda () (cl-incf cleanups)))))) + ;; Passing a View value keeps dependency ownership on the Component + ;; input effect, exercising local overlay instead of Root rebuild. + (etaf-mount buffer + (etaf-view (etaf-forward-test-leaf + :ref 'target :use behavior + :disabled (etaf-value disabled)))) + (should (zerop installs)) + (setf (etaf-value disabled) nil) + (should (= installs 1)) + (should (zerop cleanups)) + (setf (etaf-value disabled) t) + (should (= cleanups 1)) + (setf (etaf-value disabled) nil) + (should (= installs 2)) + (etaf-unmount (etaf-runtime-for-buffer buffer)) + (should (= cleanups 2))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-root-fallback-releases-abandoned-behavior () + "A local anchor proof miss releases candidate resources before Root retry." + (let ((buffer " *etaf-forward-fallback-behavior*") + (disabled (etaf-ref t)) (installs 0) (cleanups 0) + (miss-next t)) + (unwind-protect + (let ((behavior + (etaf-behavior-create + 'resource :install + (lambda () (cl-incf installs) + (lambda () (cl-incf cleanups)))))) + (etaf-mount + buffer + (etaf-view + (column + (etaf-forward-test-leaf :ref 'target :use behavior + :disabled (etaf-value disabled)) + ;; Read here to change this structural Range. A text child's + ;; deferred Expr correctly owns its own independent update. + (expr (etaf-node + 'text nil + (list (if (etaf-value disabled) "Disabled" "Enabled"))))))) + (let ((original + (symbol-function 'etaf--runtime-range-change-has-backend-anchor-p))) + (cl-letf (((symbol-function 'etaf--runtime-range-change-has-backend-anchor-p) + (lambda (runtime change) + (if miss-next + (progn (setq miss-next nil) nil) + (funcall original runtime change))))) + (setf (etaf-value disabled) nil))) + (should-not miss-next) + (should (= installs 2)) + (should (= cleanups 1)) + (etaf-unmount (etaf-runtime-for-buffer buffer)) + (should (= cleanups 2))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-removed-component-retires-behavior-immediately () + "A removed dynamic child releases its Behavior without waiting for unmount." + (let ((buffer " *etaf-forward-removed-behavior*") + (visible (etaf-ref t)) (installs 0) (cleanups 0)) + (unwind-protect + (let ((behavior + (etaf-behavior-create + 'resource :install + (lambda () (cl-incf installs) + (lambda () (cl-incf cleanups)))))) + (etaf-mount + buffer + (etaf-view (column + (etaf-forward-test-leaf :if (etaf-value visible) + :ref 'target :use behavior)))) + (should (= installs 1)) + (setf (etaf-value visible) nil) + (should (= cleanups 1)) + (should-not + (etaf--generation-index-entries + (etaf-runtime-current-generation (etaf-runtime-for-buffer buffer)) + 'behaviors)) + (setf (etaf-value visible) t) + (should (= installs 2)) + (etaf-unmount (etaf-runtime-for-buffer buffer)) + (should (= cleanups 2))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-component-overlay-removes-use-resources () + "Changing or removing use retires obsolete names on a retained Host." + (let ((buffer " *etaf-forward-overlay-use*") + (use (etaf-ref nil)) (installs 0) (cleanups 0)) + (unwind-protect + (let* ((install (lambda () (cl-incf installs) + (lambda () (cl-incf cleanups)))) + (first (etaf-behavior-create 'first :install install)) + (second (etaf-behavior-create 'second :install install))) + (setf (etaf-value use) first) + (etaf-mount + buffer (etaf-view (etaf-forward-test-leaf + :ref 'target :use (etaf-value use)))) + (should (= installs 1)) + (setf (etaf-value use) second) + (should (= installs 2)) + (should (= cleanups 1)) + (setf (etaf-value use) nil) + (should (= cleanups 2)) + (etaf-unmount (etaf-runtime-for-buffer buffer)) + (should (= cleanups 2))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-keyed-host-behaviors-follow-owner-through-reorder () + "Keyed Host resources follow semantic owners through reorder and removal." + (let ((buffer " *etaf-forward-keyed-behavior*") active events) + (unwind-protect + (let* ((behavior + (etaf-behavior-create + 'resource :install + (lambda () + (let ((ref (plist-get + (etaf-behavior-context-host-props + (etaf-current-behavior-context)) :ref))) + (push ref active) + (push (list 'install ref) events) + (lambda () + (setq active (delq ref active)) + (push (list 'cleanup ref) events)))))) + (a (etaf-node 'box (list :key 'a :ref 'a :use behavior) '("A"))) + (b (etaf-node 'box (list :key 'b :ref 'b :use behavior) '("B"))) + (items (etaf-ref (list a b)))) + (etaf-mount buffer (etaf-view (column (expr (etaf-value items))))) + (setf (etaf-value items) (list b a)) + (should (equal events '((install b) (install a)))) + (setf (etaf-value items) (list b)) + (should (equal active '(b))) + (should (equal events '((cleanup a) (install b) (install a)))) + (etaf-unmount (etaf-runtime-for-buffer buffer)) + (should-not active) + (should (= 1 (cl-count '(cleanup a) events :test #'equal))) + (should (= 1 (cl-count '(cleanup b) events :test #'equal)))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-keyed-behavior-failure-retains-committed-owners () + "Failed reorder/removal keeps committed resources and cleans only new ones." + (let ((buffer " *etaf-forward-keyed-behavior-rollback*") active events) + (unwind-protect + (let* ((behavior + (etaf-behavior-create + 'resource :install + (lambda () + (let ((ref (plist-get + (etaf-behavior-context-host-props + (etaf-current-behavior-context)) :ref))) + (push ref active) + (push (list 'install ref) events) + (lambda () + (setq active (delq ref active)) + (push (list 'cleanup ref) events)))))) + (a (etaf-node 'box (list :key 'a :ref 'a :use behavior) '("A"))) + (b (etaf-node 'box (list :key 'b :ref 'b :use behavior) '("B"))) + (c (etaf-node 'box (list :key 'c :ref 'c :use behavior) '("C"))) + (mode (etaf-ref 'initial))) + (etaf-mount + buffer + (etaf-view + (column + (expr (pcase (etaf-value mode) + ('initial (list a b)) + ('failed (list b c)) + (_ (list b)))) + (text (expr (if (eq (etaf-value mode) 'failed) + (error "later sibling failed") "OK")))))) + (let* ((runtime (etaf-runtime-for-buffer buffer)) + (generation (etaf-runtime-current-generation runtime))) + (should-error (setf (etaf-value mode) 'failed)) + (should (eq generation (etaf-runtime-current-generation runtime))) + (should (equal active '(b a))) + (should (equal events + '((cleanup c) (install c) (install b) (install a)))) + (setf (etaf-value mode) 'removed) + (should (equal active '(b))) + (should (equal (car events) '(cleanup a))) + (etaf-unmount runtime) + (should-not active) + (dolist (ref '(a b c)) + (should (= 1 (cl-count (list 'install ref) events :test #'equal))) + (should (= 1 (cl-count (list 'cleanup ref) events :test #'equal)))))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-behavior-installer-receives-effective-host-ref () + "An installer receives the effective address of an automatically referenced Host." + (let ((buffer " *etaf-forward-behavior-generated-ref*") installed-ref) + (unwind-protect + (progn + (etaf-mount + buffer + (etaf-view + (text :role 'button :on-press #'ignore + :use (etaf-behavior-create + 'resource :install + (lambda () + (setq installed-ref + (plist-get + (etaf-behavior-context-host-props + (etaf-current-behavior-context)) :ref)) + nil)) + "Control"))) + (should installed-ref) + (should (etaf-runtime-handler-for + (etaf-runtime-for-buffer buffer) installed-ref))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-behavior-address-change-reinstalls-after-commit () + "A retained Host changing its ref replaces the resource bound to that address." + (let ((buffer " *etaf-forward-behavior-ref-change*") + (ref (etaf-ref 'a)) active events) + (unwind-protect + (let ((behavior + (etaf-behavior-create + 'resource :install + (lambda () + (let ((address (plist-get + (etaf-behavior-context-host-props + (etaf-current-behavior-context)) :ref))) + (push address active) + (push (list 'install address) events) + (lambda () + (setq active (delq address active)) + (push (list 'cleanup address) events))))))) + (etaf-mount + buffer + (etaf-view (box :key 'stable :ref (etaf-value ref) :use behavior "Host"))) + (setf (etaf-value ref) 'b) + (should (equal active '(b))) + (should (equal events '((cleanup a) (install b) (install a)))) + (etaf-unmount (etaf-runtime-for-buffer buffer)) + (should-not active) + (should (equal (car events) '(cleanup b)))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-carried-component-keeps-behavior-resource () + "A Root update carrying an unchanged Component retains its Host resource." + (let ((buffer " *etaf-forward-carried-behavior*") + (version (etaf-ref 0)) (installs 0) (cleanups 0)) + (unwind-protect + (let ((behavior + (etaf-behavior-create + 'resource :install + (lambda () (cl-incf installs) + (lambda () (cl-incf cleanups)))))) + (etaf-mount + buffer + (lambda () + (etaf-value version) + (etaf-view + (column + (etaf-forward-test-leaf :ref 'target :use behavior))))) + (should (= installs 1)) + (setf (etaf-value version) 1) + (should (= installs 1)) + (should (zerop cleanups)) + (etaf-unmount (etaf-runtime-for-buffer buffer)) + (should (= cleanups 1))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-callback-snapshot-changes-only-after-commit () + "Composed subscriptions preserve committed scalar props after render fails." + (let ((buffer " *etaf-forward-callback-commit*") + (version (etaf-ref 'a)) (seen nil) + (etaf-forward-test--trace nil)) + (unwind-protect + (progn + (etaf-mount + buffer + (etaf-view + (column + (etaf-forward-test-middle + :ref 'target + :on-press (let ((snapshot (etaf-value version))) + (lambda () (push snapshot seen)))) + (text (expr (if (eq (etaf-value version) 'failed) + (error "later sibling failed") "OK")))))) + (let ((runtime (etaf-runtime-for-buffer buffer))) + (etaf-dispatch-event runtime 'target 'press) + (should-error (setf (etaf-value version) 'failed)) + (etaf-dispatch-event runtime 'target 'press) + (setf (etaf-value version) 'b) + (etaf-dispatch-event runtime 'target 'press) + (should (equal seen '(b a a))) + (should (equal etaf-forward-test--trace '(inner inner inner))))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-child-boundary-blocks-parent-activation () + "A disabled or callbackless child owns its hit area, including equal bounds." + (let ((buffer " *etaf-forward-hit-boundary*") (trace nil)) + (unwind-protect + (dolist (child-props '((:disabled t :on-press ignore) nil)) + (etaf-mount + buffer + (lambda () + (etaf-node + 'box (list :ref 'aaa-parent :role 'row :tab-index 0 + :on-press (lambda () (push 'parent trace))) + (list (etaf-node + 'text (append '(:ref zzz-child :role button) child-props) + '("Child")))))) + (let* ((runtime (etaf-runtime-for-buffer buffer)) + (position (etaf-host-ref-position runtime 'zzz-child))) + (should (equal (etaf-host-ref-bounds runtime 'aaa-parent) + (etaf-host-ref-bounds runtime 'zzz-child))) + (should-error (etaf--activation-at-position runtime position) + :type 'user-error) + (should-not trace) + (etaf-focus runtime 'aaa-parent) + (etaf-activate runtime) + (should (equal trace '(parent))) + (setq trace nil) + (etaf-unmount runtime))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-child-wins-and-ordinary-text-belongs-to-parent () + "Semantic descendants beat equal bounds; passive text keeps row activation." + (let ((buffer " *etaf-forward-hit-order*") (trace nil)) + (unwind-protect + (dolist (interactive '(t nil)) + (etaf-mount + buffer + (lambda () + (etaf-node + 'box (list :ref 'aaa-parent + :on-press (lambda () (push 'parent trace))) + (list (etaf-node + 'text (append '(:ref zzz-child) + (when interactive + (list :role 'button :on-press + (lambda () (push 'child trace))))) + '("Child")))))) + (let ((runtime (etaf-runtime-for-buffer buffer))) + (etaf--activation-at-position + runtime (etaf-host-ref-position runtime 'zzz-child)) + (should (equal trace (if interactive '(child) '(parent)))) + (setq trace nil) + (etaf-unmount runtime))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-focused-control-follows-changing-layout () + "Repeated keyboard activation follows a retained control after width changes." + (let ((buffer " *etaf-forward-focus-layout*") (page (etaf-ref 9))) + (unwind-protect + (progn + (etaf-mount + buffer + (etaf-view + (row + (text (expr (format "Page %s" (etaf-value page)))) + (text :ref 'next :role 'button :tab-index 0 + :on-press (lambda () (cl-incf (etaf-value page))) "Next")))) + (let ((runtime (etaf-runtime-for-buffer buffer))) + (etaf-focus runtime 'next) + (etaf-activate runtime) + (should (= (etaf-value page) 10)) + (should (eq (etaf-focused-host-ref runtime) 'next)) + (with-current-buffer buffer + (should (= (point) (etaf-host-ref-position runtime 'next)))) + (etaf-activate runtime) + (should (= (etaf-value page) 11)))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-layout-update-respects-manually-moved-point () + "A layout commit must not pull point back after the user leaves a control." + (let ((buffer " *etaf-forward-focus-manual*") (page (etaf-ref 9))) + (unwind-protect + (progn + (etaf-mount + buffer + (etaf-view + (row + (text (expr (format "Page %s" (etaf-value page)))) + (text :ref 'next :role 'button :tab-index 0 "Next")))) + (let ((runtime (etaf-runtime-for-buffer buffer))) + (etaf-focus runtime 'next) + (with-current-buffer buffer (goto-char (point-min))) + (setf (etaf-value page) 10) + (with-current-buffer buffer + (should (= (point) (point-min))) + (should-not (= (point) (etaf-host-ref-position runtime 'next)))))) + (etaf-forward-test--dispose buffer)))) + +(ert-deftest etaf-forward-focused-point-survives-failed-publication () + "A failed candidate retains the old focus and point; the next commit follows." + (let ((buffer " *etaf-forward-focus-rollback*") (page (etaf-ref 9))) + (unwind-protect + (progn + (etaf-mount + buffer + (etaf-view + (row + (text (expr (format "Page %s" (etaf-value page)))) + (text :ref 'next :role 'button :tab-index 0 "Next") + (text (expr (if (= (etaf-value page) 10) + (error "later rendering failed") "OK")))))) + (let* ((runtime (etaf-runtime-for-buffer buffer)) + (generation (etaf-runtime-current-generation runtime)) + (position (etaf-host-ref-position runtime 'next))) + (etaf-focus runtime 'next) + (should-error (setf (etaf-value page) 10)) + (should (eq generation (etaf-runtime-current-generation runtime))) + (should (eq (etaf-focused-host-ref runtime) 'next)) + (with-current-buffer buffer (should (= (point) position))) + (setf (etaf-value page) 11) + (with-current-buffer buffer + (should (= (point) (etaf-host-ref-position runtime 'next)))))) + (etaf-forward-test--dispose buffer)))) + +(provide 'etaf-event-forwarding-tests) +;;; etaf-event-forwarding-tests.el ends here diff --git a/tests/etaf-m0a-current-characterization-tests.el b/tests/etaf-m0a-current-characterization-tests.el index 150926c..5da9912 100644 --- a/tests/etaf-m0a-current-characterization-tests.el +++ b/tests/etaf-m0a-current-characterization-tests.el @@ -184,11 +184,12 @@ (skip-chars-forward " \t\r\n") (should (eobp))) (should (equal golden (etaf-m0a-document-example-signatures))) - (should (= 122 (length blocks))) + (should (= 124 (length blocks))) (should (= 0 (cl-count 'read-error blocks :key (lambda (entry) (plist-get entry :drift))))) - (should (= 4 (cl-count 'macroexpand-error blocks + ;; Canonical README expressions no longer carry the four baseline errors. + (should (= 0 (cl-count 'macroexpand-error blocks :key (lambda (entry) (plist-get entry :drift))))) (should (cl-every diff --git a/tests/etaf-render-port-tests.el b/tests/etaf-render-port-tests.el index b45bf2c..8f545d7 100644 --- a/tests/etaf-render-port-tests.el +++ b/tests/etaf-render-port-tests.el @@ -41,6 +41,8 @@ 'ebox-framework-spi-update)) (should (eq (etaf-render-port-revision-function port) 'ebox-surface-buffer-revision)) + (should (eq (etaf-render-port-snapshot-function port) + 'ebox-surface-buffer-snapshot)) (should (eq (etaf-render-port-bootstrap-outcome port) 'valid-v2-selected)) (setcar capabilities 'mutated) @@ -49,6 +51,108 @@ (should-error (eval `(setf (etaf-render-port--route ',port) 'broken))))) +(ert-deftest etaf-render-port-requires-public-snapshot-query () + "An otherwise valid v2 provider cannot omit the public snapshot query." + (cl-letf (((symbol-function 'ebox-surface-buffer-snapshot) nil)) + (should-error (etaf-render-port--bootstrap) + :type 'etaf-spi-bootstrap-error))) + +(ert-deftest etaf-render-port-snapshot-forwards-owned-export-once () + "The port forwards one Ebox-owned export without copying or publishing it." + (let* ((snapshot (list :input (ebox-build '(box "snapshot")) + :revision 7 :mount-id 23)) + (calls 0)) + (with-temp-buffer + (let ((buffer (current-buffer))) + (cl-letf (((symbol-function 'ebox-surface-buffer-snapshot) + (lambda (target) + (should (eq target buffer)) + (cl-incf calls) + snapshot)) + ((symbol-function 'etaf-render-port-update) + (lambda (&rest _) (error "Snapshot published")))) + (should (eq snapshot (etaf-render-port-snapshot buffer))) + (should (= calls 1))))))) + +(ert-deftest etaf-render-port-snapshot-rejects-malformed-export () + "The adapter checks the public envelope without probing private state." + (dolist (snapshot (list nil '(:input wrong :revision 7 :mount-id 23) + (list :input (ebox-build '(box "snapshot")) + :revision "7" :mount-id 23) + (list :input (ebox-build '(box "snapshot")) + :revision 7 :mount-id nil))) + (cl-letf (((symbol-function 'ebox-surface-buffer-snapshot) + (lambda (_buffer) snapshot))) + (should-error (etaf-render-port-snapshot (current-buffer)) :type 'error)))) + +(ert-deftest etaf-render-port-snapshot-preserves-query-errors () + "An Ebox rejection remains visible and never triggers a render fallback." + (let ((condition '(user-error "snapshot is unavailable in this transaction"))) + (cl-letf (((symbol-function 'ebox-surface-buffer-snapshot) + (lambda (_buffer) (signal (car condition) (cdr condition))))) + (should (equal condition + (should-error (etaf-render-port-snapshot (current-buffer)) + :type 'user-error)))))) + +(ert-deftest etaf-runtime-flush-returns-revision-without-export () + "Explicit flush requests a drain and returns a cheap committed revision." + (require 'etaf) + (let* ((runtime (etaf--runtime-create :buffer (current-buffer))) + trace) + (cl-letf (((symbol-function 'etaf-runtime-require-mounted) + (lambda (target) (should (eq target runtime)) runtime)) + ((symbol-function 'etaf--runtime-request-flush) + (lambda (target) (should (eq target runtime)) (push 'drain trace))) + ((symbol-function 'etaf-render-port-revision) + (lambda (buffer) + (should (eq buffer (current-buffer))) + (push 'revision trace) + 13)) + ((symbol-function 'etaf-runtime-snapshot) + (lambda (&rest _) (error "Flush exported a whole tree"))) + ((symbol-function 'etaf--runtime-render-root-turn) + (lambda (&rest _) (error "Flush forced a Root rebuild")))) + (should (= 13 (etaf-runtime-flush runtime))) + (should (equal '(drain revision) (nreverse trace)))))) + +(ert-deftest etaf-runtime-flush-rejects-transaction-before-drain () + "Public flush never drains work or exposes a provisional TP revision." + (require 'etaf) + (let ((runtime (etaf--runtime-create :buffer (current-buffer))) trace) + (cl-letf (((symbol-function 'etaf-runtime-require-mounted) + (lambda (_target) runtime)) + ((symbol-function 'etaf--runtime-request-flush) + (lambda (&rest _) (push 'drain trace))) + ((symbol-function 'etaf-render-port-revision) + (lambda (&rest _) (push 'revision trace) 99))) + (tp-with-transaction + (should-error (etaf-runtime-flush runtime) :type 'etaf-runtime-error)) + (should-not trace)))) + +(ert-deftest etaf-runtime-obsolete-root-getter-compiles-as-snapshot-query () + "Compiled compatibility reads query current input, not the reserved slot." + (require 'etaf) + (require 'bytecomp) + (let* ((runtime (etaf--runtime-create :reserved-root-node 'stale-root)) + (snapshot (list :input (ebox-build '(box "Current")) + :revision 7 :mount-id 23)) + (getter (let ((byte-compile-warnings '(not obsolete))) + (byte-compile '(lambda (runtime) + (etaf-runtime-root-node runtime))))) + (calls 0)) + (should (= 3 (cl-struct-slot-offset 'etaf-runtime 'reserved-root-node))) + (should (= 4 (cl-struct-slot-offset 'etaf-runtime 'scope))) + (should-not (get 'etaf-runtime-root-node 'compiler-macro)) + (should-not (get 'etaf-runtime-root-node 'side-effect-free)) + (cl-letf (((symbol-function 'etaf-runtime-snapshot) + (lambda (target) + (should (eq target runtime)) + (cl-incf calls) + snapshot))) + (should (eq (car (ebox-canonical-input-roots (plist-get snapshot :input))) + (funcall getter runtime))) + (should (= 1 calls))))) + (ert-deftest etaf-render-port-requires-v2-provider () "Complete provider absence fails closed instead of selecting a legacy port." (let ((original-featurep (symbol-function 'featurep))) diff --git a/tests/etaf-render-view-tests.el b/tests/etaf-render-view-tests.el new file mode 100644 index 0000000..1dcae61 --- /dev/null +++ b/tests/etaf-render-view-tests.el @@ -0,0 +1,293 @@ +;;; etaf-render-view-tests.el --- Ordinary Elisp View frontend -*- lexical-binding: t; -*- + +;; SPDX-License-Identifier: GPL-3.0-or-later + +;;; Commentary: + +;; The render frontend shares View compilation and retained runtime semantics. + +;;; Code: + +(require 'ert) +(require 'bytecomp) +(require 'etaf) + +(defvar etaf-rv--renders 0) +(defvar etaf-rv--left-reads 0) +(defvar etaf-rv--right-reads 0) +(defvar etaf-rv--events nil) + +(defun etaf-rv--define (name arguments &rest clauses) + "Define test Component NAME with ARGUMENTS and CLAUSES lexically." + (etaf-component-redefine-run + (lambda () + (eval `(etaf-define-component ,name ,arguments ,@clauses) t)))) + +(defmacro etaf-rv--with-buffer (&rest body) + "Run BODY in a temporary buffer and always dispose its Runtime." + (declare (indent 0) (debug t)) + `(with-temp-buffer + (unwind-protect + (progn ,@body) + (when-let* ((runtime (etaf-runtime-for-buffer (current-buffer)))) + (etaf-unmount runtime))))) + +(ert-deftest etaf-render-view-equivalent-to-view-and-node () + "All frontends produce the same visible structure and Host properties." + (etaf-rv--define 'etaf-rv-view '(&key label) + :view '(column :padding-inline 1 + (text :font-weight 'bold (expr label)) + (text "Body"))) + (etaf-rv--define 'etaf-rv-render '(&key label) + :render '(let ((caption label)) + (etaf-view + (column :padding-inline 1 + (text :font-weight 'bold (expr caption)) + (text "Body"))))) + (etaf-rv--define 'etaf-rv-node '(&key label) + :render '(etaf-node + 'column '(:padding-inline 1) + (list (etaf-node 'text '(:font-weight bold) + (list label)) + (etaf-node 'text nil '("Body"))))) + (let ((outputs + (mapcar + (lambda (name) + (etaf-rv--with-buffer + (etaf-mount (current-buffer) + (etaf-node name '(:label "Title") nil)) + (list (buffer-substring-no-properties (point-min) (point-max)) + (progn + (goto-char (point-min)) + (search-forward "Title") + (get-text-property (match-beginning 0) 'face))))) + '(etaf-rv-view etaf-rv-render etaf-rv-node)))) + (should (equal (car outputs) (cadr outputs))) + (should (equal (car outputs) (caddr outputs))))) + +(ert-deftest etaf-render-view-prop-loop-conflicts-match-view () + "Nested Views use Component prop validation during definition expansion." + (dolist (body '((etaf-view + (column (text :for (item '("A")) :key item (expr item)))) + (let ((prefix "item")) + (etaf-view + (column (text :for (item '("A")) :key item + (expr (concat prefix item)))))) + (funcall + (lambda () + (etaf-view + (column (text :for (item '("A")) :key item + (expr item)))))))) + (let ((failure + (should-error + (macroexpand + `(etaf-define-component etaf-rv-conflict (&key item) + :render ,body)) + :type 'etaf-view-syntax-error))) + (should (string-match-p "conflicts with a Component prop" + (error-message-string failure))))) + (should-error + (macroexpand + '(etaf-define-component etaf-rv-conflict (&key item) + :view (column (text :for (item '("A")) :key item (expr item))))) + :type 'etaf-view-syntax-error)) + +(ert-deftest etaf-render-view-preserves-ordinary-lexical-shadowing () + "Let and lambda bindings shadow prop shorthand using normal Elisp scope." + (etaf-rv--define + 'etaf-rv-shadow '(&key label) + :render + '(let* ((original label) + (label "local") + (caption (funcall (lambda (label) (concat original "/" label)) + label))) + (etaf-view + (box :ref 'etaf-rv-shadow-button + :on-press (lambda () (push caption etaf-rv--events)) + (text (expr caption)))))) + (let ((etaf-rv--events nil)) + (etaf-rv--with-buffer + (etaf-mount (current-buffer) + (etaf-node 'etaf-rv-shadow '(:label "prop") nil)) + (should (equal "prop/local" (buffer-string))) + (etaf-dispatch-event (etaf-runtime-for-buffer (current-buffer)) + 'etaf-rv-shadow-button 'press) + (should (equal '("prop/local") etaf-rv--events))))) + +(ert-deftest etaf-render-view-retains-surrounding-macro-environment () + "A lexically scoped macro cannot hide a View from prop grammar checks." + (should-error + (macroexpand-all + '(cl-macrolet + ((local-view () + '(etaf-view + (column (text :for (item '("A")) :key item (expr item)))))) + (etaf-define-component etaf-rv-macro-conflict (&key item) + :render (local-view)))) + :type 'etaf-view-syntax-error)) + +(ert-deftest etaf-render-view-props-shadow-surrounding-symbol-macros () + "A Component's prop scope overrides same-named outer symbol macros." + (etaf-component-redefine-run + (lambda () + (eval + '(cl-symbol-macrolet ((label "Outer")) + (etaf-define-component etaf-rv-outer-shadow (&key label) + :render (etaf-view (text (expr label))))) + t))) + (etaf-rv--with-buffer + (etaf-mount (current-buffer) + (etaf-node 'etaf-rv-outer-shadow '(:label "Prop") nil)) + (should (equal "Prop" (buffer-string))))) + +(ert-deftest etaf-render-view-byte-compiled-callback-retains-lexical-props () + "Compiled Component definitions preserve the same delayed callback scope." + (let ((byte-compile-error-on-warn t) + (etaf-rv--events nil)) + (etaf-component-redefine-run + (lambda () + (funcall + (byte-compile + '(lambda () + (etaf-define-component etaf-rv-compiled (&key label) + :render + (let ((caption label)) + (etaf-view + (box :ref 'etaf-rv-compiled-button + :on-press (lambda () (push caption etaf-rv--events)) + (text (expr caption))))))))))) + (etaf-rv--with-buffer + (etaf-mount (current-buffer) + (etaf-node 'etaf-rv-compiled '(:label "Compiled") nil)) + (should (equal "Compiled" (buffer-string))) + (etaf-dispatch-event (etaf-runtime-for-buffer (current-buffer)) + 'etaf-rv-compiled-button 'press) + (should (equal '("Compiled") etaf-rv--events))))) + +(ert-deftest etaf-render-view-keeps-deferred-dependencies-local () + "Capturing stable handles does not pull reactive reads into Component render." + (etaf-rv--define + 'etaf-rv-deferred '(&key left right) + :setup '(list left right) + :render + '(progn + (cl-incf etaf-rv--renders) + (let* ((state (etaf-state)) + (left-ref (car state)) + (right-ref (cadr state))) + (etaf-view + (column + (text (expr (progn (cl-incf etaf-rv--left-reads) + (etaf-value left-ref)))) + (text (expr (progn (cl-incf etaf-rv--right-reads) + (etaf-value right-ref))))))))) + (let ((left (etaf-ref "Left A")) + (right (etaf-ref "Right A")) + (etaf-rv--renders 0) + (etaf-rv--left-reads 0) + (etaf-rv--right-reads 0)) + (etaf-rv--with-buffer + (etaf-mount (current-buffer) + (etaf-node 'etaf-rv-deferred (list :left left :right right) + nil)) + (let ((renders etaf-rv--renders) + (left-reads etaf-rv--left-reads) + (right-reads etaf-rv--right-reads)) + (setf (etaf-value left) "Left B") + (should (string-match-p "Left B" (buffer-string))) + (should (= renders etaf-rv--renders)) + (should (> etaf-rv--left-reads left-reads)) + (should (= right-reads etaf-rv--right-reads)))))) + +(ert-deftest etaf-render-view-projects-caller-owned-slots () + "Embedded Views distinguish projections from named inputs and caller props." + (etaf-rv--define + 'etaf-rv-panel '(&key label) + :render '(let ((heading label)) + (etaf-view + (column + (text (expr heading)) + (slot) + (slot :name 'footer (text "Fallback")))))) + (etaf-rv--define + 'etaf-rv-slot-owner '(&key label) + :render '(let ((caption label)) + (etaf-view + (etaf-rv-panel :label "Panel" + (text (expr label)) + (slot :name 'footer (text (expr (concat caption " footer")))))))) + (etaf-rv--with-buffer + (etaf-mount (current-buffer) + (etaf-node 'etaf-rv-slot-owner '(:label "Caller") nil)) + (should (string-match-p "Panel[[:space:]]+Caller[[:space:]]+Caller footer" + (buffer-string))) + (should-not (string-match-p "Fallback" (buffer-string)))) + (etaf-rv--with-buffer + (etaf-mount (current-buffer) + (etaf-node 'etaf-rv-panel '(:label "Panel") nil)) + (should (string-match-p "Fallback" (buffer-string))))) + +(ert-deftest etaf-render-view-callback-captures-committed-prop-snapshot () + "A normal lexical callback uses new committed props and survives rollback." + (etaf-rv--define + 'etaf-rv-snapshot '(&key label) + :render '(let ((caption label)) + (etaf-view + (box :ref 'etaf-rv-snapshot-button + :on-press (lambda () (push caption etaf-rv--events)) + (text (expr caption)))))) + (let ((label (etaf-ref "A")) + (etaf-rv--events nil)) + (etaf-rv--with-buffer + (etaf-mount + (current-buffer) + (lambda () + (etaf-node + 'column nil + (list (etaf-node 'etaf-rv-snapshot + (list :label (etaf-value label)) nil) + (etaf-view + (text (expr (if (equal (etaf-value label) "Failed") + (error "Rejected sibling") + "Sibling")))))))) + (let ((runtime (etaf-runtime-for-buffer (current-buffer)))) + (etaf-dispatch-event runtime 'etaf-rv-snapshot-button 'press) + (setf (etaf-value label) "B") + (etaf-dispatch-event runtime 'etaf-rv-snapshot-button 'press) + (let ((published (buffer-string))) + (should-error (setf (etaf-value label) "Failed")) + (should (equal-including-properties published (buffer-string)))) + (etaf-dispatch-event runtime 'etaf-rv-snapshot-button 'press) + (should (equal '("B" "B" "A") etaf-rv--events)))))) + +(ert-deftest etaf-render-view-callback-live-ref-is-not-a-ui-snapshot () + "An explicit shared ref remains live even when a sibling rejects its UI." + (etaf-rv--define + 'etaf-rv-live '(&key model) + :render '(let ((shared model)) + (etaf-view + (box :ref 'etaf-rv-live-button + :on-press (lambda () + (push (etaf-value shared) etaf-rv--events)) + (text "Read live"))))) + (let ((model (etaf-ref "A")) + (etaf-rv--events nil)) + (etaf-rv--with-buffer + (etaf-mount + (current-buffer) + (etaf-node + 'column nil + (list (etaf-node 'etaf-rv-live (list :model model) nil) + (etaf-view + (text (expr (if (equal (etaf-value model) "B") + (error "Rejected live value") + "Sibling"))))))) + (let ((runtime (etaf-runtime-for-buffer (current-buffer))) + (published (buffer-string))) + (should-error (setf (etaf-value model) "B")) + (should (equal-including-properties published (buffer-string))) + (etaf-dispatch-event runtime 'etaf-rv-live-button 'press) + (should (equal '("B") etaf-rv--events)))))) + +(provide 'etaf-render-view-tests) +;;; etaf-render-view-tests.el ends here diff --git a/tests/etaf-tests.el b/tests/etaf-tests.el index 05a1581..0e7595a 100644 --- a/tests/etaf-tests.el +++ b/tests/etaf-tests.el @@ -55,6 +55,7 @@ (defvar etaf-test-nested-range-present nil) (defvar etaf-test-nested-range-source nil) (defvar etaf-test-nested-range-evals 0) +(defvar etaf-test-nested-inner-range-evals 0) (defvar etaf-test-nested-outer-source nil) (defvar etaf-test-nested-inner-source nil) (defvar etaf-test-inline-shared nil) @@ -151,6 +152,7 @@ (defun etaf-test--nested-range-children () "Return keyed nested Hosts from the nested Range source." + (cl-incf etaf-test-nested-inner-range-evals) (let ((state (etaf-value etaf-test-nested-range-source))) (append (and (cdr state) @@ -351,7 +353,7 @@ (etaf-node 'column nil (list "prefix" (etaf-state) "suffix"))) (etaf-define-component etaf-test-unsupported-direct-range () - "Start with a Host Range whose later unsupported output must fail." + "Start with a Host Range and then produce nested dynamic Components." :setup (let ((range (etaf--expr-create :token 'etaf-test-unsupported-range-site @@ -884,7 +886,7 @@ (setq etaf-test-theme-cell theme) (etaf-provide 'theme theme) theme) - :view (column (slot))) + :view (column (etaf-test-consumer))) (etaf-define-component etaf-test-consumer () "Render the nearest Context theme." @@ -1938,14 +1940,13 @@ (should (equal theme left-value)))) (ert-deftest etaf-context-provide-inject-follows-component-tree () - "Resolve the nearest Context and react to its provided ref." + "Test Component inheritance; separate slot tests cover author environments." (let ((buffer-name " *etaf-context-test*")) (unwind-protect (progn (etaf-mount buffer-name (etaf-view - (etaf-test-provider - (etaf-test-consumer)))) + (etaf-test-provider))) (with-current-buffer buffer-name (should (equal "dark" (buffer-string)))) (setf (etaf-value etaf-test-theme-cell) 'light) @@ -2371,17 +2372,21 @@ Event composition is a Runtime contract, not a UI-library helper contract." (should (plist-get condition :path)) (should (= 3 (length (plist-get condition :path)))))))) -(ert-deftest etaf-runtime-skips-descendant-range-under-rendered-component () - "A freshly rendered Component absorbs its old descendant Range effect." +(ert-deftest etaf-runtime-skips-reevaluated-descendant-range-effects () + "Only actual candidate evaluation absorbs a queued descendant Range effect." (let* ((component (etaf--semantic-component-create :semantic-id 7 :identity '(parent-component))) (range (etaf--semantic-range-create :semantic-id 8 :effect-id 8 :component-id 7 :parent-id 7)) + (effect + (etaf--generation-effect-create + :effect-id 8 :kind 'range :semantic-id 8)) (generation (etaf--generation-create :generation-id 1 + :effect-map (etaf--pvec-put nil 8 effect) :semantic-nodes (etaf--pvec-put (etaf--pvec-put nil 7 component) 8 range))) (runtime @@ -2389,16 +2394,19 @@ Event composition is a Runtime contract, not a UI-library helper contract." :candidate-rendered-identities '((parent-component)) :candidate-effects (make-hash-table :test #'eql) :candidate-graph-nodes (make-hash-table :test #'eql)))) - (puthash 8 - (etaf--generation-effect-create - :effect-id 8 :kind 'range :semantic-id 8) - (etaf-runtime-candidate-effects runtime)) + (puthash 8 effect (etaf-runtime-candidate-effects runtime)) (puthash 8 range (etaf-runtime-candidate-graph-nodes runtime)) - (should (etaf--runtime-range-owned-by-rendered-component-p - runtime generation range)) + (should-not (etaf--runtime-range-effect-staged-p runtime generation effect)) + ;; Reparenting or invalidating a semantic record is not effect evaluation. + (puthash 8 (copy-sequence range) (etaf-runtime-candidate-graph-nodes runtime)) + (should-not (etaf--runtime-range-effect-staged-p runtime generation effect)) + (puthash 8 (copy-sequence effect) (etaf-runtime-candidate-effects runtime)) + (should (etaf--runtime-range-effect-staged-p runtime generation effect)) + ;; Root-owned Range ancestors have the same completed-work certificate. (setf (etaf-runtime-candidate-rendered-identities runtime) nil) - (should-not (etaf--runtime-range-owned-by-rendered-component-p - runtime generation range)))) + (should (etaf--runtime-range-effect-staged-p runtime generation effect)) + (remhash 8 (etaf-runtime-candidate-graph-nodes runtime)) + (should-not (etaf--runtime-range-effect-staged-p runtime generation effect)))) (ert-deftest etaf-stateful-props-update-without-rerunning-setup () @@ -3004,8 +3012,11 @@ Event composition is a Runtime contract, not a UI-library helper contract." (let* ((runtime (etaf-runtime-for-buffer buffer-name)) (generation (etaf-runtime-current-generation runtime)) (effects (etaf--generation-source-effects generation source)) - (parent (etaf--generation-semantic - generation '(etaf-test-local-style-parent (root))))) + (parent + (cl-loop for identity being the hash-keys of + (etaf-generation-identity-index generation) + when (eq (car-safe identity) 'etaf-test-local-style-parent) + return (etaf--generation-semantic generation identity)))) (should-not (memq (etaf--semantic-component-effect-id parent) effects)) (setf (etaf-value source) "B") @@ -3628,6 +3639,32 @@ Event composition is a Runtime contract, not a UI-library helper contract." (should (equal '(20 30) (etaf-runtime-candidate-removed-semantic-ids runtime))))) +(ert-deftest etaf-runtime-range-closure-preserves-removals-and-rejects-unknown () + "Completing a reused graph cannot revive tombstones or invent owners." + (let* ((retained (etaf--semantic-host-create :semantic-id 1 :name 'text)) + (generation + (etaf--generation-create + :semantic-nodes (etaf--pvec-put nil 1 retained))) + (runtime + (etaf--runtime-create + :generation-authority (etaf-generation-authority-create generation) + :candidate-graph-nodes (make-hash-table :test #'eql) + :candidate-graph-children (make-hash-table :test #'eql) + :candidate-removed-semantic-ids '(1)))) + (cl-letf (((symbol-function 'etaf--runtime-carry-committed-subtree) + (lambda (&rest _) (error "A removed owner was resurrected")))) + (dolist (id '(1 99)) + (let ((ids (etaf--runtime-candidate-descendant-ids runtime (list id)))) + (should (equal ids (list id))) + (should-error + (etaf--runtime-index-range-item-identities + runtime ids (make-hash-table :test #'equal)) + :type 'etaf-runtime-error)))) + (should (zerop (hash-table-count + (etaf-runtime-candidate-graph-nodes runtime)))) + (should (eq retained (etaf--pvec-get + (etaf-generation-semantic-nodes generation) 1))))) + (ert-deftest etaf-runtime-two-direct-ranges-batch-one-publication () "Evaluate and splice two disjoint Ranges once in one logical commit." (let ((buffer-name " *etaf-two-range-test*") @@ -3833,8 +3870,8 @@ Event composition is a Runtime contract, not a UI-library helper contract." (etaf-unmount runtime)) (when-let* ((buffer (get-buffer buffer-name))) (kill-buffer buffer))))) -(ert-deftest etaf-runtime-direct-range-rejects-step4b-output-without-reownership () - "Reject direct Component output while retaining Range-only dependency." +(ert-deftest etaf-runtime-direct-range-accepts-nested-components-without-reownership () + "Retain ordinary and nested Components under one Range-only dependency." (dolist (unsupported '(component deep-component deep-expr-component)) (let ((buffer-name (format " *etaf-range-unsupported-%S*" unsupported)) @@ -3845,11 +3882,11 @@ Event composition is a Runtime contract, not a UI-library helper contract." (etaf-view (etaf-test-unsupported-direct-range))) (let* ((runtime (etaf-runtime-for-buffer buffer-name)) (generation (etaf-runtime-current-generation runtime))) - (should-error - (setf (etaf-value etaf-test-unsupported-range-source) - unsupported) - :type 'etaf-runtime-error) - (should (eq generation (etaf-runtime-current-generation runtime))) + (setf (etaf-value etaf-test-unsupported-range-source) unsupported) + (should-not (eq generation (etaf-runtime-current-generation runtime))) + (setq generation (etaf-runtime-current-generation runtime)) + (should (string-match-p "component\\|deep" + (etaf-test--buffer-text buffer-name))) (should (equal '(range) (mapcar @@ -3892,7 +3929,8 @@ Event composition is a Runtime contract, not a UI-library helper contract." (let ((buffer-name " *etaf-nested-range-hosts-test*") (etaf-test-nested-range-present (etaf-ref t)) (etaf-test-nested-range-source (etaf-ref (cons "A" nil))) - (etaf-test-nested-range-evals 0)) + (etaf-test-nested-range-evals 0) + (etaf-test-nested-inner-range-evals 0)) (unwind-protect (progn (etaf-mount buffer-name (etaf-view (etaf-test-nested-host-range))) @@ -3904,19 +3942,29 @@ Event composition is a Runtime contract, not a UI-library helper contract." (range-id (etaf--generation-effect-semantic-id effect)) (range (etaf--pvec-get (etaf-generation-semantic-nodes generation) range-id)) + (outer-effect-id + (car (etaf--generation-source-effects + generation etaf-test-nested-range-present))) (nested-id (cl-loop for identity being the hash-keys of (etaf--semantic-range-item-identity-index range) using (hash-values semantic-id) when (equal (plist-get (cddr identity) :key) 'nested) return semantic-id))) - (setq etaf-test-nested-range-evals 0) + (should (= 1 etaf-test-nested-range-evals)) + (should (= 1 etaf-test-nested-inner-range-evals)) + (should-not (= effect-id outer-effect-id)) + (setq etaf-test-nested-range-evals 0 + etaf-test-nested-inner-range-evals 0) (cl-letf (((symbol-function 'etaf--runtime-render-dirty-component) (lambda (&rest _) (error "Nested Range entered Component owner")))) (setf (etaf-value etaf-test-nested-range-source) (cons "B" t))) - (should (= 1 etaf-test-nested-range-evals)) + ;; The Host boundary gives this source to the inner Range alone. + (should (= 0 etaf-test-nested-range-evals)) + (should (= 1 etaf-test-nested-inner-range-evals)) + (should (equal "S sibling B" (etaf-test--buffer-text buffer-name))) (should (= 1 (plist-get (plist-get (ebox-buffer-update-report buffer-name) :range-metrics) @@ -3940,12 +3988,17 @@ Event composition is a Runtime contract, not a UI-library helper contract." (etaf--generation-source-effects generation etaf-test-nested-range-source)))) (let* ((old-generation generation) - (old-range range) + (old-range + (etaf--generation-effect-semantic + old-generation outer-effect-id)) (removed-ids (etaf--runtime-generation-descendant-ids old-generation (etaf--semantic-range-item-root-ids old-range)))) (setf (etaf-value etaf-test-nested-range-present) nil) + (should (= 1 etaf-test-nested-range-evals)) + (should (= 1 etaf-test-nested-inner-range-evals)) + (should (equal "S" (etaf-test--buffer-text buffer-name))) (let ((new-generation (etaf-runtime-current-generation runtime))) (dolist (semantic-id removed-ids) @@ -4010,6 +4063,7 @@ Event composition is a Runtime contract, not a UI-library helper contract." (setf (etaf-value etaf-test-nested-inner-source) '((row-a . "two")))) (should (equal (list inner-ref) (nreverse replacements))) + (should (equal "A two" (etaf-test--buffer-text buffer-name))) (setq generation (etaf-runtime-current-generation runtime)) (let ((new-outer (etaf--pvec-get (etaf-generation-semantic-nodes generation) @@ -4724,8 +4778,10 @@ Event composition is a Runtime contract, not a UI-library helper contract." (let* ((runtime (etaf-runtime-for-buffer buffer-name)) (generation (etaf-runtime-current-generation runtime)) (parent - (etaf--generation-semantic - generation '(etaf-test-ancestor-artifact-parent (root))))) + (cl-loop for identity being the hash-keys of + (etaf-generation-identity-index generation) + when (eq (car-safe identity) 'etaf-test-ancestor-artifact-parent) + return (etaf--generation-semantic generation identity)))) (should (etaf--semantic-component-p parent)) (should-not (etaf--semantic-component-artifact-key parent)) (setf (etaf-value etaf-test-ancestor-parent-source) "dark") @@ -4842,6 +4898,7 @@ Event composition is a Runtime contract, not a UI-library helper contract." (let ((runtime (etaf--runtime-create :candidate-removed-host-refs '(stable other) + :behaviors (make-hash-table :test #'equal) :candidate-host-props (make-hash-table :test #'equal) :candidate-handlers (make-hash-table :test #'equal) :candidate-semantic-nodes (make-hash-table :test #'equal) @@ -4902,6 +4959,7 @@ Event composition is a Runtime contract, not a UI-library helper contract." (let ((etaf-generation-index-max-depth 4) (runtime (etaf--runtime-create + :behaviors (make-hash-table :test #'equal) :candidate-handlers (make-hash-table :test #'equal) :candidate-host-props (make-hash-table :test #'equal) :candidate-semantic-nodes (make-hash-table :test #'equal) diff --git a/tests/fixtures/etaf-m0a-document-examples.sexp b/tests/fixtures/etaf-m0a-document-examples.sexp index af3253e..f8876a0 100644 --- a/tests/fixtures/etaf-m0a-document-examples.sexp +++ b/tests/fixtures/etaf-m0a-document-examples.sexp @@ -1 +1 @@ -((:file "README.md" :blocks ((1 "90788f843ad1f654c31d7317e9a853a2b90a40d961b6c494dc5dc4b5d1d8deec" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (2 "2685e06fdf8da0d8bad106cd7bec45b7e3cff4fa70c95df519dfa908b68507e4" ok 1 error etaf-view-syntax-error skipped-unsafe arbitrary-document-code macroexpand-error) (3 "4a15a53cbb5f28620fe3fbcf779fc7ec24828efff72fcb780b29cb14d61d8690" ok 2 error etaf-view-syntax-error skipped-unsafe arbitrary-document-code macroexpand-error) (4 "cdbc6593afa8eb1a55d1ebc3b7b436c5c7a73dfdb47bca7f2e4d627e2b5efc3b" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (5 "170887ddf9784e93c815c26982fa27ada7e251b4e91d084c00e508428adf2576" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (6 "03be4130f1485ae08b5d2d5e951fbc243e13db7ab43a269d2eb4fd31a6f022ba" ok 3 ok 3 skipped-unsafe arbitrary-document-code none))) (:file "README.zh-CN.md" :blocks ((1 "90788f843ad1f654c31d7317e9a853a2b90a40d961b6c494dc5dc4b5d1d8deec" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (2 "2685e06fdf8da0d8bad106cd7bec45b7e3cff4fa70c95df519dfa908b68507e4" ok 1 error etaf-view-syntax-error skipped-unsafe arbitrary-document-code macroexpand-error) (3 "4a15a53cbb5f28620fe3fbcf779fc7ec24828efff72fcb780b29cb14d61d8690" ok 2 error etaf-view-syntax-error skipped-unsafe arbitrary-document-code macroexpand-error) (4 "a23afe4a6d314d6e2f6482ff2b20809c6d00a1eb419545286601908fd4276f7e" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (5 "170887ddf9784e93c815c26982fa27ada7e251b4e91d084c00e508428adf2576" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (6 "03be4130f1485ae08b5d2d5e951fbc243e13db7ab43a269d2eb4fd31a6f022ba" ok 3 ok 3 skipped-unsafe arbitrary-document-code none))) (:file "examples/README.md" :blocks ((1 "0668b4bd330b19e78fa26c19e36ec39b3dffbe1da9bbf0b918110e4968d92c15" ok 5 ok 5 skipped-unsafe arbitrary-document-code none))) (:file "examples/README.zh-CN.md" :blocks ((1 "0668b4bd330b19e78fa26c19e36ec39b3dffbe1da9bbf0b918110e4968d92c15" ok 5 ok 5 skipped-unsafe arbitrary-document-code none))) (:file "docs/architecture.en.md" :blocks ((1 "1b540d9726c3cf0f064baa0bf8fa6e0f7bdd1bc552adb2032d76534243c691f8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (2 "aca28b3eee51e12db137d1e1f53fb70ffb7b5bb757829d93572c238b8aad4e3b" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (3 "b0c46b25b40bd95ddeadf502d19689c4068945a7ab7e415ac52f2aacbabe5f96" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (4 "37eadf35f976fcc795ef264ebe4481bf9c62576e9d879aa7f87809a912763ad8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (5 "ba85666e995b31ed155432504b66ca82aca4b173cee242906e51df1687c0b0b0" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (6 "820db03998e5d9b72defb2149f981f0a47142a374c34fd7d06894d2a46bd4e6d" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (7 "497e5a3bf314a96a61428b217870886e20cd110b65fb1e2fa5d7e53907a1603f" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (8 "2486da6efc52edf44c791ec3931ec1b6b2261283c73893581a7dfb462f08e77e" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (9 "f930e82bef8a415d67afbc9f68bfc0d4c901840137ba1cc51696a022f2efde77" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (10 "57d8239f1aca6c75bd46d3d666e730a46b232347841f37034af66efd14e14200" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (11 "0b4a7062e7591a3c49efa40750ecf00e52380b444811e476e2413ba84f21dc24" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (12 "1eaf9b6d6df02ae197d4126314d56997a9956d1f115e51d0e172bc5d53d8b31a" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (13 "2b3627845c5667b4dde0dbf1944cfbb9e7213926f40818e644502c328bd12251" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (14 "1c406360dfdc23b26ff331468db47dcb93d710168a9b14b97ff663f637e556c8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (15 "a739e2986b545a6c49528de3f515480b99bb51a99c7c950291639ef50758088a" ok 1 ok 1 skipped-unsafe arbitrary-document-code none))) (:file "docs/architecture.zh.md" :blocks ((1 "1b540d9726c3cf0f064baa0bf8fa6e0f7bdd1bc552adb2032d76534243c691f8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (2 "aca28b3eee51e12db137d1e1f53fb70ffb7b5bb757829d93572c238b8aad4e3b" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (3 "b0c46b25b40bd95ddeadf502d19689c4068945a7ab7e415ac52f2aacbabe5f96" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (4 "37eadf35f976fcc795ef264ebe4481bf9c62576e9d879aa7f87809a912763ad8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (5 "ba85666e995b31ed155432504b66ca82aca4b173cee242906e51df1687c0b0b0" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (6 "820db03998e5d9b72defb2149f981f0a47142a374c34fd7d06894d2a46bd4e6d" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (7 "497e5a3bf314a96a61428b217870886e20cd110b65fb1e2fa5d7e53907a1603f" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (8 "2486da6efc52edf44c791ec3931ec1b6b2261283c73893581a7dfb462f08e77e" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (9 "f930e82bef8a415d67afbc9f68bfc0d4c901840137ba1cc51696a022f2efde77" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (10 "57d8239f1aca6c75bd46d3d666e730a46b232347841f37034af66efd14e14200" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (11 "0b4a7062e7591a3c49efa40750ecf00e52380b444811e476e2413ba84f21dc24" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (12 "1eaf9b6d6df02ae197d4126314d56997a9956d1f115e51d0e172bc5d53d8b31a" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (13 "2b3627845c5667b4dde0dbf1944cfbb9e7213926f40818e644502c328bd12251" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (14 "1c406360dfdc23b26ff331468db47dcb93d710168a9b14b97ff663f637e556c8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (15 "a739e2986b545a6c49528de3f515480b99bb51a99c7c950291639ef50758088a" ok 1 ok 1 skipped-unsafe arbitrary-document-code none))) (:file "docs/user-guide.en.md" :blocks ((1 "03be4130f1485ae08b5d2d5e951fbc243e13db7ab43a269d2eb4fd31a6f022ba" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (2 "3ea1c9ff2e1ca4a31ef46ac55ef3c78e7a630346f385c12621504a5470f24f56" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (3 "ebbbee1c31e6f89aca772b7ebba9756d555a108e97197cd8ee6db74f09e5f6b9" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (4 "d8963a39d3ca02c524661bf0ccc05eef1331f4305e6e568b6ed4cd021e5b2e4f" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (5 "14fb850d1f28bf6970aeb9ca0d15fa9ab4bed747b5a0671e07642cc7ff6da9f4" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (6 "947b25b44a74005f416e9556caeae6f0b6cbc95f69cc248ad4a375b321d983ba" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (7 "072830fd4b5b0cecbe51808bf1cbd39a06586f43d5729c65ac09f3a33ebdc220" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (8 "c94fcd1e44d8ef044d6372d01bef058b05581569ba3a499530ba85040f238c9c" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (9 "023c30adcfd14fc330ab07529ce32856e27209aa87b36e0cc1a50aae5f20d283" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (10 "f19f26fead0e2efc8ab26631f010518cf5a6b477990598ae20c0dd911908c838" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (11 "216d25cee8b364bcc5dc39d8458ce08db07c40fa8ce8894ae10fdde94bc52906" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (12 "eb7850ac84d549db58ea7c4c1c3b47d4eae25957be77f6e0ae2a196bbbead2f1" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (13 "4113f7b5f7f7b95ace9fae0b92b91ce33b8dfb85d534480b7cc1994b4e30187a" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (14 "502cc98bd5ec20f24764a935ca60ceb8c0de61d4507fe76530070b0516e9c016" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (15 "d5e1633dd8fa0b5edfba0bc31314fcc97e446ffa0577f3d3c9465e887d5b7903" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (16 "0944e77a00d76e6462cca3e117c2b36c3611863128321c2307dd1ff8b3696368" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (17 "1d5c4895dfaaa595e6ea821158f0bfb11d7777dd697e5660bfc00cf14f82a684" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (18 "79390863a3fdf604969cc4f424b36a9a466e569bd0ac740e61c580b2518cb2da" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (19 "d76fc85649564ade8955902b14bf562617c5aa46bbae899c970816e26edacb58" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (20 "2b3627845c5667b4dde0dbf1944cfbb9e7213926f40818e644502c328bd12251" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (21 "9f81f0ca552349f409a8125e06243f41b6a53c1704827a74d9e305383c5f2f6e" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (22 "47844b8b47eb7fe2403b4a93919bd2885fc505cf1937d60de3455f9a6a252e09" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (23 "9b6376756bbfd168285a847ea1bc908ec2e71948a3f5193a06b6b8c8b77310ed" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (24 "803de6e620863e9e0697e3b97305bd7a4f84696a0ab521f4d889c6718f33fc29" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (25 "2105a901604efb117020716ba735764c75312391b0af6094b103808163281b6c" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (26 "ecdda8ab15432792c2a879a8ae942609fa9884f8f1c0a9c1ce008d199119aba2" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (27 "1e22b36b7c2601697a08c378c1480d434bff34e2cdfea19b3c01eedac13585b9" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (28 "dd7229d4443b5ae338ae78e3b9007ab25f1c7abfc028224c4af549a320e355d2" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (29 "c0d6fac9480353ea5d474510a6179c117a61f9658b3a60e19b45c5bcd8e28771" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (30 "58d5a6f2347b7ce1e9769acba9405d484c47cc2fa4ace19b73457d2cc99e4a2c" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (31 "c00135e9766c2e2e274917db624e9642e950d2f24e1aef6e9d1f4f4ee349ca71" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (32 "d3898d77f2d252873e0cb11a27de6737d36a8146b8bbdbc667769e8a116a85e1" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (33 "67dd1415cb71b8d8efbaacaf3cf88502611ace7132d9fa192b3471d9c75c2115" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (34 "67fa1ca41788eebc5efe50add5e7245d60c0f2d6b84a8023d02c80c39089b1c8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (35 "170887ddf9784e93c815c26982fa27ada7e251b4e91d084c00e508428adf2576" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (36 "691a4c3ef48dad301e35e7fb439810dd6e00395dab3094538e0ed9ead465b252" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (37 "fc6559abfcc19506f1caf7ac89bcb8115af37aab75d36c8da3a050fa2df0527e" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (38 "60c8119e975c2518c63fa18c345cb294dd41640f6f94b98b3eda677c3eb01b3e" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (39 "7443880f4f6bec722a9cca875228183fb8ea323edb5253e73dbaca32a7d228da" ok 3 ok 3 skipped-unsafe arbitrary-document-code none))) (:file "docs/user-guide.zh.md" :blocks ((1 "03be4130f1485ae08b5d2d5e951fbc243e13db7ab43a269d2eb4fd31a6f022ba" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (2 "3ea1c9ff2e1ca4a31ef46ac55ef3c78e7a630346f385c12621504a5470f24f56" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (3 "ebbbee1c31e6f89aca772b7ebba9756d555a108e97197cd8ee6db74f09e5f6b9" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (4 "d8963a39d3ca02c524661bf0ccc05eef1331f4305e6e568b6ed4cd021e5b2e4f" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (5 "14fb850d1f28bf6970aeb9ca0d15fa9ab4bed747b5a0671e07642cc7ff6da9f4" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (6 "947b25b44a74005f416e9556caeae6f0b6cbc95f69cc248ad4a375b321d983ba" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (7 "072830fd4b5b0cecbe51808bf1cbd39a06586f43d5729c65ac09f3a33ebdc220" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (8 "c94fcd1e44d8ef044d6372d01bef058b05581569ba3a499530ba85040f238c9c" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (9 "023c30adcfd14fc330ab07529ce32856e27209aa87b36e0cc1a50aae5f20d283" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (10 "f19f26fead0e2efc8ab26631f010518cf5a6b477990598ae20c0dd911908c838" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (11 "216d25cee8b364bcc5dc39d8458ce08db07c40fa8ce8894ae10fdde94bc52906" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (12 "eb7850ac84d549db58ea7c4c1c3b47d4eae25957be77f6e0ae2a196bbbead2f1" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (13 "4113f7b5f7f7b95ace9fae0b92b91ce33b8dfb85d534480b7cc1994b4e30187a" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (14 "502cc98bd5ec20f24764a935ca60ceb8c0de61d4507fe76530070b0516e9c016" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (15 "d5e1633dd8fa0b5edfba0bc31314fcc97e446ffa0577f3d3c9465e887d5b7903" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (16 "0944e77a00d76e6462cca3e117c2b36c3611863128321c2307dd1ff8b3696368" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (17 "1d5c4895dfaaa595e6ea821158f0bfb11d7777dd697e5660bfc00cf14f82a684" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (18 "79390863a3fdf604969cc4f424b36a9a466e569bd0ac740e61c580b2518cb2da" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (19 "d76fc85649564ade8955902b14bf562617c5aa46bbae899c970816e26edacb58" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (20 "2b3627845c5667b4dde0dbf1944cfbb9e7213926f40818e644502c328bd12251" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (21 "9f81f0ca552349f409a8125e06243f41b6a53c1704827a74d9e305383c5f2f6e" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (22 "47844b8b47eb7fe2403b4a93919bd2885fc505cf1937d60de3455f9a6a252e09" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (23 "9b6376756bbfd168285a847ea1bc908ec2e71948a3f5193a06b6b8c8b77310ed" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (24 "803de6e620863e9e0697e3b97305bd7a4f84696a0ab521f4d889c6718f33fc29" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (25 "2105a901604efb117020716ba735764c75312391b0af6094b103808163281b6c" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (26 "ecdda8ab15432792c2a879a8ae942609fa9884f8f1c0a9c1ce008d199119aba2" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (27 "1e22b36b7c2601697a08c378c1480d434bff34e2cdfea19b3c01eedac13585b9" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (28 "dd7229d4443b5ae338ae78e3b9007ab25f1c7abfc028224c4af549a320e355d2" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (29 "c0d6fac9480353ea5d474510a6179c117a61f9658b3a60e19b45c5bcd8e28771" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (30 "58d5a6f2347b7ce1e9769acba9405d484c47cc2fa4ace19b73457d2cc99e4a2c" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (31 "c00135e9766c2e2e274917db624e9642e950d2f24e1aef6e9d1f4f4ee349ca71" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (32 "d3898d77f2d252873e0cb11a27de6737d36a8146b8bbdbc667769e8a116a85e1" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (33 "67dd1415cb71b8d8efbaacaf3cf88502611ace7132d9fa192b3471d9c75c2115" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (34 "67fa1ca41788eebc5efe50add5e7245d60c0f2d6b84a8023d02c80c39089b1c8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (35 "170887ddf9784e93c815c26982fa27ada7e251b4e91d084c00e508428adf2576" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (36 "691a4c3ef48dad301e35e7fb439810dd6e00395dab3094538e0ed9ead465b252" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (37 "fc6559abfcc19506f1caf7ac89bcb8115af37aab75d36c8da3a050fa2df0527e" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (38 "60c8119e975c2518c63fa18c345cb294dd41640f6f94b98b3eda677c3eb01b3e" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (39 "3b2dd2ce38340418d7c2bdbc71675a97ce696e091aa98f2e9c750150dc2b71c7" ok 3 ok 3 skipped-unsafe arbitrary-document-code none))) (:file "docs/implementation-plan.en.md" :blocks nil) (:file "docs/implementation-plan.zh.md" :blocks nil)) +((:file "README.md" :blocks ((1 "162bc3140b6e124acea41d5ebffea9de6cf88aa71644c39aca5dd3b606e1a018" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (2 "d835a3e05346b4e7de94a6bd3717c26c3dfebd3a29842321f83d16fbae0e3ad1" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (3 "9c918cef36143b2374c2da68b41ec5db8b045514e2a52caa8d9d0042c6dc82c1" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (4 "cdbc6593afa8eb1a55d1ebc3b7b436c5c7a73dfdb47bca7f2e4d627e2b5efc3b" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (5 "170887ddf9784e93c815c26982fa27ada7e251b4e91d084c00e508428adf2576" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (6 "ee971bf3c4dfbb4291a2a62a127ea5927b803caae37a6b2d742e0e627a1790dc" ok 5 ok 5 skipped-unsafe arbitrary-document-code none))) (:file "README.zh-CN.md" :blocks ((1 "162bc3140b6e124acea41d5ebffea9de6cf88aa71644c39aca5dd3b606e1a018" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (2 "d835a3e05346b4e7de94a6bd3717c26c3dfebd3a29842321f83d16fbae0e3ad1" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (3 "9c918cef36143b2374c2da68b41ec5db8b045514e2a52caa8d9d0042c6dc82c1" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (4 "a23afe4a6d314d6e2f6482ff2b20809c6d00a1eb419545286601908fd4276f7e" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (5 "170887ddf9784e93c815c26982fa27ada7e251b4e91d084c00e508428adf2576" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (6 "ee971bf3c4dfbb4291a2a62a127ea5927b803caae37a6b2d742e0e627a1790dc" ok 5 ok 5 skipped-unsafe arbitrary-document-code none))) (:file "examples/README.md" :blocks ((1 "0668b4bd330b19e78fa26c19e36ec39b3dffbe1da9bbf0b918110e4968d92c15" ok 5 ok 5 skipped-unsafe arbitrary-document-code none))) (:file "examples/README.zh-CN.md" :blocks ((1 "0668b4bd330b19e78fa26c19e36ec39b3dffbe1da9bbf0b918110e4968d92c15" ok 5 ok 5 skipped-unsafe arbitrary-document-code none))) (:file "docs/architecture.en.md" :blocks ((1 "1b540d9726c3cf0f064baa0bf8fa6e0f7bdd1bc552adb2032d76534243c691f8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (2 "aca28b3eee51e12db137d1e1f53fb70ffb7b5bb757829d93572c238b8aad4e3b" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (3 "b0c46b25b40bd95ddeadf502d19689c4068945a7ab7e415ac52f2aacbabe5f96" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (4 "37eadf35f976fcc795ef264ebe4481bf9c62576e9d879aa7f87809a912763ad8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (5 "ba85666e995b31ed155432504b66ca82aca4b173cee242906e51df1687c0b0b0" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (6 "820db03998e5d9b72defb2149f981f0a47142a374c34fd7d06894d2a46bd4e6d" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (7 "497e5a3bf314a96a61428b217870886e20cd110b65fb1e2fa5d7e53907a1603f" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (8 "2486da6efc52edf44c791ec3931ec1b6b2261283c73893581a7dfb462f08e77e" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (9 "f930e82bef8a415d67afbc9f68bfc0d4c901840137ba1cc51696a022f2efde77" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (10 "57d8239f1aca6c75bd46d3d666e730a46b232347841f37034af66efd14e14200" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (11 "0b4a7062e7591a3c49efa40750ecf00e52380b444811e476e2413ba84f21dc24" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (12 "4550d8661069cc7c785304ec57482154fba956281d932d402d3f52c3f18d39ff" ok 4 ok 4 skipped-unsafe arbitrary-document-code none) (13 "200bc4dc9f40ef4e6a96b2f56b36e6a02aff95822759f4223d7d01b71fed1fce" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (14 "1c406360dfdc23b26ff331468db47dcb93d710168a9b14b97ff663f637e556c8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (15 "a739e2986b545a6c49528de3f515480b99bb51a99c7c950291639ef50758088a" ok 1 ok 1 skipped-unsafe arbitrary-document-code none))) (:file "docs/architecture.zh.md" :blocks ((1 "1b540d9726c3cf0f064baa0bf8fa6e0f7bdd1bc552adb2032d76534243c691f8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (2 "aca28b3eee51e12db137d1e1f53fb70ffb7b5bb757829d93572c238b8aad4e3b" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (3 "b0c46b25b40bd95ddeadf502d19689c4068945a7ab7e415ac52f2aacbabe5f96" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (4 "37eadf35f976fcc795ef264ebe4481bf9c62576e9d879aa7f87809a912763ad8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (5 "ba85666e995b31ed155432504b66ca82aca4b173cee242906e51df1687c0b0b0" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (6 "820db03998e5d9b72defb2149f981f0a47142a374c34fd7d06894d2a46bd4e6d" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (7 "497e5a3bf314a96a61428b217870886e20cd110b65fb1e2fa5d7e53907a1603f" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (8 "2486da6efc52edf44c791ec3931ec1b6b2261283c73893581a7dfb462f08e77e" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (9 "f930e82bef8a415d67afbc9f68bfc0d4c901840137ba1cc51696a022f2efde77" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (10 "57d8239f1aca6c75bd46d3d666e730a46b232347841f37034af66efd14e14200" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (11 "0b4a7062e7591a3c49efa40750ecf00e52380b444811e476e2413ba84f21dc24" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (12 "4550d8661069cc7c785304ec57482154fba956281d932d402d3f52c3f18d39ff" ok 4 ok 4 skipped-unsafe arbitrary-document-code none) (13 "200bc4dc9f40ef4e6a96b2f56b36e6a02aff95822759f4223d7d01b71fed1fce" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (14 "1c406360dfdc23b26ff331468db47dcb93d710168a9b14b97ff663f637e556c8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (15 "a739e2986b545a6c49528de3f515480b99bb51a99c7c950291639ef50758088a" ok 1 ok 1 skipped-unsafe arbitrary-document-code none))) (:file "docs/user-guide.en.md" :blocks ((1 "ee971bf3c4dfbb4291a2a62a127ea5927b803caae37a6b2d742e0e627a1790dc" ok 5 ok 5 skipped-unsafe arbitrary-document-code none) (2 "3ea1c9ff2e1ca4a31ef46ac55ef3c78e7a630346f385c12621504a5470f24f56" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (3 "ebbbee1c31e6f89aca772b7ebba9756d555a108e97197cd8ee6db74f09e5f6b9" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (4 "d8963a39d3ca02c524661bf0ccc05eef1331f4305e6e568b6ed4cd021e5b2e4f" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (5 "14fb850d1f28bf6970aeb9ca0d15fa9ab4bed747b5a0671e07642cc7ff6da9f4" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (6 "947b25b44a74005f416e9556caeae6f0b6cbc95f69cc248ad4a375b321d983ba" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (7 "072830fd4b5b0cecbe51808bf1cbd39a06586f43d5729c65ac09f3a33ebdc220" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (8 "5e7b4088abd90a8ff9a5676d8ac98a2534ef43b79e927f14b4a67fe1189a9c11" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (9 "c94fcd1e44d8ef044d6372d01bef058b05581569ba3a499530ba85040f238c9c" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (10 "023c30adcfd14fc330ab07529ce32856e27209aa87b36e0cc1a50aae5f20d283" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (11 "f19f26fead0e2efc8ab26631f010518cf5a6b477990598ae20c0dd911908c838" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (12 "216d25cee8b364bcc5dc39d8458ce08db07c40fa8ce8894ae10fdde94bc52906" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (13 "cba6a38f37cc39bc9ff8722251212a17a8f7e5c8af9c0a0ec0dc1bd77fdc6eed" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (14 "fce8bf15e6fce496ae0a066f8d0bd44dc30e4f93d3ffc18ca43de8eb03e458e6" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (15 "502cc98bd5ec20f24764a935ca60ceb8c0de61d4507fe76530070b0516e9c016" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (16 "d5e1633dd8fa0b5edfba0bc31314fcc97e446ffa0577f3d3c9465e887d5b7903" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (17 "0944e77a00d76e6462cca3e117c2b36c3611863128321c2307dd1ff8b3696368" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (18 "1d5c4895dfaaa595e6ea821158f0bfb11d7777dd697e5660bfc00cf14f82a684" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (19 "79390863a3fdf604969cc4f424b36a9a466e569bd0ac740e61c580b2518cb2da" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (20 "d76fc85649564ade8955902b14bf562617c5aa46bbae899c970816e26edacb58" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (21 "200bc4dc9f40ef4e6a96b2f56b36e6a02aff95822759f4223d7d01b71fed1fce" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (22 "9f81f0ca552349f409a8125e06243f41b6a53c1704827a74d9e305383c5f2f6e" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (23 "47844b8b47eb7fe2403b4a93919bd2885fc505cf1937d60de3455f9a6a252e09" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (24 "9b6376756bbfd168285a847ea1bc908ec2e71948a3f5193a06b6b8c8b77310ed" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (25 "803de6e620863e9e0697e3b97305bd7a4f84696a0ab521f4d889c6718f33fc29" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (26 "2105a901604efb117020716ba735764c75312391b0af6094b103808163281b6c" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (27 "ecdda8ab15432792c2a879a8ae942609fa9884f8f1c0a9c1ce008d199119aba2" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (28 "09843dae21c1aafbaee1ec8d43a2a1adb4314678b30f7b169a162d34ae1642cb" ok 4 ok 4 skipped-unsafe arbitrary-document-code none) (29 "dd7229d4443b5ae338ae78e3b9007ab25f1c7abfc028224c4af549a320e355d2" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (30 "c0d6fac9480353ea5d474510a6179c117a61f9658b3a60e19b45c5bcd8e28771" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (31 "58d5a6f2347b7ce1e9769acba9405d484c47cc2fa4ace19b73457d2cc99e4a2c" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (32 "c00135e9766c2e2e274917db624e9642e950d2f24e1aef6e9d1f4f4ee349ca71" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (33 "d3898d77f2d252873e0cb11a27de6737d36a8146b8bbdbc667769e8a116a85e1" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (34 "67dd1415cb71b8d8efbaacaf3cf88502611ace7132d9fa192b3471d9c75c2115" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (35 "67fa1ca41788eebc5efe50add5e7245d60c0f2d6b84a8023d02c80c39089b1c8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (36 "170887ddf9784e93c815c26982fa27ada7e251b4e91d084c00e508428adf2576" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (37 "691a4c3ef48dad301e35e7fb439810dd6e00395dab3094538e0ed9ead465b252" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (38 "fc6559abfcc19506f1caf7ac89bcb8115af37aab75d36c8da3a050fa2df0527e" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (39 "60c8119e975c2518c63fa18c345cb294dd41640f6f94b98b3eda677c3eb01b3e" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (40 "7443880f4f6bec722a9cca875228183fb8ea323edb5253e73dbaca32a7d228da" ok 3 ok 3 skipped-unsafe arbitrary-document-code none))) (:file "docs/user-guide.zh.md" :blocks ((1 "ee971bf3c4dfbb4291a2a62a127ea5927b803caae37a6b2d742e0e627a1790dc" ok 5 ok 5 skipped-unsafe arbitrary-document-code none) (2 "3ea1c9ff2e1ca4a31ef46ac55ef3c78e7a630346f385c12621504a5470f24f56" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (3 "ebbbee1c31e6f89aca772b7ebba9756d555a108e97197cd8ee6db74f09e5f6b9" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (4 "d8963a39d3ca02c524661bf0ccc05eef1331f4305e6e568b6ed4cd021e5b2e4f" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (5 "14fb850d1f28bf6970aeb9ca0d15fa9ab4bed747b5a0671e07642cc7ff6da9f4" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (6 "947b25b44a74005f416e9556caeae6f0b6cbc95f69cc248ad4a375b321d983ba" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (7 "072830fd4b5b0cecbe51808bf1cbd39a06586f43d5729c65ac09f3a33ebdc220" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (8 "5e7b4088abd90a8ff9a5676d8ac98a2534ef43b79e927f14b4a67fe1189a9c11" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (9 "c94fcd1e44d8ef044d6372d01bef058b05581569ba3a499530ba85040f238c9c" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (10 "023c30adcfd14fc330ab07529ce32856e27209aa87b36e0cc1a50aae5f20d283" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (11 "f19f26fead0e2efc8ab26631f010518cf5a6b477990598ae20c0dd911908c838" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (12 "216d25cee8b364bcc5dc39d8458ce08db07c40fa8ce8894ae10fdde94bc52906" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (13 "cba6a38f37cc39bc9ff8722251212a17a8f7e5c8af9c0a0ec0dc1bd77fdc6eed" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (14 "fce8bf15e6fce496ae0a066f8d0bd44dc30e4f93d3ffc18ca43de8eb03e458e6" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (15 "502cc98bd5ec20f24764a935ca60ceb8c0de61d4507fe76530070b0516e9c016" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (16 "d5e1633dd8fa0b5edfba0bc31314fcc97e446ffa0577f3d3c9465e887d5b7903" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (17 "0944e77a00d76e6462cca3e117c2b36c3611863128321c2307dd1ff8b3696368" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (18 "1d5c4895dfaaa595e6ea821158f0bfb11d7777dd697e5660bfc00cf14f82a684" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (19 "79390863a3fdf604969cc4f424b36a9a466e569bd0ac740e61c580b2518cb2da" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (20 "d76fc85649564ade8955902b14bf562617c5aa46bbae899c970816e26edacb58" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (21 "200bc4dc9f40ef4e6a96b2f56b36e6a02aff95822759f4223d7d01b71fed1fce" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (22 "9f81f0ca552349f409a8125e06243f41b6a53c1704827a74d9e305383c5f2f6e" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (23 "47844b8b47eb7fe2403b4a93919bd2885fc505cf1937d60de3455f9a6a252e09" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (24 "9b6376756bbfd168285a847ea1bc908ec2e71948a3f5193a06b6b8c8b77310ed" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (25 "803de6e620863e9e0697e3b97305bd7a4f84696a0ab521f4d889c6718f33fc29" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (26 "2105a901604efb117020716ba735764c75312391b0af6094b103808163281b6c" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (27 "ecdda8ab15432792c2a879a8ae942609fa9884f8f1c0a9c1ce008d199119aba2" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (28 "09843dae21c1aafbaee1ec8d43a2a1adb4314678b30f7b169a162d34ae1642cb" ok 4 ok 4 skipped-unsafe arbitrary-document-code none) (29 "dd7229d4443b5ae338ae78e3b9007ab25f1c7abfc028224c4af549a320e355d2" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (30 "c0d6fac9480353ea5d474510a6179c117a61f9658b3a60e19b45c5bcd8e28771" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (31 "58d5a6f2347b7ce1e9769acba9405d484c47cc2fa4ace19b73457d2cc99e4a2c" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (32 "c00135e9766c2e2e274917db624e9642e950d2f24e1aef6e9d1f4f4ee349ca71" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (33 "d3898d77f2d252873e0cb11a27de6737d36a8146b8bbdbc667769e8a116a85e1" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (34 "67dd1415cb71b8d8efbaacaf3cf88502611ace7132d9fa192b3471d9c75c2115" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (35 "67fa1ca41788eebc5efe50add5e7245d60c0f2d6b84a8023d02c80c39089b1c8" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (36 "170887ddf9784e93c815c26982fa27ada7e251b4e91d084c00e508428adf2576" ok 3 ok 3 skipped-unsafe arbitrary-document-code none) (37 "691a4c3ef48dad301e35e7fb439810dd6e00395dab3094538e0ed9ead465b252" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (38 "fc6559abfcc19506f1caf7ac89bcb8115af37aab75d36c8da3a050fa2df0527e" ok 1 ok 1 skipped-unsafe arbitrary-document-code none) (39 "60c8119e975c2518c63fa18c345cb294dd41640f6f94b98b3eda677c3eb01b3e" ok 2 ok 2 skipped-unsafe arbitrary-document-code none) (40 "3b2dd2ce38340418d7c2bdbc71675a97ce696e091aa98f2e9c750150dc2b71c7" ok 3 ok 3 skipped-unsafe arbitrary-document-code none))) (:file "docs/implementation-plan.en.md" :blocks nil) (:file "docs/implementation-plan.zh.md" :blocks nil)) diff --git a/tests/fixtures/etaf-m0b-doc-examples.sexp b/tests/fixtures/etaf-m0b-doc-examples.sexp index 3c6200e..ae486dd 100644 --- a/tests/fixtures/etaf-m0b-doc-examples.sexp +++ b/tests/fixtures/etaf-m0b-doc-examples.sexp @@ -1,5 +1,15 @@ ((:file "docs/user-guide.en.md" - :block 18 + :block 19 :sha256 "79390863a3fdf604969cc4f424b36a9a466e569bd0ac740e61c580b2518cb2da" :classification mounted-smoke - :probe collection-composition)) + :probe collection-composition) + (:file "docs/user-guide.en.md" + :block 8 + :sha256 "5e7b4088abd90a8ff9a5676d8ac98a2534ef43b79e927f14b4a67fe1189a9c11" + :classification mounted-smoke + :probe runtime-snapshot) + (:file "docs/user-guide.zh.md" + :block 8 + :sha256 "5e7b4088abd90a8ff9a5676d8ac98a2534ef43b79e927f14b4a67fe1189a9c11" + :classification mounted-smoke + :probe runtime-snapshot))