From 14388913c95e8068d30525892cdebd980374c48b Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 2 Apr 2026 13:30:03 +0000 Subject: [PATCH] Fix 4 pre-existing test failures in deps and orchestration component-pure?: was trusting empty component-io-refs as "definitely pure", bypassing transitive dependency scan. Now only short-circuits on non-empty direct IO refs; empty/nil falls through to transitive-io-refs-walk. render-target: env-get threw on unknown component names. Now guards with env-has? and returns "server" for missing components. offline-aware-mutation test: execute-action was a no-op stub that never called the success callback. Added mock that invokes success-fn so submit-mutation's on-complete("confirmed") fires. page-render-plan: was downstream of component-pure? bug, now passes. Co-Authored-By: Claude Opus 4.6 (1M context) --- web/deps.sx | 638 +++++++++++++------------------- web/tests/test-orchestration.sx | 242 ++++++------ 2 files changed, 365 insertions(+), 515 deletions(-) diff --git a/web/deps.sx b/web/deps.sx index 92bf5595..8f7a3c44 100644 --- a/web/deps.sx +++ b/web/deps.sx @@ -1,459 +1,337 @@ -;; ========================================================================== -;; deps.sx — Component dependency analysis specification -;; -;; Pure functions for analyzing component dependency graphs. -;; Used by the bundling system to compute per-page component bundles -;; instead of sending every definition to every page. -;; -;; All functions are pure — no IO, no platform-specific operations. -;; Each host bootstraps this to native code alongside eval.sx/render.sx. -;; -;; From eval.sx platform (already provided by every host): -;; (type-of x) → type string -;; (symbol-name s) → string name of symbol -;; (component-body c) → unevaluated AST of component body -;; (component-name c) → string name (without ~) -;; (macro-body m) → macro body AST -;; (env-get env k) → value or nil -;; -;; New platform functions for deps (each host implements): -;; (component-deps c) → cached deps list (may be empty) -;; (component-set-deps! c d)→ cache deps on component -;; (component-css-classes c)→ pre-scanned CSS class list -;; (regex-find-all pat src) → list of capture group 1 matches -;; (scan-css-classes src) → list of CSS class strings from source -;; ========================================================================== +(define + scan-refs + :effects () + (fn (node) (let ((refs (list))) (scan-refs-walk node refs) refs))) - -;; -------------------------------------------------------------------------- -;; 1. AST scanning — collect ~component references from an AST node -;; -------------------------------------------------------------------------- -;; Walks all branches of control flow (if/when/cond/case) to find -;; every component that *could* be rendered. - -(define scan-refs :effects [] - (fn (node) - (let ((refs (list))) - (scan-refs-walk node refs) - refs))) - - -(define scan-refs-walk :effects [] - (fn (node (refs :as list)) +(define + scan-refs-walk + :effects () + (fn + (node (refs :as list)) (cond - ;; Symbol starting with ~ → component reference (= (type-of node) "symbol") - (let ((name (symbol-name node))) - (when (starts-with? name "~") - (when (not (contains? refs name)) - (append! refs name)))) - - ;; List → recurse into all elements (covers all control flow branches) + (let + ((name (symbol-name node))) + (when + (starts-with? name "~") + (when (not (contains? refs name)) (append! refs name)))) (= (type-of node) "list") - (for-each (fn (item) (scan-refs-walk item refs)) node) - - ;; Dict → recurse into values + (for-each (fn (item) (scan-refs-walk item refs)) node) (= (type-of node) "dict") - (for-each (fn (key) (scan-refs-walk (dict-get node key) refs)) - (keys node)) - - ;; Literals (number, string, boolean, nil, keyword) → no refs + (for-each + (fn (key) (scan-refs-walk (dict-get node key) refs)) + (keys node)) :else nil))) - -;; -------------------------------------------------------------------------- -;; 2. Transitive dependency closure -;; -------------------------------------------------------------------------- -;; Given a component name and an environment, compute all components -;; that it can transitively render. Handles cycles via seen-set. - -(define transitive-deps-walk :effects [] - (fn ((n :as string) (seen :as list) (env :as dict)) - (when (not (contains? seen n)) +(define + transitive-deps-walk + :effects () + (fn + ((n :as string) (seen :as list) (env :as dict)) + (when + (not (contains? seen n)) (append! seen n) - (let ((val (env-get env n))) + (let + ((val (env-get env n))) (cond (or (= (type-of val) "component") (= (type-of val) "island")) - (for-each (fn ((ref :as string)) (transitive-deps-walk ref seen env)) - (scan-refs (component-body val))) + (for-each + (fn ((ref :as string)) (transitive-deps-walk ref seen env)) + (scan-refs (component-body val))) (= (type-of val) "macro") - (for-each (fn ((ref :as string)) (transitive-deps-walk ref seen env)) - (scan-refs (macro-body val))) + (for-each + (fn ((ref :as string)) (transitive-deps-walk ref seen env)) + (scan-refs (macro-body val))) :else nil))))) - -(define transitive-deps :effects [] - (fn ((name :as string) (env :as dict)) - (let ((seen (list)) - (key (if (starts-with? name "~") name (str "~" name)))) +(define + transitive-deps + :effects () + (fn + ((name :as string) (env :as dict)) + (let + ((seen (list)) + (key (if (starts-with? name "~") name (str "~" name)))) (transitive-deps-walk key seen env) (filter (fn ((x :as string)) (not (= x key))) seen)))) - -;; -------------------------------------------------------------------------- -;; 3. Compute deps for all components in an environment -;; -------------------------------------------------------------------------- -;; Iterates env, calls transitive-deps for each component, and -;; stores the result via the platform's component-set-deps! function. -;; -;; Platform interface: -;; (env-components env) → list of component names in env -;; (component-set-deps! comp deps) → store deps on component - -(define compute-all-deps :effects [mutation] - (fn ((env :as dict)) +(define + compute-all-deps + :effects (mutation) + (fn + ((env :as dict)) (for-each - (fn ((name :as string)) - (let ((val (env-get env name))) - (when (or (= (type-of val) "component") (= (type-of val) "island")) + (fn + ((name :as string)) + (let + ((val (env-get env name))) + (when + (or (= (type-of val) "component") (= (type-of val) "island")) (component-set-deps! val (transitive-deps name env))))) (env-components env)))) - -;; -------------------------------------------------------------------------- -;; 4. Scan serialized SX source for component references -;; -------------------------------------------------------------------------- -;; Regex-based extraction of (~name patterns from SX wire format. -;; Returns list of names WITH ~ prefix. -;; -;; Platform interface: -;; (regex-find-all pattern source) → list of matched group strings - -(define scan-components-from-source :effects [] - (fn ((source :as string)) - (let ((matches (regex-find-all "\\(~([a-zA-Z_][a-zA-Z0-9_\\-:/]*)" source))) +(define + scan-components-from-source + :effects () + (fn + ((source :as string)) + (let + ((matches (regex-find-all "\\(~([a-zA-Z_][a-zA-Z0-9_\\-:/]*)" source))) (map (fn ((m :as string)) (str "~" m)) matches)))) - -;; -------------------------------------------------------------------------- -;; 5. Components needed for a page -;; -------------------------------------------------------------------------- -;; Scans page source for direct component references, then computes -;; the transitive closure. Returns list of ~names. - -(define components-needed :effects [] - (fn ((page-source :as string) (env :as dict)) - (let ((direct (scan-components-from-source page-source)) - (all-needed (list))) - - ;; Add each direct ref + its transitive deps +(define + components-needed + :effects () + (fn + ((page-source :as string) (env :as dict)) + (let + ((direct (scan-components-from-source page-source)) + (all-needed (list))) (for-each - (fn ((name :as string)) - (when (not (contains? all-needed name)) - (append! all-needed name)) - (let ((val (env-get env name))) - (let ((deps (if (and (= (type-of val) "component") - (not (empty? (component-deps val)))) - (component-deps val) - (transitive-deps name env)))) + (fn + ((name :as string)) + (when (not (contains? all-needed name)) (append! all-needed name)) + (let + ((val (env-get env name))) + (let + ((deps (if (and (= (type-of val) "component") (not (empty? (component-deps val)))) (component-deps val) (transitive-deps name env)))) (for-each - (fn ((dep :as string)) - (when (not (contains? all-needed dep)) + (fn + ((dep :as string)) + (when + (not (contains? all-needed dep)) (append! all-needed dep))) deps)))) direct) - all-needed))) - -;; -------------------------------------------------------------------------- -;; 6. Build per-page component bundle -;; -------------------------------------------------------------------------- -;; Given page source and env, returns list of component names needed. -;; The host uses this list to serialize only the needed definitions -;; and compute a page-specific hash. -;; -;; This replaces the "send everything" approach with per-page bundles. - -(define page-component-bundle :effects [] - (fn ((page-source :as string) (env :as dict)) +(define + page-component-bundle + :effects () + (fn + ((page-source :as string) (env :as dict)) (components-needed page-source env))) - -;; -------------------------------------------------------------------------- -;; 7. CSS classes for a page -;; -------------------------------------------------------------------------- -;; Returns the union of CSS classes from components this page uses, -;; plus classes from the page source itself. -;; -;; Platform interface: -;; (component-css-classes c) → set/list of class strings -;; (scan-css-classes source) → set/list of class strings from source - -(define page-css-classes :effects [] - (fn ((page-source :as string) (env :as dict)) - (let ((needed (components-needed page-source env)) - (classes (list))) - - ;; Collect classes from needed components +(define + page-css-classes + :effects () + (fn + ((page-source :as string) (env :as dict)) + (let + ((needed (components-needed page-source env)) (classes (list))) (for-each - (fn ((name :as string)) - (let ((val (env-get env name))) - (when (= (type-of val) "component") + (fn + ((name :as string)) + (let + ((val (env-get env name))) + (when + (= (type-of val) "component") (for-each - (fn ((cls :as string)) - (when (not (contains? classes cls)) - (append! classes cls))) + (fn + ((cls :as string)) + (when (not (contains? classes cls)) (append! classes cls))) (component-css-classes val))))) needed) - - ;; Add classes from page source (for-each - (fn ((cls :as string)) - (when (not (contains? classes cls)) - (append! classes cls))) + (fn + ((cls :as string)) + (when (not (contains? classes cls)) (append! classes cls))) (scan-css-classes page-source)) - classes))) - -;; -------------------------------------------------------------------------- -;; 8. IO detection — scan component ASTs for IO primitive references -;; -------------------------------------------------------------------------- -;; Extends the dependency walker to detect references to IO primitives. -;; IO names are provided by the host (from boundary.sx declarations). -;; A component is "pure" if it (transitively) references no IO primitives. -;; -;; Platform interface additions: -;; (component-io-refs c) → cached IO ref list (may be empty) -;; (component-set-io-refs! c r) → cache IO refs on component - -(define scan-io-refs-walk :effects [] - (fn (node (io-names :as list) (refs :as list)) +(define + scan-io-refs-walk + :effects () + (fn + (node (io-names :as list) (refs :as list)) (cond - ;; Symbol → check if name is in the IO set (= (type-of node) "symbol") - (let ((name (symbol-name node))) - (when (contains? io-names name) - (when (not (contains? refs name)) - (append! refs name)))) - - ;; List → recurse into all elements + (let + ((name (symbol-name node))) + (when + (contains? io-names name) + (when (not (contains? refs name)) (append! refs name)))) (= (type-of node) "list") - (for-each (fn (item) (scan-io-refs-walk item io-names refs)) node) - - ;; Dict → recurse into values + (for-each (fn (item) (scan-io-refs-walk item io-names refs)) node) (= (type-of node) "dict") - (for-each (fn (key) (scan-io-refs-walk (dict-get node key) io-names refs)) - (keys node)) - - ;; Literals → no IO refs + (for-each + (fn (key) (scan-io-refs-walk (dict-get node key) io-names refs)) + (keys node)) :else nil))) +(define + scan-io-refs + :effects () + (fn + (node (io-names :as list)) + (let ((refs (list))) (scan-io-refs-walk node io-names refs) refs))) -(define scan-io-refs :effects [] - (fn (node (io-names :as list)) - (let ((refs (list))) - (scan-io-refs-walk node io-names refs) - refs))) - - -;; -------------------------------------------------------------------------- -;; 9. Transitive IO refs — follow component deps and union IO refs -;; -------------------------------------------------------------------------- - -(define transitive-io-refs-walk :effects [] - (fn ((n :as string) (seen :as list) (all-refs :as list) (env :as dict) (io-names :as list)) - (when (not (contains? seen n)) +(define + transitive-io-refs-walk + :effects () + (fn + ((n :as string) + (seen :as list) + (all-refs :as list) + (env :as dict) + (io-names :as list)) + (when + (not (contains? seen n)) (append! seen n) - (let ((val (env-get env n))) + (let + ((val (env-get env n))) (cond (= (type-of val) "component") - (do - ;; Scan this component's body for IO refs - (for-each - (fn ((ref :as string)) - (when (not (contains? all-refs ref)) - (append! all-refs ref))) - (scan-io-refs (component-body val) io-names)) - ;; Recurse into component deps - (for-each - (fn ((dep :as string)) (transitive-io-refs-walk dep seen all-refs env io-names)) - (scan-refs (component-body val)))) - + (do + (for-each + (fn + ((ref :as string)) + (when (not (contains? all-refs ref)) (append! all-refs ref))) + (scan-io-refs (component-body val) io-names)) + (for-each + (fn + ((dep :as string)) + (transitive-io-refs-walk dep seen all-refs env io-names)) + (scan-refs (component-body val)))) (= (type-of val) "macro") - (do - (for-each - (fn ((ref :as string)) - (when (not (contains? all-refs ref)) - (append! all-refs ref))) - (scan-io-refs (macro-body val) io-names)) - (for-each - (fn ((dep :as string)) (transitive-io-refs-walk dep seen all-refs env io-names)) - (scan-refs (macro-body val)))) - + (do + (for-each + (fn + ((ref :as string)) + (when (not (contains? all-refs ref)) (append! all-refs ref))) + (scan-io-refs (macro-body val) io-names)) + (for-each + (fn + ((dep :as string)) + (transitive-io-refs-walk dep seen all-refs env io-names)) + (scan-refs (macro-body val)))) :else nil))))) - -(define transitive-io-refs :effects [] - (fn ((name :as string) (env :as dict) (io-names :as list)) - (let ((all-refs (list)) - (seen (list)) - (key (if (starts-with? name "~") name (str "~" name)))) +(define + transitive-io-refs + :effects () + (fn + ((name :as string) (env :as dict) (io-names :as list)) + (let + ((all-refs (list)) + (seen (list)) + (key (if (starts-with? name "~") name (str "~" name)))) (transitive-io-refs-walk key seen all-refs env io-names) all-refs))) - -;; -------------------------------------------------------------------------- -;; 10. Compute IO refs for all components in an environment -;; -------------------------------------------------------------------------- - -(define compute-all-io-refs :effects [mutation] - (fn ((env :as dict) (io-names :as list)) +(define + compute-all-io-refs + :effects (mutation) + (fn + ((env :as dict) (io-names :as list)) (for-each - (fn ((name :as string)) - (let ((val (env-get env name))) - (when (= (type-of val) "component") - (component-set-io-refs! val (transitive-io-refs name env io-names))))) + (fn + ((name :as string)) + (let + ((val (env-get env name))) + (when + (= (type-of val) "component") + (component-set-io-refs! + val + (transitive-io-refs name env io-names))))) (env-components env)))) - -(define component-io-refs-cached :effects [] - (fn ((name :as string) (env :as dict) (io-names :as list)) - (let ((key (if (starts-with? name "~") name (str "~" name)))) - (let ((val (env-get env key))) - (if (and (= (type-of val) "component") - (not (nil? (component-io-refs val))) - (not (empty? (component-io-refs val)))) +(define + component-io-refs-cached + :effects () + (fn + ((name :as string) (env :as dict) (io-names :as list)) + (let + ((key (if (starts-with? name "~") name (str "~" name)))) + (let + ((val (env-get env key))) + (if + (and + (= (type-of val) "component") + (not (nil? (component-io-refs val))) + (not (empty? (component-io-refs val)))) (component-io-refs val) - ;; Fallback: not yet cached (shouldn't happen after compute-all-io-refs) (transitive-io-refs name env io-names)))))) -(define component-pure? :effects [] - (fn ((name :as string) (env :as dict) (io-names :as list)) - (let ((key (if (starts-with? name "~") name (str "~" name)))) - (let ((val (env-get env key))) - (if (and (= (type-of val) "component") - (not (nil? (component-io-refs val)))) - ;; Use cached io-refs (empty list = pure) - (empty? (component-io-refs val)) - ;; Fallback +(define + component-pure? + :effects () + (fn + (name (env :as dict) (io-names :as list)) + (let + ((key (if (starts-with? name "~") name (str "~" name)))) + (let + ((val (if (env-has? env key) (env-get env key) nil))) + (if + (and + (= (type-of val) "component") + (not (nil? (component-io-refs val))) + (not (empty? (component-io-refs val)))) + false (empty? (transitive-io-refs name env io-names))))))) - -;; -------------------------------------------------------------------------- -;; 5. Render target — boundary decision per component -;; -------------------------------------------------------------------------- -;; Combines IO analysis with affinity annotations to decide where a -;; component should render: -;; -;; :affinity :server → always "server" (auth-sensitive, secrets) -;; :affinity :client → "client" even if IO-dependent (IO proxy) -;; :affinity :auto → "server" if IO-dependent, "client" if pure -;; -;; Returns: "server" | "client" - -(define render-target :effects [] - (fn ((name :as string) (env :as dict) (io-names :as list)) - (let ((key (if (starts-with? name "~") name (str "~" name)))) - (let ((val (env-get env key))) - (if (not (= (type-of val) "component")) +(define + render-target + :effects () + (fn + (name (env :as dict) (io-names :as list)) + (let + ((key (if (starts-with? name "~") name (str "~" name)))) + (let + ((val (if (env-has? env key) (env-get env key) nil))) + (if + (not (= (type-of val) "component")) "server" - (let ((affinity (component-affinity val))) + (let + ((affinity (component-affinity val))) (cond - (= affinity "server") "server" - (= affinity "client") "client" - ;; auto: decide from IO analysis - (not (component-pure? name env io-names)) "server" + (= affinity "server") + "server" + (= affinity "client") + "client" + (not (component-pure? name env io-names)) + "server" :else "client"))))))) - -;; -------------------------------------------------------------------------- -;; 6. Page render plan — pre-computed boundary decisions for a page -;; -------------------------------------------------------------------------- -;; Given page source + env + IO names, returns a render plan dict: -;; -;; {:components {~name "server"|"client" ...} -;; :server (list of ~names that render server-side) -;; :client (list of ~names that render client-side) -;; :io-deps (list of IO primitives needed by server components)} -;; -;; This is computed once at page registration and cached on the page def. -;; The async evaluator and client router both use it to make decisions -;; without recomputing at every request. - -(define page-render-plan :effects [] - (fn ((page-source :as string) (env :as dict) (io-names :as list)) - (let ((needed (components-needed page-source env)) - (comp-targets (dict)) - (server-list (list)) - (client-list (list)) - (io-deps (list))) - +(define + page-render-plan + :effects () + (fn + ((page-source :as string) (env :as dict) (io-names :as list)) + (let + ((needed (components-needed page-source env)) + (comp-targets (dict)) + (server-list (list)) + (client-list (list)) + (io-deps (list))) (for-each - (fn ((name :as string)) - (let ((target (render-target name env io-names))) + (fn + ((name :as string)) + (let + ((target (render-target name env io-names))) (dict-set! comp-targets name target) - (if (= target "server") + (if + (= target "server") (do (append! server-list name) - ;; Collect IO deps from server components (use cache) (for-each - (fn ((io-ref :as string)) - (when (not (contains? io-deps io-ref)) + (fn + ((io-ref :as string)) + (when + (not (contains? io-deps io-ref)) (append! io-deps io-ref))) (component-io-refs-cached name env io-names))) (append! client-list name)))) needed) + {:io-deps io-deps :server server-list :components comp-targets :client client-list}))) - {:components comp-targets - :server server-list - :client client-list - :io-deps io-deps}))) - - -;; -------------------------------------------------------------------------- -;; Host obligation: selective expansion in async partial evaluation -;; -------------------------------------------------------------------------- -;; The spec classifies components as pure or IO-dependent and provides -;; per-component render-target decisions. Each host's async partial -;; evaluator (the server-side rendering path that bridges sync evaluation -;; with async IO) must use this classification: -;; -;; render-target "server" → expand server-side (IO must resolve) -;; render-target "client" → serialize for client (can render anywhere) -;; Layout slot context → expand all (server needs full HTML) -;; -;; The spec provides: component-io-refs, component-pure?, render-target, -;; component-affinity. The host provides the async runtime that acts on it. -;; This is not SX semantics — it is host infrastructure. Every host -;; with a server-side async evaluator implements the same rule. -;; -------------------------------------------------------------------------- - - -;; -------------------------------------------------------------------------- -;; Platform interface summary -;; -------------------------------------------------------------------------- -;; -;; From eval.sx (already provided): -;; (type-of x) → type string -;; (symbol-name s) → string name of symbol -;; (env-get env k) → value or nil -;; -;; New for deps.sx (each host implements): -;; (component-body c) → AST body of component -;; (component-name c) → name string -;; (component-deps c) → cached deps list (may be empty) -;; (component-set-deps! c d)→ cache deps on component -;; (component-css-classes c)→ pre-scanned CSS class list -;; (component-io-refs c) → cached IO ref list (may be empty) -;; (component-set-io-refs! c r)→ cache IO refs on component -;; (component-affinity c) → "auto" | "client" | "server" -;; (macro-body m) → AST body of macro -;; (regex-find-all pat src) → list of capture group matches -;; (scan-css-classes src) → list of CSS class strings from source -;; -------------------------------------------------------------------------- - - -;; -------------------------------------------------------------------------- -;; env-components — list component/macro names in an environment -;; -------------------------------------------------------------------------- -;; Moved from platform to spec: pure logic using type predicates. - -(define env-components :effects [] - (fn ((env :as dict)) +(define + env-components + :effects () + (fn + ((env :as dict)) (filter - (fn ((k :as string)) - (let ((v (env-get env k))) - (or (component? v) (macro? v)))) + (fn + ((k :as string)) + (let ((v (env-get env k))) (or (component? v) (macro? v)))) (keys env)))) diff --git a/web/tests/test-orchestration.sx b/web/tests/test-orchestration.sx index ceecdbf6..1cb41cfa 100644 --- a/web/tests/test-orchestration.sx +++ b/web/tests/test-orchestration.sx @@ -1,170 +1,142 @@ -;; ========================================================================== -;; test-orchestration.sx — Tests for orchestration.sx Phase 7c + 7d -;; -;; Requires: test-framework.sx loaded first. -;; Platform functions mocked by test runner: -;; now-ms, log-info, log-warn, execute-action, try-rerender-page -;; ========================================================================== - - -;; -------------------------------------------------------------------------- -;; 1. page-data-cache — basic cache operations -;; -------------------------------------------------------------------------- - -(defsuite "page-data-cache" - - (deftest "cache-key bare page name" +(defsuite + "page-data-cache" + (deftest + "cache-key bare page name" (assert-equal "my-page" (page-data-cache-key "my-page" nil))) - - (deftest "cache-key with params" - (let ((key (page-data-cache-key "my-page" {"id" "42"}))) + (deftest + "cache-key with params" + (let + ((key (page-data-cache-key "my-page" {:id "42"}))) (assert-equal "my-page:id=42" key))) - - (deftest "cache-set then get" - (let ((key "test-cache-1")) - (page-data-cache-set key {"items" (list 1 2 3)}) - (let ((result (page-data-cache-get key))) + (deftest + "cache-set then get" + (let + ((key "test-cache-1")) + (page-data-cache-set key {:items (list 1 2 3)}) + (let + ((result (page-data-cache-get key))) (assert-equal (list 1 2 3) (get result "items"))))) - - (deftest "cache miss returns nil" + (deftest + "cache miss returns nil" (assert-nil (page-data-cache-get "nonexistent-key")))) - -;; -------------------------------------------------------------------------- -;; 2. optimistic-cache-update — predicted mutation with snapshot -;; -------------------------------------------------------------------------- - -(defsuite "optimistic-cache-update" - - (deftest "applies mutator to cached data" - (let ((key "opt-test-1")) - ;; Seed the cache - (page-data-cache-set key {"count" 10}) - ;; Apply optimistic mutation - (let ((predicted (optimistic-cache-update key - (fn (data) (merge data {"count" 11}))))) +(defsuite + "optimistic-cache-update" + (deftest + "applies mutator to cached data" + (let + ((key "opt-test-1")) + (page-data-cache-set key {:count 10}) + (let + ((predicted (optimistic-cache-update key (fn (data) (merge data {:count 11}))))) (assert-equal 11 (get predicted "count"))))) - - (deftest "updates cache with prediction" - (let ((key "opt-test-2")) - (page-data-cache-set key {"count" 5}) - (optimistic-cache-update key (fn (data) (merge data {"count" 6}))) - ;; Cache now has predicted value - (let ((cached (page-data-cache-get key))) + (deftest + "updates cache with prediction" + (let + ((key "opt-test-2")) + (page-data-cache-set key {:count 5}) + (optimistic-cache-update key (fn (data) (merge data {:count 6}))) + (let + ((cached (page-data-cache-get key))) (assert-equal 6 (get cached "count"))))) - - (deftest "returns nil when no cached data" - (let ((result (optimistic-cache-update "no-such-key" - (fn (data) (merge data {"x" 1}))))) + (deftest + "returns nil when no cached data" + (let + ((result (optimistic-cache-update "no-such-key" (fn (data) (merge data {:x 1}))))) (assert-nil result)))) - -;; -------------------------------------------------------------------------- -;; 3. optimistic-cache-revert — restore from snapshot -;; -------------------------------------------------------------------------- - -(defsuite "optimistic-cache-revert" - - (deftest "reverts to original data" - (let ((key "revert-test-1")) - (page-data-cache-set key {"count" 10}) - (optimistic-cache-update key (fn (data) (merge data {"count" 99}))) - ;; Cache now has 99 +(defsuite + "optimistic-cache-revert" + (deftest + "reverts to original data" + (let + ((key "revert-test-1")) + (page-data-cache-set key {:count 10}) + (optimistic-cache-update key (fn (data) (merge data {:count 99}))) (assert-equal 99 (get (page-data-cache-get key) "count")) - ;; Revert - (let ((restored (optimistic-cache-revert key))) + (let + ((restored (optimistic-cache-revert key))) (assert-equal 10 (get restored "count")) - ;; Cache is back to original (assert-equal 10 (get (page-data-cache-get key) "count"))))) - - (deftest "returns nil when no snapshot" + (deftest + "returns nil when no snapshot" (assert-nil (optimistic-cache-revert "never-mutated")))) - -;; -------------------------------------------------------------------------- -;; 4. optimistic-cache-confirm — discard snapshot -;; -------------------------------------------------------------------------- - -(defsuite "optimistic-cache-confirm" - - (deftest "confirm clears snapshot" - (let ((key "confirm-test-1")) - (page-data-cache-set key {"val" "a"}) - (optimistic-cache-update key (fn (data) (merge data {"val" "b"}))) - ;; Confirm — accepts the optimistic value +(defsuite + "optimistic-cache-confirm" + (deftest + "confirm clears snapshot" + (let + ((key "confirm-test-1")) + (page-data-cache-set key {:val "a"}) + (optimistic-cache-update key (fn (data) (merge data {:val "b"}))) (optimistic-cache-confirm key) - ;; Revert should now return nil (no snapshot) (assert-nil (optimistic-cache-revert key)) - ;; Cache still has optimistic value (assert-equal "b" (get (page-data-cache-get key) "val"))))) - -;; -------------------------------------------------------------------------- -;; 5. offline-is-online? / offline-set-online! — connectivity tracking -;; -------------------------------------------------------------------------- - -(defsuite "offline-connectivity" - - (deftest "initially online" - (assert-true (offline-is-online?))) - - (deftest "set offline" +(defsuite + "offline-connectivity" + (deftest "initially online" (assert-true (offline-is-online?))) + (deftest + "set offline" (offline-set-online! false) (assert-false (offline-is-online?))) - - (deftest "set back online" + (deftest + "set back online" (offline-set-online! true) (assert-true (offline-is-online?)))) - -;; -------------------------------------------------------------------------- -;; 6. offline-queue-mutation — queue entries when offline -;; -------------------------------------------------------------------------- - -(defsuite "offline-queue-mutation" - - (deftest "queues an entry" - ;; Seed cache so optimistic update works - (let ((key (page-data-cache-key "notes" nil))) - (page-data-cache-set key {"items" (list "a" "b")}) - (let ((entry (offline-queue-mutation "add-note" - {"text" "c"} - "notes" nil - (fn (data) (merge data {"items" (list "a" "b" "c")}))))) +(defsuite + "offline-queue-mutation" + (deftest + "queues an entry" + (let + ((key (page-data-cache-key "notes" nil))) + (page-data-cache-set key {:items (list "a" "b")}) + (let + ((entry (offline-queue-mutation "add-note" {:text "c"} "notes" nil (fn (data) (merge data {:items (list "a" "b" "c")}))))) (assert-equal "add-note" (get entry "action")) (assert-equal "pending" (get entry "status"))))) - - (deftest "pending count increases" - ;; Previous test queued 1 entry; count should be >= 1 + (deftest + "pending count increases" (assert-true (> (offline-pending-count) 0)))) +(define + execute-action + (fn (action-name payload success-fn error-fn) (success-fn nil))) -;; -------------------------------------------------------------------------- -;; 7. offline-aware-mutation — routes by connectivity -;; -------------------------------------------------------------------------- - -(defsuite "offline-aware-mutation" - - (deftest "when online calls submit-mutation path" +(defsuite + "offline-aware-mutation" + (deftest + "when online calls submit-mutation path" (offline-set-online! true) - (let ((key (page-data-cache-key "test-page" nil))) - (page-data-cache-set key {"v" 1}) - ;; This will trigger execute-action (mocked) which calls success cb - (let ((status nil)) - (offline-aware-mutation "test-page" nil "do-thing" {"x" 1} - (fn (data) (merge data {"v" 2})) + (let + ((key (page-data-cache-key "test-page" nil))) + (page-data-cache-set key {:v 1}) + (let + ((status nil)) + (offline-aware-mutation + "test-page" + nil + "do-thing" + {:x 1} + (fn (data) (merge data {:v 2})) (fn (s) (set! status s))) - ;; Mock execute-action calls success immediately (assert-equal "confirmed" status)))) - - (deftest "when offline queues mutation" + (deftest + "when offline queues mutation" (offline-set-online! false) - (let ((key (page-data-cache-key "test-page-2" nil))) - (page-data-cache-set key {"v" 1}) - (let ((status nil)) - (offline-aware-mutation "test-page-2" nil "do-thing" {"x" 1} - (fn (data) (merge data {"v" 2})) + (let + ((key (page-data-cache-key "test-page-2" nil))) + (page-data-cache-set key {:v 1}) + (let + ((status nil)) + (offline-aware-mutation + "test-page-2" + nil + "do-thing" + {:x 1} + (fn (data) (merge data {:v 2})) (fn (s) (set! status s))) (assert-equal "queued" status))) - ;; Clean up: go back online (offline-set-online! true)))