content: ordered block document + edit ops + 40 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@ if [ ! -x "$SX_SERVER" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SUITES=(block)
|
SUITES=(block doc)
|
||||||
|
|
||||||
OUT_JSON="lib/content/scoreboard.json"
|
OUT_JSON="lib/content/scoreboard.json"
|
||||||
OUT_MD="lib/content/scoreboard.md"
|
OUT_MD="lib/content/scoreboard.md"
|
||||||
@@ -34,6 +34,7 @@ run_suite() {
|
|||||||
(load "lib/guest/reflective/env.sx")
|
(load "lib/guest/reflective/env.sx")
|
||||||
(load "lib/smalltalk/eval.sx")
|
(load "lib/smalltalk/eval.sx")
|
||||||
(load "lib/content/block.sx")
|
(load "lib/content/block.sx")
|
||||||
|
(load "lib/content/doc.sx")
|
||||||
(epoch 2)
|
(epoch 2)
|
||||||
(eval "(define content-test-pass 0)")
|
(eval "(define content-test-pass 0)")
|
||||||
(eval "(define content-test-fail 0)")
|
(eval "(define content-test-fail 0)")
|
||||||
|
|||||||
194
lib/content/doc.sx
Normal file
194
lib/content/doc.sx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
;; content-on-sx — ordered block document on Smalltalk-on-SX.
|
||||||
|
;;
|
||||||
|
;; A document (CtDoc) is a Smalltalk object holding an ordered sequence of block
|
||||||
|
;; objects. Editing is a stream of ops (data dicts); doc-apply interprets one op
|
||||||
|
;; and returns a NEW document — the input is never mutated, so any version is the
|
||||||
|
;; head of an op stream (replay-friendly for persist + CRDT merge).
|
||||||
|
;;
|
||||||
|
;; Op shapes (data, not objects — they are the persist event payload):
|
||||||
|
;; {:op "insert" :block <blk> :after <id|nil>} ; after nil = prepend
|
||||||
|
;; {:op "update" :id <id> :field <name> :value <v>}
|
||||||
|
;; {:op "move" :id <id> :index <n>}
|
||||||
|
;; {:op "delete" :id <id>}
|
||||||
|
|
||||||
|
(define
|
||||||
|
content-bootstrap-doc!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(begin
|
||||||
|
(st-class-define! "CtDoc" "Object" (list "id" "blocks"))
|
||||||
|
(ct-def-method! "CtDoc" "id" "id ^ id")
|
||||||
|
(ct-def-method! "CtDoc" "blocks" "blocks ^ blocks")
|
||||||
|
(ct-def-method! "CtDoc" "type" "type ^ #document")
|
||||||
|
true)))
|
||||||
|
|
||||||
|
;; ── construction ──
|
||||||
|
(define
|
||||||
|
doc-new
|
||||||
|
(fn
|
||||||
|
(id blocks)
|
||||||
|
(st-iv-set!
|
||||||
|
(st-iv-set! (st-make-instance "CtDoc") "id" id)
|
||||||
|
"blocks"
|
||||||
|
blocks)))
|
||||||
|
|
||||||
|
(define doc-empty (fn (id) (doc-new id (list))))
|
||||||
|
|
||||||
|
;; ── accessors (message dispatch) ──
|
||||||
|
(define doc-id (fn (doc) (st-send doc "id" (list))))
|
||||||
|
(define doc-type (fn (doc) (str (st-send doc "type" (list)))))
|
||||||
|
(define doc-blocks (fn (doc) (st-send doc "blocks" (list))))
|
||||||
|
(define doc-count (fn (doc) (len (doc-blocks doc))))
|
||||||
|
(define doc-block-at (fn (doc i) (nth (doc-blocks doc) i)))
|
||||||
|
|
||||||
|
(define doc? (fn (v) (and (st-instance? v) (= (get v :class) "CtDoc"))))
|
||||||
|
|
||||||
|
;; ── list helpers over block sequences ──
|
||||||
|
(define
|
||||||
|
ct-index-loop
|
||||||
|
(fn
|
||||||
|
(blocks id i)
|
||||||
|
(cond
|
||||||
|
((= (len blocks) 0) -1)
|
||||||
|
((= (blk-id (first blocks)) id) i)
|
||||||
|
(else (ct-index-loop (rest blocks) id (+ i 1))))))
|
||||||
|
|
||||||
|
(define ct-index-of (fn (blocks id) (ct-index-loop blocks id 0)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ct-insert-at
|
||||||
|
(fn
|
||||||
|
(blocks i x)
|
||||||
|
(cond
|
||||||
|
((= i 0) (cons x blocks))
|
||||||
|
((= (len blocks) 0) (list x))
|
||||||
|
(else
|
||||||
|
(cons
|
||||||
|
(first blocks)
|
||||||
|
(ct-insert-at (rest blocks) (- i 1) x))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ct-remove-id
|
||||||
|
(fn
|
||||||
|
(blocks id)
|
||||||
|
(filter (fn (b) (if (= (blk-id b) id) false true)) blocks)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ct-replace-id
|
||||||
|
(fn
|
||||||
|
(blocks id f)
|
||||||
|
(map (fn (b) (if (= (blk-id b) id) (f b) b)) blocks)))
|
||||||
|
|
||||||
|
;; ── query ──
|
||||||
|
(define doc-index-of (fn (doc id) (ct-index-of (doc-blocks doc) id)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
doc-find
|
||||||
|
(fn
|
||||||
|
(doc id)
|
||||||
|
(let
|
||||||
|
((hits (filter (fn (b) (= (blk-id b) id)) (doc-blocks doc))))
|
||||||
|
(if (= (len hits) 0) nil (first hits)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
doc-has?
|
||||||
|
(fn (doc id) (if (= (doc-index-of doc id) -1) false true)))
|
||||||
|
|
||||||
|
;; ── structural edits (each returns a new document) ──
|
||||||
|
(define doc-with-blocks (fn (doc blocks) (st-iv-set! doc "blocks" blocks)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
doc-append
|
||||||
|
(fn
|
||||||
|
(doc block)
|
||||||
|
(doc-with-blocks doc (append (doc-blocks doc) (list block)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
doc-insert-at
|
||||||
|
(fn
|
||||||
|
(doc block i)
|
||||||
|
(doc-with-blocks doc (ct-insert-at (doc-blocks doc) i block))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
doc-insert-after
|
||||||
|
(fn
|
||||||
|
(doc block after-id)
|
||||||
|
(let
|
||||||
|
((blocks (doc-blocks doc)))
|
||||||
|
(if
|
||||||
|
(= after-id nil)
|
||||||
|
(doc-with-blocks doc (cons block blocks))
|
||||||
|
(let
|
||||||
|
((idx (ct-index-of blocks after-id)))
|
||||||
|
(if
|
||||||
|
(= idx -1)
|
||||||
|
(doc-with-blocks doc (append blocks (list block)))
|
||||||
|
(doc-with-blocks
|
||||||
|
doc
|
||||||
|
(ct-insert-at blocks (+ idx 1) block))))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
doc-update
|
||||||
|
(fn
|
||||||
|
(doc id field value)
|
||||||
|
(doc-with-blocks
|
||||||
|
doc
|
||||||
|
(ct-replace-id (doc-blocks doc) id (fn (b) (blk-set b field value))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
doc-delete
|
||||||
|
(fn (doc id) (doc-with-blocks doc (ct-remove-id (doc-blocks doc) id))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
doc-move
|
||||||
|
(fn
|
||||||
|
(doc id i)
|
||||||
|
(let
|
||||||
|
((blk (doc-find doc id)))
|
||||||
|
(if
|
||||||
|
(= blk nil)
|
||||||
|
doc
|
||||||
|
(doc-with-blocks
|
||||||
|
doc
|
||||||
|
(ct-insert-at (ct-remove-id (doc-blocks doc) id) i blk))))))
|
||||||
|
|
||||||
|
;; ── op constructors (data payload, reused by persist op log) ──
|
||||||
|
(define op-insert (fn (block after) {:after after :op "insert" :block block}))
|
||||||
|
|
||||||
|
(define op-update (fn (id field value) {:field field :id id :op "update" :value value}))
|
||||||
|
|
||||||
|
(define op-move (fn (id index) {:id id :op "move" :index index}))
|
||||||
|
|
||||||
|
(define op-delete (fn (id) {:id id :op "delete"}))
|
||||||
|
|
||||||
|
;; ── op interpreter ──
|
||||||
|
(define
|
||||||
|
doc-apply
|
||||||
|
(fn
|
||||||
|
(doc op)
|
||||||
|
(let
|
||||||
|
((kind (get op :op)))
|
||||||
|
(cond
|
||||||
|
((= kind "insert")
|
||||||
|
(doc-insert-after doc (get op :block) (get op :after)))
|
||||||
|
((= kind "update")
|
||||||
|
(doc-update doc (get op :id) (get op :field) (get op :value)))
|
||||||
|
((= kind "move") (doc-move doc (get op :id) (get op :index)))
|
||||||
|
((= kind "delete") (doc-delete doc (get op :id)))
|
||||||
|
(else (error (str "unknown op: " kind)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
doc-apply-all
|
||||||
|
(fn
|
||||||
|
(doc ops)
|
||||||
|
(if
|
||||||
|
(= (len ops) 0)
|
||||||
|
doc
|
||||||
|
(doc-apply-all (doc-apply doc (first ops)) (rest ops)))))
|
||||||
|
|
||||||
|
;; ── render-agnostic snapshot: list of (id . type) for assertions/debug ──
|
||||||
|
(define doc-ids (fn (doc) (map (fn (b) (blk-id b)) (doc-blocks doc))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
doc-types
|
||||||
|
(fn (doc) (map (fn (b) (blk-type b)) (doc-blocks doc))))
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"suites": {
|
"suites": {
|
||||||
"block": {"pass": 38, "fail": 0}
|
"block": {"pass": 38, "fail": 0},
|
||||||
|
"doc": {"pass": 40, "fail": 0}
|
||||||
},
|
},
|
||||||
"total_pass": 38,
|
"total_pass": 78,
|
||||||
"total_fail": 0,
|
"total_fail": 0,
|
||||||
"total": 38
|
"total": 78
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ _Generated by `lib/content/conformance.sh`_
|
|||||||
| Suite | Pass | Fail | Total |
|
| Suite | Pass | Fail | Total |
|
||||||
|-------|-----:|-----:|------:|
|
|-------|-----:|-----:|------:|
|
||||||
| block | 38 | 0 | 38 |
|
| block | 38 | 0 | 38 |
|
||||||
| **Total** | **38** | **0** | **38** |
|
| doc | 40 | 0 | 40 |
|
||||||
|
| **Total** | **78** | **0** | **78** |
|
||||||
|
|||||||
132
lib/content/tests/doc.sx
Normal file
132
lib/content/tests/doc.sx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
;; Phase 1 — ordered block document: apply edit ops, structural moves.
|
||||||
|
;; Every op returns a NEW document; the input is never mutated.
|
||||||
|
|
||||||
|
(st-bootstrap-classes!)
|
||||||
|
(content-bootstrap-blocks!)
|
||||||
|
(content-bootstrap-doc!)
|
||||||
|
|
||||||
|
(define h (mk-heading "h" 1 "Title"))
|
||||||
|
(define p1 (mk-text "p1" "First"))
|
||||||
|
(define p2 (mk-text "p2" "Second"))
|
||||||
|
(define img (mk-image "img" "/c.png" "cat"))
|
||||||
|
|
||||||
|
;; ── empty + construction ──
|
||||||
|
(define d0 (doc-empty "doc1"))
|
||||||
|
(content-test "empty id" (doc-id d0) "doc1")
|
||||||
|
(content-test "empty type" (doc-type d0) "document")
|
||||||
|
(content-test "empty count" (doc-count d0) 0)
|
||||||
|
(content-test "doc? on doc" (doc? d0) true)
|
||||||
|
(content-test "doc? on block" (doc? h) false)
|
||||||
|
|
||||||
|
;; ── append + order ──
|
||||||
|
(define d1 (doc-append (doc-append (doc-append d0 h) p1) p2))
|
||||||
|
(content-test "append count" (doc-count d1) 3)
|
||||||
|
(content-test "append order" (doc-ids d1) (list "h" "p1" "p2"))
|
||||||
|
(content-test "append types" (doc-types d1) (list "heading" "text" "text"))
|
||||||
|
(content-test "block-at 0" (blk-id (doc-block-at d1 0)) "h")
|
||||||
|
|
||||||
|
;; ── append is immutable ──
|
||||||
|
(content-test "append leaves original" (doc-count d0) 0)
|
||||||
|
|
||||||
|
;; ── find / index / has ──
|
||||||
|
(content-test "find p1" (blk-id (doc-find d1 "p1")) "p1")
|
||||||
|
(content-test "find missing" (doc-find d1 "nope") nil)
|
||||||
|
(content-test "index-of p2" (doc-index-of d1 "p2") 2)
|
||||||
|
(content-test "index-of missing" (doc-index-of d1 "nope") -1)
|
||||||
|
(content-test "has? yes" (doc-has? d1 "h") true)
|
||||||
|
(content-test "has? no" (doc-has? d1 "x") false)
|
||||||
|
|
||||||
|
;; ── insert-after ──
|
||||||
|
(define d2 (doc-insert-after d1 img "h"))
|
||||||
|
(content-test "insert-after order" (doc-ids d2) (list "h" "img" "p1" "p2"))
|
||||||
|
(content-test
|
||||||
|
"insert-after prepend"
|
||||||
|
(doc-ids (doc-insert-after d1 img nil))
|
||||||
|
(list "img" "h" "p1" "p2"))
|
||||||
|
(content-test
|
||||||
|
"insert-after missing appends"
|
||||||
|
(doc-ids (doc-insert-after d1 img "zzz"))
|
||||||
|
(list "h" "p1" "p2" "img"))
|
||||||
|
(content-test "insert-after immutable" (doc-ids d1) (list "h" "p1" "p2"))
|
||||||
|
|
||||||
|
;; ── insert-at ──
|
||||||
|
(content-test
|
||||||
|
"insert-at 0"
|
||||||
|
(doc-ids (doc-insert-at d1 img 0))
|
||||||
|
(list "img" "h" "p1" "p2"))
|
||||||
|
(content-test
|
||||||
|
"insert-at 1"
|
||||||
|
(doc-ids (doc-insert-at d1 img 1))
|
||||||
|
(list "h" "img" "p1" "p2"))
|
||||||
|
|
||||||
|
;; ── update (copy-on-write block) ──
|
||||||
|
(define d3 (doc-update d1 "p1" "text" "Edited"))
|
||||||
|
(content-test
|
||||||
|
"update value"
|
||||||
|
(str (blk-send (doc-find d3 "p1") "text"))
|
||||||
|
"Edited")
|
||||||
|
(content-test "update keeps order" (doc-ids d3) (list "h" "p1" "p2"))
|
||||||
|
(content-test
|
||||||
|
"update immutable"
|
||||||
|
(str (blk-send (doc-find d1 "p1") "text"))
|
||||||
|
"First")
|
||||||
|
|
||||||
|
;; ── delete ──
|
||||||
|
(define d4 (doc-delete d1 "p1"))
|
||||||
|
(content-test "delete order" (doc-ids d4) (list "h" "p2"))
|
||||||
|
(content-test "delete count" (doc-count d4) 2)
|
||||||
|
(content-test "delete immutable" (doc-count d1) 3)
|
||||||
|
(content-test
|
||||||
|
"delete missing no-op"
|
||||||
|
(doc-ids (doc-delete d1 "x"))
|
||||||
|
(list "h" "p1" "p2"))
|
||||||
|
|
||||||
|
;; ── move ──
|
||||||
|
(content-test
|
||||||
|
"move p2 to front"
|
||||||
|
(doc-ids (doc-move d1 "p2" 0))
|
||||||
|
(list "p2" "h" "p1"))
|
||||||
|
(content-test
|
||||||
|
"move h to end"
|
||||||
|
(doc-ids (doc-move d1 "h" 2))
|
||||||
|
(list "p1" "p2" "h"))
|
||||||
|
(content-test
|
||||||
|
"move missing no-op"
|
||||||
|
(doc-ids (doc-move d1 "x" 0))
|
||||||
|
(list "h" "p1" "p2"))
|
||||||
|
(content-test "move immutable" (doc-ids d1) (list "h" "p1" "p2"))
|
||||||
|
|
||||||
|
;; ── op constructors + interpreter ──
|
||||||
|
(content-test
|
||||||
|
"op-insert apply"
|
||||||
|
(doc-ids (doc-apply d1 (op-insert img "h")))
|
||||||
|
(list "h" "img" "p1" "p2"))
|
||||||
|
(content-test
|
||||||
|
"op-delete apply"
|
||||||
|
(doc-ids (doc-apply d1 (op-delete "h")))
|
||||||
|
(list "p1" "p2"))
|
||||||
|
(content-test
|
||||||
|
"op-move apply"
|
||||||
|
(doc-ids (doc-apply d1 (op-move "p2" 0)))
|
||||||
|
(list "p2" "h" "p1"))
|
||||||
|
(content-test
|
||||||
|
"op-update apply"
|
||||||
|
(str
|
||||||
|
(blk-send
|
||||||
|
(doc-find (doc-apply d1 (op-update "p1" "text" "X")) "p1")
|
||||||
|
"text"))
|
||||||
|
"X")
|
||||||
|
|
||||||
|
;; ── apply-all: a stream of ops ──
|
||||||
|
(define
|
||||||
|
ops
|
||||||
|
(list (op-insert img "h") (op-delete "p1") (op-move "p2" 0)))
|
||||||
|
(content-test
|
||||||
|
"apply-all"
|
||||||
|
(doc-ids (doc-apply-all d1 ops))
|
||||||
|
(list "p2" "h" "img"))
|
||||||
|
(content-test "apply-all immutable" (doc-ids d1) (list "h" "p1" "p2"))
|
||||||
|
(content-test
|
||||||
|
"apply-all empty"
|
||||||
|
(doc-ids (doc-apply-all d1 (list)))
|
||||||
|
(list "h" "p1" "p2"))
|
||||||
@@ -19,7 +19,7 @@ injected adapter, not core.
|
|||||||
|
|
||||||
## Status (rolling)
|
## Status (rolling)
|
||||||
|
|
||||||
`bash lib/content/conformance.sh` → **38/38** (Phase 1: typed blocks)
|
`bash lib/content/conformance.sh` → **78/78** (Phase 1: blocks + doc)
|
||||||
|
|
||||||
## Ground rules
|
## Ground rules
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─
|
|||||||
|
|
||||||
## Phase 1 — Block document model
|
## Phase 1 — Block document model
|
||||||
- [x] `block.sx` — typed block objects
|
- [x] `block.sx` — typed block objects
|
||||||
- [ ] `doc.sx` — ordered tree, apply edit op, structural moves
|
- [x] `doc.sx` — ordered tree, apply edit op, structural moves
|
||||||
- [ ] `render.sx` — block tree → HTML/SX
|
- [ ] `render.sx` — block tree → HTML/SX
|
||||||
- [ ] `api.sx` + tests + scoreboard + conformance.sh
|
- [ ] `api.sx` + tests + scoreboard + conformance.sh
|
||||||
|
|
||||||
@@ -77,6 +77,12 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─
|
|||||||
|
|
||||||
## Progress log
|
## Progress log
|
||||||
|
|
||||||
|
- 2026-06-06 — Phase 1 `doc.sx`: ordered block document (`CtDoc`) as a
|
||||||
|
Smalltalk object holding an ordered block sequence. Edit ops are data dicts
|
||||||
|
(`insert`/`update`/`move`/`delete`) with `op-*` constructors; `doc-apply` /
|
||||||
|
`doc-apply-all` interpret an op stream, each returning a NEW document (input
|
||||||
|
never mutated → replay-safe). Structural moves, insert-after/at, find/index,
|
||||||
|
immutability all tested. 40 tests; suite 78/78.
|
||||||
- 2026-06-06 — Phase 1 `block.sx`: typed block objects as Smalltalk instances
|
- 2026-06-06 — Phase 1 `block.sx`: typed block objects as Smalltalk instances
|
||||||
(`CtBlock` hierarchy: text/heading/code/quote/image/embed/divider/list).
|
(`CtBlock` hierarchy: text/heading/code/quote/image/embed/divider/list).
|
||||||
Type tag + accessors are message sends (polymorphic dispatch); fields are
|
Type tag + accessors are message sends (polymorphic dispatch); fields are
|
||||||
|
|||||||
Reference in New Issue
Block a user