From 60b58fdff72149a3c11db3cc17be6cab3820c857 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 7 Mar 2026 00:18:11 +0000 Subject: [PATCH] Add cache unit tests (10) and update data-test demo for TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 10 new tests: cache key generation, set/get, TTL expiry, overwrite, key independence, complex nested data - Update data-test.sx with cache verification instructions: navigate away+back within 30s → client+cache, after 30s → new fetch Co-Authored-By: Claude Opus 4.6 --- shared/sx/tests/test_page_data.py | 152 +++++++++++++++++++++++++++++- sx/sx/data-test.sx | 26 ++--- 2 files changed, 165 insertions(+), 13 deletions(-) diff --git a/shared/sx/tests/test_page_data.py b/shared/sx/tests/test_page_data.py index 5c18a2a..d67d0ad 100644 --- a/shared/sx/tests/test_page_data.py +++ b/shared/sx/tests/test_page_data.py @@ -1,8 +1,8 @@ """Tests for Phase 4 page data pipeline. Tests the serialize→parse roundtrip for data dicts (SX wire format), -the kebab-case key conversion, and component dep computation for -:data pages. +the kebab-case key conversion, component dep computation for +:data pages, and the client data cache logic. """ import pytest @@ -283,3 +283,151 @@ class TestDataPipelineSimulation: for expr in pa('(~page :title title :count count)'): result = _trampoline(_eval(expr, page_env)) assert result == expected + + +# --------------------------------------------------------------------------- +# Client data cache +# --------------------------------------------------------------------------- + +class TestDataCache: + """Test the page data cache logic from orchestration.sx. + + The cache functions are pure SX evaluated with a mock now-ms primitive. + """ + + def _make_env(self, current_time_ms=1000): + """Create an env with cache functions and a controllable now-ms.""" + from shared.sx.parser import parse_all as pa + from shared.sx.evaluator import _eval, _trampoline + + env = {} + # Mock now-ms as a callable that returns current_time_ms + self._time = current_time_ms + env["now-ms"] = lambda: self._time + + # Mutating primitives needed by cache (available in JS, not bare Python) + def _dict_set(d, k, v): + d[k] = v + return v + def _append_b(lst, item): + lst.append(item) + return lst + env["dict-set!"] = _dict_set + env["append!"] = _append_b + + # Define the cache functions from orchestration.sx + cache_src = """ + (define _page-data-cache (dict)) + (define _page-data-cache-ttl 30000) + + (define page-data-cache-key + (fn (page-name params) + (let ((base page-name)) + (if (or (nil? params) (empty? (keys params))) + base + (let ((parts (list))) + (for-each + (fn (k) + (append! parts (str k "=" (get params k)))) + (keys params)) + (str base ":" (join "&" parts))))))) + + (define page-data-cache-get + (fn (cache-key) + (let ((entry (get _page-data-cache cache-key))) + (if (nil? entry) + nil + (if (> (- (now-ms) (get entry "ts")) _page-data-cache-ttl) + (do + (dict-set! _page-data-cache cache-key nil) + nil) + (get entry "data")))))) + + (define page-data-cache-set + (fn (cache-key data) + (dict-set! _page-data-cache cache-key + {"data" data "ts" (now-ms)}))) + """ + for expr in pa(cache_src): + _trampoline(_eval(expr, env)) + return env + + def _eval(self, src, env): + from shared.sx.parser import parse_all as pa + from shared.sx.evaluator import _eval, _trampoline + result = None + for expr in pa(src): + result = _trampoline(_eval(expr, env)) + return result + + def test_cache_key_no_params(self): + env = self._make_env() + result = self._eval('(page-data-cache-key "data-test" {})', env) + assert result == "data-test" + + def test_cache_key_with_params(self): + env = self._make_env() + result = self._eval('(page-data-cache-key "reference" {"slug" "div"})', env) + assert result == "reference:slug=div" + + def test_cache_key_nil_params(self): + env = self._make_env() + result = self._eval('(page-data-cache-key "data-test" nil)', env) + assert result == "data-test" + + def test_cache_miss_returns_nil(self): + env = self._make_env() + result = self._eval('(page-data-cache-get "nonexistent")', env) + assert result is NIL or result is None + + def test_cache_set_then_get(self): + env = self._make_env(current_time_ms=1000) + self._eval('(page-data-cache-set "test-page" {"title" "Hello"})', env) + result = self._eval('(page-data-cache-get "test-page")', env) + assert result["title"] == "Hello" + + def test_cache_hit_within_ttl(self): + env = self._make_env(current_time_ms=1000) + self._eval('(page-data-cache-set "test-page" {"val" 42})', env) + # Advance time by 10 seconds (within 30s TTL) + self._time = 11000 + result = self._eval('(page-data-cache-get "test-page")', env) + assert result["val"] == 42 + + def test_cache_expired_returns_nil(self): + env = self._make_env(current_time_ms=1000) + self._eval('(page-data-cache-set "test-page" {"val" 42})', env) + # Advance time by 31 seconds (past 30s TTL) + self._time = 32000 + result = self._eval('(page-data-cache-get "test-page")', env) + assert result is NIL or result is None + + def test_cache_overwrite(self): + env = self._make_env(current_time_ms=1000) + self._eval('(page-data-cache-set "p" {"v" 1})', env) + self._time = 2000 + self._eval('(page-data-cache-set "p" {"v" 2})', env) + result = self._eval('(page-data-cache-get "p")', env) + assert result["v"] == 2 + + def test_cache_different_keys_independent(self): + env = self._make_env(current_time_ms=1000) + self._eval('(page-data-cache-set "a" {"x" 1})', env) + self._eval('(page-data-cache-set "b" {"x" 2})', env) + a = self._eval('(page-data-cache-get "a")', env) + b = self._eval('(page-data-cache-get "b")', env) + assert a["x"] == 1 + assert b["x"] == 2 + + def test_cache_complex_data(self): + """Cache preserves nested dicts and lists.""" + env = self._make_env(current_time_ms=1000) + self._eval(""" + (page-data-cache-set "complex" + {"items" (list {"label" "A"} {"label" "B"}) + "count" 2}) + """, env) + result = self._eval('(page-data-cache-get "complex")', env) + assert result["count"] == 2 + assert len(result["items"]) == 2 + assert result["items"][0]["label"] == "A" diff --git a/sx/sx/data-test.sx b/sx/sx/data-test.sx index b890cdd..9bacbe8 100644 --- a/sx/sx/data-test.sx +++ b/sx/sx/data-test.sx @@ -1,20 +1,22 @@ -;; Data test page — exercises Phase 4 client-side data rendering. +;; Data test page — exercises Phase 4 client-side data rendering + caching. ;; ;; This page has a :data expression. When navigated to: ;; - Full page load: server evaluates data + renders content (normal path) -;; - Client route: client fetches /sx/data/data-test, parses SX, renders locally +;; - Client route (1st): client fetches /sx/data/data-test, caches, renders +;; - Client route (2nd within 30s): client uses cached data, renders instantly ;; -;; Open browser console and look for "sx:route client+data" to confirm -;; client-side rendering happened. +;; Open browser console and look for: +;; "sx:route client+data" — cache miss, fetched from server +;; "sx:route client+cache" — cache hit, rendered from cached data (defcomp ~data-test-content (&key server-time items phase transport) (div :class "space-y-8" (div :class "border-b border-stone-200 pb-6" (h1 :class "text-2xl font-bold text-stone-900" "Data Test") (p :class "mt-2 text-stone-600" - "This page tests the Phase 4 data endpoint. The content you see was " - "rendered using data from the server, but the rendering itself may have " - "happened client-side.")) + "This page tests the Phase 4 data endpoint and client-side data cache. " + "The content you see was rendered using data from the server, but the " + "rendering itself may have happened client-side.")) ;; Server-provided metadata (div :class "rounded-lg border border-stone-200 bg-white p-6 space-y-3" @@ -41,12 +43,14 @@ (div :class "text-sm text-stone-500" (get item "detail"))))) items))) - ;; How to verify + ;; How to verify — updated with cache instructions (div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2" - (p :class "font-semibold text-amber-800" "How to verify client-side rendering") + (p :class "font-semibold text-amber-800" "How to verify client-side rendering + caching") (ol :class "list-decimal list-inside text-amber-700 space-y-1" (li "Open the browser console (F12)") (li "Navigate to this page from another page using a link") (li "Look for: " (code :class "bg-amber-100 px-1 rounded" "sx:route client+data /isomorphism/data-test")) - (li "That log line means the data was fetched and rendered client-side") - (li "A full page reload will show server-side rendering instead"))))) + (li "Navigate away, then back within 30 seconds") + (li "Look for: " (code :class "bg-amber-100 px-1 rounded" "sx:route client+cache /isomorphism/data-test")) + (li "The server-time value should be the same (cached data)") + (li "Wait 30+ seconds, navigate back again — new fetch, updated time")))))