Add cache unit tests (10) and update data-test demo for TTL

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 00:18:11 +00:00
parent d3617ab7f3
commit 60b58fdff7
2 changed files with 165 additions and 13 deletions

View File

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

View File

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