Login
gpcr/spec/render_spec.cr
Login

[edit]

spec/render_spec.cr @ trunk

spec

require '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)

crystal source

require "spec"

json block

{
  "kind": "require",
  "name": "spec",
  "doc": null,
  "children": []
}

../src/gpcr/render

require '../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)

crystal source

require "../src/gpcr/render"

json block

{
  "kind": "require",
  "name": "../src/gpcr/render",
  "doc": null,
  "children": []
}

../src/gpcr/gallery

require '../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)

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