spec/render_spec.cr @ trunk
spec
scale = 0.75 fontscale = 1.500 box wid 1.380 ht 0.340 with .nw at (0.035, -0.035) fill 0xCBD5E1 color 0xCBD5E1 box wid 1.380 ht 0.340 with .nw at (0.000, 0.000) fill 0x7C83E6 color 0x7C83E6 box wid 0.300 ht 0.065 rad 0.018 at (0.370, -0.022) fill 0xffffff color 0xffffff line from (0.140, 0.000) to (0.140, -0.340) color white line from (1.240, 0.000) to (1.240, -0.340) color white text "require 'spec'" color white at (0.690, -0.170)→ /pikchrshow
crystal source
require "spec"
json block
{
"kind": "require",
"name": "spec",
"doc": null,
"children": []
}
../src/gpcr/render
scale = 0.75 fontscale = 1.500 box wid 2.343 ht 0.340 with .nw at (0.035, -0.455) fill 0xCBD5E1 color 0xCBD5E1 box wid 2.343 ht 0.340 with .nw at (0.000, -0.420) fill 0x7C83E6 color 0x7C83E6 box wid 0.300 ht 0.065 rad 0.018 at (0.370, -0.443) fill 0xffffff color 0xffffff line from (0.140, -0.420) to (0.140, -0.760) color white line from (2.203, -0.420) to (2.203, -0.760) color white text "require '../src/gpcr/render'" color white at (1.172, -0.590)→ /pikchrshow
crystal source
require "../src/gpcr/render"
json block
{
"kind": "require",
"name": "../src/gpcr/render",
"doc": null,
"children": []
}
../src/gpcr/gallery
scale = 0.75 fontscale = 1.500 box wid 2.353 ht 0.340 with .nw at (0.035, -0.875) fill 0xCBD5E1 color 0xCBD5E1 box wid 2.353 ht 0.340 with .nw at (0.000, -0.840) fill 0x7C83E6 color 0x7C83E6 box wid 0.300 ht 0.065 rad 0.018 at (0.370, -0.863) fill 0xffffff color 0xffffff line from (0.140, -0.840) to (0.140, -1.180) color white line from (2.213, -0.840) to (2.213, -1.180) color white text "require '../src/gpcr/gallery'" color white at (1.177, -1.010)→ /pikchrshow
crystal source
require "../src/gpcr/gallery"
json block
{
"kind": "require",
"name": "../src/gpcr/gallery",
"doc": null,
"children": []
}
SRC = File.read(File.join(__DIR__, "fixtures", "edit_fixture.cr"))
# Shape fixtures, inline: one predefined_process face and the document-shaped
# file primitives (which live in src/gpcr/std.cr in the real palette).
PRIMS_SRC = <<-GPCR_SRC
@[GPCR::Primitive(shape: :predefined_process, label: "run subroutine \#{name}", category: "Control")]
def run_subroutine(name : String)
end
@[GPCR::Primitive(shape: :document, label: "open file \#{path}", category: "File")]
def open_file(path : String)
File.open(path)
end
@[GPCR::Primitive(shape: :document, label: "read line from \#{stream}", category: "File")]
def read_line(stream : IO)
stream.gets || ""
end
@[GPCR::Primitive(shape: :document, label: "close \#{stream}", category: "File")]
def close_file(stream : IO)
stream.close
end
GPCR_SRC
RING_SRC = <<-GPCR_SRC
@[GPCR::Primitive(shape: :ring, label: "ring shape", category: "Gallery", returns: :string)]
def gallery_ring : String
""
end
GPCR_SRC
CASE_SRC = File.read(File.join(__DIR__, "..", "examples", "case_dispatch.cr"))
# Block def fixture: one Primitive and one Block with an if-chain body,
# mirroring the block_color live case (Block def whose body is an if-chain).
BLOCK_DEF_SRC = <<-GPCR_SRC
@[GPCR::Primitive(shape: :command, label: "paint \#{color}")]
def paint(color : String)
end
@[GPCR::Block(shape: :reporter, label: "pick color \#{flag}", returns: :string, category: "Renderer")]
def pick_color(flag : Bool) : String
if flag
paint "red"
else
paint "blue"
end
end
GPCR_SRC
# Source containing triple backticks — verifies fence_for escaping.
# Uses a regular string so backticks are literal characters in value.
# Stack with three blocks of different label lengths → tab center-x must be uniform
ALIGN_SRC = <<-GPCR_SRC
@[GPCR::Primitive(shape: :command, label: "short")]
def short_block; end
@[GPCR::Primitive(shape: :command, label: "a much longer label here")]
def long_block; end
@[GPCR::Primitive(shape: :command, label: "medium label")]
def medium_block; end
@[GPCR::Graph(x: 0, y: 0)]
def align_test
short_block
long_block
medium_block
end
GPCR_SRC
BACKTICK_SRC = "# plain code\nx = \"```\"\n"
BACKTICK_DEF_SRC = <<-GPCR_SRC
@[GPCR::Primitive(shape: :command, label: "ticks")]
def ticks
"```"
end
GPCR_SRC
# Fixtures for private def (VisibilityModifier) unwrapping tests.
PRIVATE_PRIM_SRC = <<-GPCR_SRC
@[GPCR::Primitive(shape: :command, label: "do the thing")]
private def do_thing
end
GPCR_SRC
PRIVATE_BLOCK_SRC2 = <<-GPCR_SRC
@[GPCR::Primitive(shape: :command, label: "step")]
def step_impl
end
@[GPCR::Block(shape: :command, label: "run \#{step}", category: "Control")]
private def run_step(step : String)
step_impl
end
GPCR_SRC
describe GPCR::Node do
it "holds kind, name, args, children, else_nodes, doc, and line span" do
child = GPCR::Node.new(:call, "foo", ["x", "y"], [] of GPCR::Node)
child.kind.should eq(:call)
child.name.should eq("foo")
child.args.should eq(["x", "y"])
child.children.should be_empty
child.else_nodes.should be_empty
child.doc.should be_nil
child.line_start.should eq(0)
child.line_end.should eq(0)
end
it "accepts doc and line span" do
n = GPCR::Node.new(:graph, "blink", [] of String, [] of GPCR::Node,
[] of GPCR::Node, "blink doc", 34, 43)
n.doc.should eq("blink doc")
n.line_start.should eq(34)
n.line_end.should eq(43)
end
it "accepts else_nodes for :if kind" do
then_n = GPCR::Node.new(:call, "a", [] of String, [] of GPCR::Node)
else_n = GPCR::Node.new(:call, "b", [] of String, [] of GPCR::Node)
n = GPCR::Node.new(:if, "x > 0", [] of String, [then_n], [else_n])
n.kind.should eq(:if)
n.name.should eq("x > 0")
n.children.size.should eq(1)
n.else_nodes.size.should eq(1)
end
end
describe "GPCR.parse_source" do
prims = {} of String => GPCR::Prim
nodes = GPCR.parse_source(SRC, prims)
it "fills the prims map from @[Primitive]/@[Block] defs in the source" do
prims.keys.sort.should eq(["forever", "on_start", "set_user_led", "wait"])
prims["on_start"].shape.should eq("hat")
prims["wait"].shape.should eq("command")
end
it "returns top-level nodes with correct kinds" do
kinds = nodes.map(&.kind)
kinds.should contain(:plain)
kinds.should contain(:require)
kinds.should contain(:primitive)
kinds.should contain(:graph)
kinds.count(:graph).should eq(1)
kinds.count(:primitive).should eq(4) # on_start, set_user_led, wait, forever
end
it "assigns :require kind to top-level require statements" do
req = nodes.find { |n| n.kind == :require }.not_nil!
req.name.should eq("../../src/gpcr")
req.line_start.should eq(1)
end
it "names :primitive nodes after the annotated defs" do
prim_names = nodes.select { |n| n.kind == :primitive }.map(&.name).sort
prim_names.should eq(["forever", "on_start", "set_user_led", "wait"])
end
it "captures the doc comment on the :graph node" do
blink = nodes.find { |n| n.kind == :graph }.not_nil!
blink.name.should eq("blink")
blink.doc.should eq("blink: LED on/off loop after start")
end
it "builds body nodes for the :graph, typing them correctly" do
blink = nodes.find { |n| n.kind == :graph }.not_nil!
# blink body: on_start (call), forever do...end (c_call)
blink.children.size.should eq(2)
blink.children[0].kind.should eq(:call)
blink.children[0].name.should eq("on_start")
blink.children[1].kind.should eq(:c_call)
blink.children[1].name.should eq("forever")
end
it "recursively builds c_call children with literalized args" do
forever = nodes.find { |n| n.kind == :graph }.not_nil!
.children.find { |c| c.kind == :c_call }.not_nil!
forever.children.size.should eq(4)
forever.children.map(&.kind).uniq.should eq([:call])
forever.children.map(&.name).should eq(["set_user_led", "wait", "set_user_led", "wait"])
forever.children.map(&.args).should eq([["true"], ["0.5"], ["false"], ["0.5"]])
end
it "records correct line span on :graph nodes (annotation start to def end)" do
blink = nodes.find { |n| n.kind == :graph }.not_nil!
blink.line_start.should eq(34) # @[GPCR::Graph] line
blink.line_end.should eq(43) # end keyword line
end
it "assigns :plain kind to unannotated top-level code" do
plain_nodes = nodes.select { |n| n.kind == :plain }
plain_nodes.size.should be > 0
# The leading require renders as prose; plain starts at the setup assignment.
plain_nodes.first.line_start.should eq(7)
end
describe "with seed faces (faces: from reflection)" do
it "pre-seeds prims from faces and source defs take precedence" do
extra_prims = {} of String => GPCR::Prim
fake_face = GPCR::Face.new("fake_block", "boolean", "fake label")
GPCR.parse_source(SRC, extra_prims)
extra_prims.keys.should contain("on_start")
extra_prims.keys.should contain("wait")
end
end
describe "Prim#label with Array(String) args" do
it "fills slots from pre-literalized arg strings" do
p = prims["set_user_led"].not_nil!
p.label(["true"]).should eq("set user LED [true]")
p.label(["false"]).should eq("set user LED [false]")
p.label.should eq("set user LED [state]") # no args → slot placeholder
end
it "multi-slot label fills in order" do
p = prims["wait"].not_nil!
p.label(["0.5"]).should eq("wait [0.5] seconds")
end
end
describe "case/when in parse_source" do
case_prims = {} of String => GPCR::Prim
case_nodes = GPCR.parse_source(CASE_SRC, case_prims)
it "produces :graph nodes for case_dispatch.cr" do
graphs = case_nodes.select { |n| n.kind == :graph }
graphs.map(&.name).sort.should eq(["dispatch_graph", "dispatch_range"])
end
it "produces a :case body node for dispatch_graph" do
graph = case_nodes.find { |n| n.name == "dispatch_graph" }.not_nil!
graph.children.size.should eq(1)
graph.children[0].kind.should eq(:case)
end
it "case node has :when children and else_nodes" do
dispatch = case_nodes.find { |n| n.name == "dispatch_graph" }.not_nil!
case_node = dispatch.children[0]
case_node.children.map(&.kind).should eq([:when, :when, :when])
case_node.else_nodes.size.should eq(1)
end
it "literal when conditions are bracketed in the when name" do
dispatch = case_nodes.find { |n| n.name == "dispatch_graph" }.not_nil!
case_node = dispatch.children[0]
when_names = case_node.children.map(&.name)
when_names.should contain("when [hat]")
when_names.should contain("when [command], [boolean]")
end
it "non-literal when conditions are unbracketed" do
dispatch = case_nodes.find { |n| n.name == "dispatch_range" }.not_nil!
case_node = dispatch.children[0]
case_node.children[0].name.should eq("when 1..5")
case_node.children[0].name.should_not contain("[")
end
end
describe "@[GPCR::Block] body reification" do
block_prims = {} of String => GPCR::Prim
block_nodes = GPCR.parse_source(BLOCK_DEF_SRC, block_prims)
it "parses @[GPCR::Block] defs as :block kind with reified body children" do
bn = block_nodes.find { |n| n.kind == :block }.not_nil!
bn.name.should eq("pick_color")
bn.children.size.should eq(1)
bn.children[0].kind.should eq(:if)
end
it "parses @[GPCR::Primitive] defs as :primitive kind with no children" do
pn = block_nodes.find { |n| n.kind == :primitive }.not_nil!
pn.name.should eq("paint")
pn.children.should be_empty
end
it "if-chain body: condition is the node name, branches are children/else_nodes" do
bn = block_nodes.find { |n| n.kind == :block }.not_nil!
if_node = bn.children[0]
if_node.name.should eq("flag")
if_node.children.size.should eq(1)
if_node.else_nodes.size.should eq(1)
end
end
describe "canonical block JSON round-trip (node_tree_json)" do
draw_src = File.read(File.join(__DIR__, "..", "src", "gpcr", "draw.cr"))
faces = GPCR.blocks_of
draw_md = render_source(draw_src, nil, faces)
# Load schema once at describe level; if the file is missing the suite fails loudly.
schema = JSON.parse(File.read(File.join(__DIR__, "fixtures", "gpcr_block_node.schema.json")))
# call_node is a oneOf variant inside $defs.body_node
call_node_def = schema["$defs"]["body_node"]["oneOf"].as_a
.find! { |d| d["title"]?.try(&.as_s) == "call_node" }
it "schema fixture is valid and exposes call_node required fields" do
schema["$schema"].as_s.should contain("json-schema.org")
call_node_def["required"].as_a.should_not be_empty
end
it "pik_point json block section conforms to schema required fields" do
root_required = schema["required"].as_a.map(&.as_s)
call_required = call_node_def["required"].as_a.map(&.as_s)
pik_section = draw_md.split("## pik_point\n")[1]?.to_s
json_text = pik_section.scan(/```json\n(.*?)```/m).first?.try(&.[1]?).to_s.strip
json_text.should_not be_empty
parsed = JSON.parse(json_text)
# Root fields derived from schema["required"]
root_required.each { |f| parsed.as_h.has_key?(f).should be_true }
parsed["kind"].as_s.should eq("block")
parsed["name"].as_s.should eq("pik_point")
parsed["shape"].as_s.should eq("reporter")
# Each call child: required fields derived from schema $defs.call_node.required
parsed["children"].as_a.each do |child|
call_required.each { |f| child.as_h.has_key?(f).should be_true }
end
end
it "pik_point body yields two receiverless fmt children with correct args and labels" do
pik_section = draw_md.split("## pik_point\n")[1]?.to_s
json_text = pik_section.scan(/```json\n(.*?)```/m).first?.try(&.[1]?).to_s.strip
parsed = JSON.parse(json_text)
children = parsed["children"].as_a
children.size.should eq(2)
children.each do |child|
child["name"].as_s.should eq("fmt")
child["receiver"].raw.should be_nil # receiverless plain call
child["shape"].as_s.should eq("reporter")
end
children[0]["args"].as_a.map(&.as_s).should eq(["x"])
children[0]["label"].as_s.should eq("format [x]")
children[1]["args"].as_a.map(&.as_s).should eq(["y"])
children[1]["label"].as_s.should eq("format [y]")
end
it "fmt block JSON body has operator child with explicit receiver field" do
fmt_section = draw_md.split("## fmt\n")[1]?.to_s
json_text = fmt_section.scan(/```json\n(.*?)```/m).first?.try(&.[1]?).to_s.strip
parsed = JSON.parse(json_text)
parsed["kind"].as_s.should eq("block")
parsed["name"].as_s.should eq("fmt")
children = parsed["children"].as_a
children.size.should eq(1)
op = children[0]
op["kind"].as_s.should eq("call")
op["name"].as_s.should eq("%")
# Explicit receiver field: "%.3f" is the receiver (LHS), "v" is the sole arg (RHS).
# Crystal emit: receiver % args[0] = "%.3f" % v — not v % "%.3f".
op["receiver"].as_s.should eq("%.3f")
op["args"].as_a.map(&.as_s).should eq(["v"])
op["label"].as_s.should eq("[%.3f] % [v]")
end
it "pik_box body: receiverless single-arg fmt and multi-arg pik_point round-trip" do
box_section = draw_md.split("## pik_box\n")[1]?.to_s
json_text = box_section.scan(/```json\n(.*?)```/m).first?.try(&.[1]?).to_s.strip
parsed = JSON.parse(json_text)
parsed["name"].as_s.should eq("pik_box")
# pik_box body is a single if-node
children = parsed["children"].as_a
children.size.should eq(1)
if_node = children[0]
if_node["kind"].as_s.should eq("if")
if_node["cond"].as_s.should contain("rad")
then_branch = if_node["then"].as_a
# then branch embeds fmt(w), fmt(h), fmt(rad), pik_point(x, y) from the interpolation
fmt_child = then_branch[0]
fmt_child["name"].as_s.should eq("fmt")
fmt_child["receiver"].raw.should be_nil # receiverless — not an operator call
fmt_child["args"].as_a.map(&.as_s).should eq(["w"])
fmt_child["label"].as_s.should eq("format [w]")
# pik_point(x, y) — multi-arg, no receiver
pp_child = then_branch.last
pp_child["name"].as_s.should eq("pik_point")
pp_child["receiver"].raw.should be_nil
pp_child["args"].as_a.map(&.as_s).should eq(["x", "y"])
pp_child["label"].as_s.should eq("point [x] [y]")
end
it "toolchain_packages table root: json block panel present, kind=table, headers and rows correct" do
tp_src = File.read(File.join(__DIR__, "..", "examples", "toolchain_packages.cr"))
tp_md = render_source(tp_src, nil, faces)
tp_section = tp_md.split("## PACKAGES\n")[1]?.to_s
json_text = tp_section.scan(/```json\n(.*?)```/m).first?.try(&.[1]?).to_s.strip
json_text.should_not be_empty
parsed = JSON.parse(json_text)
# Root required fields from schema (children removed; kind+name remain)
schema["required"].as_a.map(&.as_s).each { |f| parsed.as_h.has_key?(f).should be_true }
parsed["kind"].as_s.should eq("table")
parsed["name"].as_s.should eq("PACKAGES")
# Data-kind roots use headers/rows not children
parsed.as_h.has_key?("children").should be_false
headers = parsed["headers"].as_a.map(&.as_s)
headers.first.should eq("name: String")
headers.size.should eq(5)
rows = parsed["rows"].as_a
rows.size.should be > 0
rows[0].as_a.map(&.as_s).first.should eq("binutils")
end
it "blink graph root: json block panel present, kind=graph, schema required fields, correct children" do
blink_src = File.read(File.join(__DIR__, "..", "examples", "blink.cr"))
blink_md = render_source(blink_src, nil, faces)
blink_section = blink_md.split("## blink\n")[1]?.to_s
json_text = blink_section.scan(/```json\n(.*?)```/m).first?.try(&.[1]?).to_s.strip
json_text.should_not be_empty
parsed = JSON.parse(json_text)
# Root required fields from schema
schema["required"].as_a.map(&.as_s).each { |f| parsed.as_h.has_key?(f).should be_true }
parsed["kind"].as_s.should eq("graph")
parsed["name"].as_s.should eq("blink")
parsed["doc"].as_s.should eq("blink: LED on/off loop after start")
# Graph roots have no @[GPCR::Block] registration — face fields must be absent
parsed.as_h.has_key?("label").should be_false
parsed.as_h.has_key?("shape").should be_false
# Two body children: on_start (call) and forever (c_call)
children = parsed["children"].as_a
children.size.should eq(2)
on_start = children[0]
on_start["kind"].as_s.should eq("call")
on_start["name"].as_s.should eq("on_start")
on_start["receiver"].raw.should be_nil
forever = children[1]
forever["kind"].as_s.should eq("c_call")
forever["name"].as_s.should eq("forever")
# forever body: set_user_led, wait, set_user_led, wait
body = forever["body"].as_a
body.size.should eq(4)
body.map { |c| c["kind"].as_s }.should eq(["call", "call", "call", "call"])
body.map { |c| c["name"].as_s }.should eq(["set_user_led", "wait", "set_user_led", "wait"])
end
end
end
describe GPCR::Renderer do
md = GPCR::Renderer.new(SRC).render
it "renders the graph's doc comment as prose" do
md.should contain("*blink: LED on/off loop after start*")
end
it "renders each annotated def under its own heading" do
md.should contain("## blink")
md.should contain("## on_start")
md.should contain("## set_user_led")
end
it "fills label slots from call arguments" do
md.should contain(%(text "set user LED " color white))
md.should contain(%(text "true" color 0x263238))
md.should contain(%(text "wait " color white))
md.should contain(%(text "0.5" color 0x263238))
md.should contain(%(text " seconds" color white))
end
it "renders a do…end call as a C-shape with nested blocks" do
ts = GPCR::Tokens::MICROBLOCKS_COMPACT
graph = md.split("## blink")[1]
graph.should contain(%(text "forever"))
# cmd_rad and c_inner_rad both = 0.035 in microblocks-compact; count covers
# shadow boxes, main boxes, and inner well boxes for nested blocks.
graph.scan("rad #{fmt(ts.cmd_rad)}").size.should be >= 8
end
it "passes unannotated code through as a crystal fence" do
md.should contain("GPCR_START = Channel(Nil).new(0)")
md.should contain("spawn { blink }")
end
it "keeps the source fence byte-identical to the input lines" do
md.should contain("def blink\n on_start\n forever do")
end
it "collapses rendered node crystal source under native details" do
section = md.split("## on_start")[1].split("## set_user_led")[0]
section.should contain("```pikchr toggle")
section.should contain("<details>\n<summary>crystal source</summary>")
section.should contain("```crystal\n@[GPCR::Primitive")
section.should contain("</details>")
end
it "renders a require statement as a predefined-process pikchr block" do
req_md = GPCR::Renderer.new(%(require "foo/bar"\n)).render
req_md.should contain("## foo/bar")
req_md.should contain("```pikchr toggle")
req_md.should contain("require 'foo/bar'")
req_md.should_not contain("*require* `foo/bar`")
end
it "emits a json block panel for require nodes" do
req_md = GPCR::Renderer.new(%(require "foo/bar"\n)).render
req_md.should contain("<details>")
req_md.should contain("json block")
req_md.should contain(%("kind": "require"))
req_md.should contain(%("name": "foo/bar"))
end
it "emits only valid pikchr fences" do
unless Process.find_executable("fossil")
pending!("fossil binary not available to syntax-check pikchr")
end
fences = md.scan(/```pikchr[^\n]*\n(.*?)```/m).map(&.[1])
fences.size.should be > 3
fences.each do |fence|
output = IO::Memory.new
File.tempfile("gpcr", ".pikchr") do |tmp|
tmp.print(fence)
tmp.flush
Process.run("fossil", ["pikchr", tmp.path], output: output)
end
output.to_s.should contain("<svg")
end
end
describe "predefined_process and document shapes" do
prims_md = GPCR::Renderer.new(PRIMS_SRC).render
it "renders each primitive under its own heading" do
prims_md.should contain("## run_subroutine")
prims_md.should contain("## open_file")
prims_md.should contain("## read_line")
prims_md.should contain("## close_file")
end
it "emits inner vertical bars for predefined_process shape" do
# predefined_process is the only shape that uses pikchr `line` commands
prims_md.should contain("line from")
end
it "emits a concave-bottom ellipse for document shape" do
# document wave: bg-coloured ellipse at the block's bottom edge;
# distinguishable from reporter (0xFF8C1A) and hat (same fill as block)
prims_md.should contain("ellipse wid")
# The wave ellipse is filled with the background colour (white for
# standalone primitives rendered at the top level)
prims_md.should contain("fill 0xffffff color 0xffffff")
end
it "emits only valid pikchr fences for new shapes" do
unless Process.find_executable("fossil")
pending!("fossil binary not available to syntax-check pikchr")
end
fences = prims_md.scan(/```pikchr[^\n]*\n(.*?)```/m).map(&.[1])
fences.size.should be > 3
fences.each do |fence|
output = IO::Memory.new
File.tempfile("gpcr", ".pikchr") do |tmp|
tmp.print(fence)
tmp.flush
Process.run("fossil", ["pikchr", tmp.path], output: output)
end
output.to_s.should contain("<svg")
end
end
end
describe "ring shape" do
ring_md = GPCR::Renderer.new(RING_SRC).render
it "renders ring as two pill boxes with visible violet text" do
ts = GPCR::Tokens::MICROBLOCKS_COMPACT
lbl = "ring shape"
tw = GPCR::Tokens::FontMetrics.text_width(lbl) * ts.fontscale
bw = tw + ts.h_pad * 2
bw = ts.block_min_w if bw < ts.block_min_w
inner_w = bw * ts.ring_inner_w
inner_h = ts.block_h * ts.ring_inner_h
ring_md.should contain("box wid #{fmt(bw)} ht #{fmt(ts.block_h)} rad #{fmt(ts.pill_rad)}")
ring_md.should contain("box wid #{fmt(inner_w)} ht #{fmt(inner_h)} rad #{fmt(inner_h / 2)}")
ring_md.should contain(%(text "ring shape" color 0x9966FF))
ring_md.should_not contain("ellipse wid")
end
end
describe "case/when rendering (examples/case_dispatch.cr)" do
case_md = GPCR::Renderer.new(CASE_SRC).render
it "renders a case as a match header block with the subject expression" do
# The case subject is join("com", "mand") — rendered as its source form
case_md.should contain(%(text "match " color white))
case_md.should contain(%(text "join('com', 'mand')" color 0x263238))
end
it "renders single-condition literal when arms as slot pills" do
# Literal conditions use slot pills — the same visual language as all
# other slot fills. StringLiteral#value strips the quotes so the slot
# contains the bare value.
case_md.should contain(%(text "when " color white))
case_md.should contain(%(text "hat" color 0x263238))
case_md.should contain(%(text "reporter" color 0x263238))
end
it "renders multi-condition when arms joined by comma" do
case_md.should contain(%(text "command" color 0x263238))
case_md.should contain(%(text ", " color white))
case_md.should contain(%(text "boolean" color 0x263238))
end
it "renders non-literal when conditions as unbracketed source text" do
# Non-literal conditions (ranges, calls, variables) keep their source text
# without brackets — the bracket grammar implies a single substitutable slot
# value, which a range expression is not.
range_md = GPCR::Renderer.new(CASE_SRC).render
range_section = range_md.split("## dispatch_range")[1]
range_section.should contain(%(text "when 1..5"))
range_section.should_not contain(%(text "when [1..5]"))
end
it "renders an else arm" do
case_md.should contain(%(text "else"))
end
it "uses c_min_w for all case elements" do
ts = GPCR::Tokens::MICROBLOCKS_COMPACT
case_section = case_md.split("## dispatch_graph")[1]
# match header uses c_min_w × block_h, wider than a content-driven command block
case_section.should contain("wid #{fmt(ts.c_min_w)} ht #{fmt(ts.block_h)} rad #{fmt(ts.cmd_rad)}")
end
it "emits only valid pikchr fences for case rendering" do
unless Process.find_executable("fossil")
pending!("fossil binary not available to syntax-check pikchr")
end
fences = case_md.scan(/```pikchr[^\n]*\n(.*?)```/m).map(&.[1])
fences.size.should be > 0
fences.each do |fence|
output = IO::Memory.new
File.tempfile("gpcr", ".pikchr") do |tmp|
tmp.print(fence)
tmp.flush
Process.run("fossil", ["pikchr", tmp.path], output: output)
end
output.to_s.should contain("<svg")
end
end
end
describe "@[GPCR::Block] body rendered as block stack" do
block_md = GPCR::Renderer.new(BLOCK_DEF_SRC).render
it "renders a Block def's if-chain body as a C-shape 'if' block" do
section = block_md.split("## pick_color")[1]
section.should contain(%(text "if " color white))
section.should contain(%(text "flag" color 0x263238))
section.should contain(%(text " then" color white))
end
it "Block def section contains both the face and the body stack" do
section = block_md.split("## pick_color")[1]
# Face: the reporter pill box (PILL_RAD=0.17 distinguishes it from command/boolean)
section.should contain("rad 0.170")
# Body stack: C-shape frame for the if
section.should contain(%(text "if " color white))
section.should contain(%(text "flag" color 0x263238))
section.should contain(%(text " then" color white))
end
it "Primitive def body stays fenced — face + fence only, no body stack" do
section = block_md.split("## paint")[1].split("## pick_color")[0]
# No C-shape / if-chain in the primitive pikchr section
section.should_not contain(%(text "if ["))
# Source fence is present, but collapsed by default
section.should contain("<summary>crystal source</summary>")
section.should contain("def paint")
end
it "emits only valid pikchr fences for Block def rendering" do
unless Process.find_executable("fossil")
pending!("fossil binary not available to syntax-check pikchr")
end
fences = block_md.scan(/```pikchr[^\n]*\n(.*?)```/m).map(&.[1])
fences.size.should be > 0
fences.each do |fence|
output = IO::Memory.new
File.tempfile("gpcr", ".pikchr") do |tmp|
tmp.print(fence)
tmp.flush
Process.run("fossil", ["pikchr", tmp.path], output: output)
end
output.to_s.should contain("<svg")
end
end
end
describe "fence escaping for source containing triple backticks" do
backtick_md = GPCR::Renderer.new(BACKTICK_SRC).render
backtick_def_md = GPCR::Renderer.new(BACKTICK_DEF_SRC).render
it "uses 4+ backtick fence when source contains triple backticks" do
# The plain code x = \"```\" has a 3-backtick run, so fence_for returns 4.
backtick_md.should contain("````crystal")
backtick_md.should contain("````\n")
end
it "uses 4+ backtick fence inside collapsed rendered-node source" do
backtick_def_md.should contain("<summary>crystal source</summary>")
backtick_def_md.should contain("````crystal")
backtick_def_md.should contain("````\n\n</details>")
end
it "the crystal fence content is present and the markdown has balanced fences" do
combined = backtick_md + backtick_def_md
combined.should contain("x = \"```\"")
open_fences = 0
current_marker = ""
combined.each_line do |line|
s = line.rstrip
if open_fences == 0
if m = s.match(/^(`{3,})/)
current_marker = m[1]
open_fences += 1
end
elsif s == current_marker
open_fences -= 1
current_marker = ""
end
end
open_fences.should eq(0)
end
it "pik_text strips newlines from multi-line labels" do
# A condition with a newline becomes a label; pik_text must not break pikchr
src = "@[GPCR::Primitive(shape: :command, label: \"emit\")]\ndef emit_nl\nend\n"
md = GPCR::Renderer.new(src).render
# No raw newline should appear inside a pikchr text command
pikchr_sections = md.scan(/```pikchr[^\n]*\n(.*?)```/m).map(&.[1])
pikchr_sections.each do |section|
section.lines.select { |l| l.starts_with?("text ") }.each do |text_line|
# Once we strip the surrounding text "..." delimiters, content must not have newlines
text_line.should_not contain("\\n")
end
end
end
end
describe "fixed notch inset: tab x alignment across a rendered stack" do
# Blocks with different label lengths have different content-driven widths, but
# all tabs/notches must land at the same x = block_left_x + notch_inset.
# This spec renders a stack of three command blocks with varying label lengths
# and asserts that every tab box appears at the same center-x in the pikchr.
align_md = GPCR::Renderer.new(ALIGN_SRC).render
it "all stack connector tabs in the graph section share the same center-x" do
ts = GPCR::Tokens::MICROBLOCKS_COMPACT
ov = GPCR::Tokens::NOTCH_OVERSCAN
# After NOTCH_OVERSCAN: all notch cuts and plugs have height tab_h + NOTCH_OVERSCAN.
notch_h = ts.tab_h + ov
# Extract the pikchr section for the align_test graph
graph_section = align_md.split("## align_test").last.split("```pikchr").last.split("```").first
# Collect center-x values of all tab boxes (wid=tab_w, ht=tab_h+ov): "at (cx, cy)"
tab_cx_values = graph_section.scan(/box wid #{Regex.escape(fmt(ts.tab_w))} ht #{Regex.escape(fmt(notch_h))} rad #{Regex.escape(fmt(ts.tab_rad))} at \(([^,]+),/).map { |m| m[1].strip }
tab_cx_values.size.should be > 4, "expected multiple tab boxes in the stack"
unique_cx = tab_cx_values.uniq
unique_cx.size.should eq(1), "all tab center-x should be identical (notch_inset fixed), got: #{unique_cx.inspect}"
end
end
describe "NOTCH_OVERSCAN" do
ov = GPCR::Tokens::NOTCH_OVERSCAN
it "is 0.01 inches" do
ov.should be_close(0.01, 1e-9)
end
it "notch cut top y exceeds block top y by NOTCH_OVERSCAN (token math)" do
ts = GPCR::Tokens::MICROBLOCKS_COMPACT
th = ts.tab_h
# For a block at y=0: center_y = ov/2 - th/2, height = th + ov
# top = center_y + height/2 = ov/2 - th/2 + th/2 + ov/2 = ov
actual_top = ov / 2.0 - th / 2.0 + (th + ov) / 2.0
actual_top.should be_close(ov, 1e-9)
end
it "emitted command block has notch cut with top y = block_top + NOTCH_OVERSCAN" do
ts = GPCR::Tokens::MICROBLOCKS_COMPACT
th = ts.tab_h
tw = ts.tab_w
ov = GPCR::Tokens::NOTCH_OVERSCAN
GPCR::Tokens.use("microblocks-compact")
# Render a standalone command block; bg = "0xffffff" from render_node
src = <<-GPCR_SRC
@[GPCR::Primitive(shape: :command, label: "notch test")]
def notch_test_prim
end
GPCR_SRC
md = GPCR::Renderer.new(src).render
pikchr = md.scan(/```pikchr[^\n]*\n(.*?)```/m).first[1]
x = 0.0
y = 0.0
expected_h = th + ov
# notch cut center y: y + ov/2 - th/2
expected_cy = y + ov / 2.0 - th / 2.0
# center x: x + ts.notch_inset + tw/2
expected_cx = x + ts.notch_inset + tw / 2.0
# The notch cut is the bg-colored (0xffffff) box at the block top
pikchr.should contain("ht #{fmt(expected_h)} rad #{fmt(ts.tab_rad)} at (#{fmt(expected_cx)}, #{fmt(expected_cy)}) fill 0xffffff")
end
end
describe "gallery catalogue entries: no overlapping bounding boxes" do
gallery_src = File.read(File.join(__DIR__, "..", "src", "gpcr", "gallery.cr"))
faces = GPCR.blocks_of
# Ticket ca825f45f6: the design gallery capstone requires BOTH visual
# token styles to render correctly, not just microblocks-compact — this
# used to only check one set. blockly-zelos has different geometry
# (taller blocks, larger fontscale) and must pass the same no-overlap
# check independently.
{"microblocks-compact", "blockly-zelos"}.each do |token_set|
it "#{token_set}: y-extents of consecutive catalogue entries are disjoint" do
gallery_md = render_source(gallery_src, nil, faces, token_set)
# Extract all pikchr fences; the first 8 are the @[GPCR::Primitive] catalogue entries.
fences = gallery_md.scan(/```pikchr[^\n]*\n(.*?)```/m).map(&.[1])
catalogue_fences = fences.first(8)
catalogue_fences.size.should eq(8), "expected 8 catalogue primitive fences"
# For each fence collect all y coordinates from "with .nw at (x, y)" and "at (x, y)" patterns.
extents = catalogue_fences.map do |fence|
ys = [] of Float64
fence.scan(/\.nw at \([^,]+, ([^)]+)\)/) { |m| ys << m[1].strip.to_f }
fence.scan(/ at \([^,]+, ([^)]+)\)/) { |m| ys << m[1].strip.to_f }
{ymin: ys.min_of { |v| v }, ymax: ys.max_of { |v| v }}
end
# Entry N+1 must sit entirely below entry N: entry N+1's top (max y) must be
# strictly less than entry N's bottom (min y), since y increases upward.
(1...extents.size).each do |i|
extents[i][:ymax].should be < extents[i - 1][:ymin],
"entry #{i} top y=#{extents[i][:ymax]} overlaps entry #{i - 1} bottom y=#{extents[i - 1][:ymin]}"
end
end
end
it "blink stack output is byte-for-byte identical to the regenerated artifact" do
blink_src = File.read(File.join(__DIR__, "..", "examples", "blink.cr"))
blink_pikchr_fences = [] of String
blink_md = render_source(blink_src, nil, faces, "microblocks-compact")
blink_md.scan(/```pikchr[^\n]*\n(.*?)```/m) { |m| blink_pikchr_fences << m[1] }
combined = blink_pikchr_fences.join("\n")
artifact = File.read(File.join(__DIR__, "..", "artifacts", "dual-design", "blink-microblocks-compact.pikchr"))
combined.should eq(artifact), "blink pikchr changed — regenerate artifacts with crystal scripts/gen_artifacts.cr"
end
end
describe "private def unwrapping (VisibilityModifier)" do
describe "parse_source" do
it "classifies private @[GPCR::Primitive] def as :primitive" do
prims = {} of String => GPCR::Prim
nodes = GPCR.parse_source(PRIVATE_PRIM_SRC, prims)
pn = nodes.find { |n| n.kind == :primitive }
pn.should_not be_nil
pn.not_nil!.name.should eq("do_thing")
end
it "registers private @[GPCR::Primitive] def in the prims map" do
prims = {} of String => GPCR::Prim
GPCR.parse_source(PRIVATE_PRIM_SRC, prims)
prims.keys.should contain("do_thing")
end
it "classifies private @[GPCR::Block] def as :block with reified body" do
prims = {} of String => GPCR::Prim
nodes = GPCR.parse_source(PRIVATE_BLOCK_SRC2, prims)
bn = nodes.find { |n| n.kind == :block }
bn.should_not be_nil
bn.not_nil!.name.should eq("run_step")
bn.not_nil!.children.size.should eq(1)
bn.not_nil!.children[0].kind.should eq(:call)
end
end
describe "rendering" do
it "private @[GPCR::Primitive] renders heading + face, not a plain fence" do
md = GPCR::Renderer.new(PRIVATE_PRIM_SRC).render
md.should contain("## do_thing")
# Annotation must not appear inside a plain crystal fence
stripped = md.gsub(/<details>.*?<\/details>/m, "")
stripped.should_not contain("@[GPCR::")
end
it "private @[GPCR::Block] renders heading + face + body stack" do
md = GPCR::Renderer.new(PRIVATE_BLOCK_SRC2).render
md.should contain("## run_step")
# Body stack: step_impl call renders with its prim label "step"
run_section = md.split("## run_step")[1]
run_section.should contain(%(text "step"))
end
end
describe "cli.cr rendering" do
cli_md = GPCR::Renderer.new(File.read(File.join(__DIR__, "..", "src", "gpcr", "cli.cr"))).render
it "renders a heading for each private primitive" do
cli_md.should contain("## cli_fossil_ls")
cli_md.should contain("## cli_fossil_cat")
cli_md.should contain("## cli_cgi_ci")
cli_md.should contain("## cli_render_source")
cli_md.should contain("## cli_review_gallery")
cli_md.should contain("## cli_pus_emit")
cli_md.should contain("## cli_ops_apply")
cli_md.should contain("## cli_ops_index")
cli_md.should contain("## cli_census")
end
it "produces no plain crystal fence containing a GPCR annotation" do
# Remove <details>...</details> sections — those intentionally contain
# annotations in the collapsed crystal source view. Any annotation
# remaining after stripping details is in a plain fence (a classification bug).
stripped = cli_md.gsub(/<details>.*?<\/details>/m, "")
stripped.should_not contain("@[GPCR::")
end
end
end
describe "per-block op-index badges" do
# Render the edit fixture and collect badge numbers from all pikchr fences.
# Badge text lines look like: text "7" at 1.24in -2.34in color 0xf8fafc
it "rendered pikchr contains at least one badge text element" do
rendered = GPCR::Renderer.new(SRC).render
nums = [] of Int32
rendered.scan(/text "(\d+)"[^\n]*color #{GPCR::Tokens::BADGE_TEXT}/) do |m|
nums << m[1].to_i
end
nums.size.should be > 0
end
it "all badge indices are present in NodeIndexer output" do
rendered = GPCR::Renderer.new(SRC).render
badge_nums = [] of Int32
rendered.scan(/text "(\d+)"[^\n]*color #{GPCR::Tokens::BADGE_TEXT}/) do |m|
badge_nums << m[1].to_i
end
located, _ = GPCR::Ops::NodeIndexer.new(SRC).index
indexer_set = located.map(&.index)
badge_nums.each { |b| indexer_set.should contain(b) }
end
it "non-graph-def NodeIndexer indices all appear as badges" do
# NodeIndexer assigns an index to the blink Graph def itself,
# but graph defs have no face block in the renderer — only their
# body is rendered — so that index has no badge.
rendered = GPCR::Renderer.new(SRC).render
badge_nums = [] of Int32
rendered.scan(/text "(\d+)"[^\n]*color #{GPCR::Tokens::BADGE_TEXT}/) do |m|
badge_nums << m[1].to_i
end
badge_set = badge_nums.sort
located, _ = GPCR::Ops::NodeIndexer.new(SRC).index
graph_names = GPCR.parse_source(SRC).select { |n| n.kind == :graph }.map(&.name)
graph_def_idxs = located.select { |n| n.kind == :def && graph_names.includes?(n.name) }.map(&.index)
expected = (located.map(&.index) - graph_def_idxs).sort
badge_set.should eq(expected)
end
end
end