diff --git a/spec/tests/test-stepper.sx b/spec/tests/test-stepper.sx new file mode 100644 index 00000000..86b83534 --- /dev/null +++ b/spec/tests/test-stepper.sx @@ -0,0 +1,139 @@ +(define + is-html-tag? + (fn + (name) + (contains? + (list "div" "span" "h1" "h2" "h3" "p" "a" "button" "section" "nav") + name))) + +(define + split-tag + (fn + (expr result) + (cond + (not (list? expr)) + (append! result {:expr expr :type "leaf"}) + (empty? expr) + nil + (not (= (type-of (first expr)) "symbol")) + (append! result {:expr expr :type "leaf"}) + (is-html-tag? (symbol-name (first expr))) + (let + ((ctag (symbol-name (first expr))) + (cargs (rest expr)) + (cch (list)) + (cat (list)) + (spreads (list)) + (ckw false)) + (for-each + (fn + (a) + (cond + (= (type-of a) "keyword") + (do (set! ckw true) (append! cat a)) + ckw + (do (set! ckw false) (append! cat a)) + (and + (list? a) + (not (empty? a)) + (= (type-of (first a)) "symbol") + (starts-with? (symbol-name (first a)) "~")) + (do (set! ckw false) (append! spreads a)) + :else (do (set! ckw false) (append! cch a)))) + cargs) + (append! result {:spreads spreads :tag ctag :type "open" :attrs cat}) + (for-each (fn (c) (split-tag c result)) cch) + (append! result {:tag ctag :type "close"})) + :else (append! result {:expr expr :type "expr"})))) + +(define + steps-to-preview + (fn + (all-steps target) + (if + (or (empty? all-steps) (<= target 0)) + nil + (let + ((pos (dict "i" 0)) (max-i (min target (len all-steps)))) + (letrec + ((bc-loop (fn (children) (if (>= (get pos "i") max-i) children (let ((step (nth all-steps (get pos "i"))) (stype (get step "type"))) (cond (= stype "open") (do (dict-set! pos "i" (+ (get pos "i") 1)) (let ((tag (get step "tag")) (inner (bc-loop (list)))) (append! children (concat (list (make-symbol tag)) inner))) (bc-loop children)) (= stype "close") (do (dict-set! pos "i" (+ (get pos "i") 1)) children) (= stype "leaf") (do (dict-set! pos "i" (+ (get pos "i") 1)) (append! children (get step "expr")) (bc-loop children)) :else (do (dict-set! pos "i" (+ (get pos "i") 1)) (bc-loop children)))))))) + (let + ((root (bc-loop (list)))) + (cond + (= (len root) 1) + (first root) + (empty? root) + nil + :else (concat (list (make-symbol "<>")) root)))))))) + +(define + test-src + (quote (div (h1 (span "the ") (span "joy ") (span "of ") (span "sx"))))) + +(defsuite + "stepper-split-tag" + (deftest + "produces 16 steps for nested 4-span expression" + (let + ((result (list))) + (split-tag test-src result) + (assert-equal 16 (len result)))) + (deftest + "step sequence is open/open/.../close/close" + (let + ((result (list))) + (split-tag test-src result) + (assert-equal "open" (get (first result) "type")) + (assert-equal "div" (get (first result) "tag")) + (assert-equal "leaf" (get (nth result 3) "type")) + (assert-equal "the " (get (nth result 3) "expr")) + (assert-equal "close" (get (last result) "type")) + (assert-equal "div" (get (last result) "tag")))) + (deftest + "children are correctly nested" + (let + ((result (list))) + (split-tag test-src result) + (assert-equal "span" (get (nth result 2) "tag")) + (assert-equal "the " (get (nth result 3) "expr")) + (assert-equal "span" (get (nth result 4) "tag")) + (assert-equal "span" (get (nth result 8) "tag")) + (assert-equal "of " (get (nth result 9) "expr")) + (assert-equal "span" (get (nth result 10) "tag"))))) + +(defsuite + "stepper-preview" + (deftest + "full preview at step 16 equals source" + (let + ((result (list))) + (split-tag test-src result) + (let + ((expr (steps-to-preview result 16))) + (assert-equal test-src expr)))) + (deftest + "step 10 includes of" + (let + ((result (list))) + (split-tag test-src result) + (let + ((expr (steps-to-preview result 10))) + (assert-true (string-contains? (str expr) "of "))))) + (deftest + "step 9 does NOT include of (leaf not yet processed)" + (let + ((result (list))) + (split-tag test-src result) + (let + ((expr (steps-to-preview result 9))) + (assert-false (string-contains? (str expr) "of "))))) + (deftest + "step 8 shows the and joy only" + (let + ((result (list))) + (split-tag test-src result) + (let + ((expr (steps-to-preview result 8))) + (assert-true (string-contains? (str expr) "the ")) + (assert-true (string-contains? (str expr) "joy ")) + (assert-false (string-contains? (str expr) "of ")))))) diff --git a/sx/sx/home-stepper.sx b/sx/sx/home-stepper.sx index e7ee42d9..b61b6a56 100644 --- a/sx/sx/home-stepper.sx +++ b/sx/sx/home-stepper.sx @@ -5,12 +5,12 @@ ((source "(div (~tw :tokens \"text-center\")\n (h1 (~tw :tokens \"text-3xl font-bold mb-2\")\n (span (~tw :tokens \"text-rose-500\") \"the \")\n (span (~tw :tokens \"text-amber-500\") \"joy \")\n (span (~tw :tokens \"text-emerald-500\") \"of \")\n (span (~tw :tokens \"text-violet-600 text-4xl\") \"sx\")))") (steps (signal (list))) (store - (if (client?) (def-store "home-stepper" (fn () {:step-idx (signal 9)})) nil)) - (step-idx (if store (get store "step-idx") (signal 9))) + (if (client?) (def-store "home-stepper" (fn () {:step-idx (signal 16)})) nil)) + (step-idx (if store (get store "step-idx") (signal 16))) (dom-stack-sig (signal (list))) (code-tokens (signal (list)))) (letrec - ((split-tag (fn (expr result) (cond (not (list? expr)) (append! result {:expr expr :type "leaf"}) (empty? expr) nil (not (= (type-of (first expr)) "symbol")) (append! result {:expr expr :type "leaf"}) (is-html-tag? (symbol-name (first expr))) (let ((ctag (symbol-name (first expr))) (cargs (rest expr)) (cch (mutable-list)) (cat (mutable-list)) (spreads (mutable-list)) (ckw false)) (for-each (fn (a) (cond (= (type-of a) "keyword") (do (set! ckw true) (append! cat a)) ckw (do (set! ckw false) (append! cat a)) (and (list? a) (not (empty? a)) (= (type-of (first a)) "symbol") (starts-with? (symbol-name (first a)) "~")) (do (set! ckw false) (append! spreads a)) :else (do (set! ckw false) (append! cch a)))) cargs) (append! result {:spreads spreads :tag ctag :type "open" :attrs cat}) (for-each (fn (c) (split-tag c result)) cch) (append! result {:open-attrs cat :open-spreads spreads :tag ctag :type "close"})) :else (append! result {:expr expr :type "expr"})))) + ((split-tag (fn (expr result) (cond (not (list? expr)) (append! result {:expr expr :type "leaf"}) (empty? expr) nil (not (= (type-of (first expr)) "symbol")) (append! result {:expr expr :type "leaf"}) (is-html-tag? (symbol-name (first expr))) (let ((ctag (symbol-name (first expr))) (cargs (rest expr)) (cch (list)) (cat (list)) (spreads (list)) (ckw false)) (for-each (fn (a) (cond (= (type-of a) "keyword") (do (set! ckw true) (append! cat a)) ckw (do (set! ckw false) (append! cat a)) (and (list? a) (not (empty? a)) (= (type-of (first a)) "symbol") (starts-with? (symbol-name (first a)) "~")) (do (set! ckw false) (append! spreads a)) :else (do (set! ckw false) (append! cch a)))) cargs) (append! result {:spreads spreads :tag ctag :type "open" :attrs cat}) (for-each (fn (c) (split-tag c result)) cch) (append! result {:open-attrs cat :open-spreads spreads :tag ctag :type "close"})) :else (append! result {:expr expr :type "expr"})))) (build-code-tokens (fn (expr tokens step-ref indent) @@ -99,9 +99,9 @@ (let ((pos (dict "i" 0)) (max-i (min target (len all-steps)))) (letrec - ((bc-loop (fn (children) (if (>= (get pos "i") max-i) children (let ((step (nth all-steps (get pos "i"))) (stype (get step "type"))) (cond (= stype "open") (do (dict-set! pos "i" (+ (get pos "i") 1)) (let ((tag (get step "tag")) (attrs (or (get step "attrs") (list))) (spreads (or (get step "spreads") (list))) (inner (bc-loop (mutable-list)))) (append! children (concat (list (make-symbol tag)) spreads attrs inner))) (bc-loop children)) (= stype "close") (do (dict-set! pos "i" (+ (get pos "i") 1)) children) (= stype "leaf") (do (dict-set! pos "i" (+ (get pos "i") 1)) (append! children (get step "expr")) (bc-loop children)) (= stype "expr") (do (dict-set! pos "i" (+ (get pos "i") 1)) (append! children (get step "expr")) (bc-loop children)) :else (do (dict-set! pos "i" (+ (get pos "i") 1)) (bc-loop children)))))))) + ((bc-loop (fn (children) (if (>= (get pos "i") max-i) children (let ((step (nth all-steps (get pos "i"))) (stype (get step "type"))) (cond (= stype "open") (do (dict-set! pos "i" (+ (get pos "i") 1)) (let ((tag (get step "tag")) (attrs (or (get step "attrs") (list))) (spreads (or (get step "spreads") (list))) (inner (bc-loop (list)))) (append! children (concat (list (make-symbol tag)) spreads attrs inner))) (bc-loop children)) (= stype "close") (do (dict-set! pos "i" (+ (get pos "i") 1)) children) (= stype "leaf") (do (dict-set! pos "i" (+ (get pos "i") 1)) (append! children (get step "expr")) (bc-loop children)) (= stype "expr") (do (dict-set! pos "i" (+ (get pos "i") 1)) (append! children (get step "expr")) (bc-loop children)) :else (do (dict-set! pos "i" (+ (get pos "i") 1)) (bc-loop children)))))))) (let - ((root (bc-loop (mutable-list)))) + ((root (bc-loop (list)))) (cond (= (len root) 1) (first root) @@ -295,11 +295,11 @@ (when (not (empty? parsed)) (let - ((result (mutable-list)) (step-ref (dict "v" 0))) + ((result (list)) (step-ref (dict "v" 0))) (split-tag (first parsed) result) (reset! steps result) (let - ((tokens (mutable-list))) + ((tokens (list))) (dict-set! step-ref "v" 0) (build-code-tokens (first parsed) tokens step-ref 0) (reset! code-tokens tokens))))) diff --git a/tests/playwright/stepper.spec.js b/tests/playwright/stepper.spec.js index b37f46c8..232dc639 100644 --- a/tests/playwright/stepper.spec.js +++ b/tests/playwright/stepper.spec.js @@ -1,31 +1,81 @@ const { test, expect } = require('playwright/test'); const { loadPage, trackErrors } = require('./helpers'); -test('home page stepper: no raw SX component calls visible', async ({ page }) => { +test('stepper: no raw SX component calls visible after hydration', async ({ page }) => { const t = trackErrors(page); await loadPage(page, ''); const stepper = page.locator('[data-sx-island="home/stepper"]'); await expect(stepper).toBeVisible({ timeout: 10000 }); - // The lake (rendered preview) should NOT show raw component calls const lake = stepper.locator('[data-sx-lake]'); await expect(lake).toBeVisible({ timeout: 5000 }); const lakeText = await lake.textContent(); expect(lakeText).not.toContain('~cssx/tw'); + expect(lakeText).not.toContain('~tw'); expect(lakeText).not.toContain(':tokens'); - // Should show rendered content (colored text) - expect(lakeText.length).toBeGreaterThan(5); - - // Stepper navigation should work - const buttons = stepper.locator('button'); - await expect(buttons).toHaveCount(2); - const textBefore = await stepper.textContent(); - await buttons.last().click(); - await page.waitForTimeout(300); - const textAfter = await stepper.textContent(); - expect(textAfter).not.toBe(textBefore); - expect(t.errors()).toEqual([]); }); + +test('stepper: default view shows all four words', async ({ page }) => { + // Clear stepper cookie + await page.context().clearCookies(); + await loadPage(page, ''); + + const lake = page.locator('[data-sx-lake="home-preview"]'); + await expect(lake).toBeVisible({ timeout: 10000 }); + // Wait for hydration + await page.waitForTimeout(2000); + + const text = await lake.textContent(); + // All four words should be present after hydration + expect(text).toContain('the'); + expect(text).toContain('joy'); + expect(text).toContain('of'); + expect(text).toContain('sx'); +}); + +test('stepper: all spans inside h1 with correct structure', async ({ page }) => { + await page.context().clearCookies(); + await loadPage(page, ''); + await page.waitForTimeout(3000); + + const lake = page.locator('[data-sx-lake="home-preview"]'); + const h1 = lake.locator('h1'); + await expect(h1).toBeVisible({ timeout: 5000 }); + + // All colored spans should be inside the h1 + const spans = h1.locator('span'); + const count = await spans.count(); + expect(count).toBeGreaterThanOrEqual(4); +}); + +test('stepper: stepping forward renders styled text', async ({ page }) => { + // Start from step 0 + await page.context().addCookies([{ + name: 'sx-home-stepper', + value: '0', + url: 'http://localhost:8013' + }]); + await loadPage(page, ''); + await page.waitForTimeout(3000); + + const fwdBtn = page.locator('button:has-text("▶")'); + await expect(fwdBtn).toBeVisible({ timeout: 5000 }); + + // Step forward 10 times to get through "of" + for (let i = 0; i < 10; i++) { + await fwdBtn.click(); + await page.waitForTimeout(300); + } + + const lake = page.locator('[data-sx-lake="home-preview"]'); + const text = await lake.textContent(); + expect(text).toContain('of'); + + // The "of" text should be in a styled span (with sx- or data-tw class) + const styledSpan = lake.locator('span[data-tw]').filter({ hasText: 'of' }); + const count = await styledSpan.count(); + expect(count).toBeGreaterThan(0); +}); diff --git a/web/page-helpers.sx b/web/page-helpers.sx index eb2b7dd2..e538cb91 100644 --- a/web/page-helpers.sx +++ b/web/page-helpers.sx @@ -49,7 +49,7 @@ "Other"))) (when (not (has-key? categories category)) - (dict-set! categories category (mutable-list))) + (dict-set! categories category (list))) (append! (get categories category) {:doc (or (get kwargs "doc") "") :example (or (get kwargs "example") "") :tail-position (or (get kwargs "tail-position") "") :syntax (or (get kwargs "syntax") "") :name name})))) parsed-exprs) categories))) @@ -200,7 +200,7 @@ (fn ((pages-raw :as list)) (let - ((pages-data (mutable-list)) (client-count 0) (server-count 0)) + ((pages-data (list)) (client-count 0) (server-count 0)) (for-each (fn ((page :as dict))