Login
gpcr/spec/ops_spec.cr
Login

[edit]

spec/ops_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/ops

require '../src/gpcr/ops'
scale = 0.75
fontscale = 1.500
box wid 2.117 ht 0.340 with .nw at (0.035, -0.455) fill 0xCBD5E1 color 0xCBD5E1
box wid 2.117 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 (1.977, -0.420) to (1.977, -0.760) color white
text "require '../src/gpcr/ops'" color white at (1.059, -0.590)

crystal source

require "../src/gpcr/ops"

json block

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

../src/gpcr/apply

require '../src/gpcr/apply'
scale = 0.75
fontscale = 1.500
box wid 2.255 ht 0.340 with .nw at (0.035, -0.875) fill 0xCBD5E1 color 0xCBD5E1
box wid 2.255 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.115, -0.840) to (2.115, -1.180) color white
text "require '../src/gpcr/apply'" color white at (1.127, -1.010)

crystal source

require "../src/gpcr/apply"

json block

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

../src/gpcr/speak

require '../src/gpcr/speak'
scale = 0.75
fontscale = 1.500
box wid 2.304 ht 0.340 with .nw at (0.035, -1.295) fill 0xCBD5E1 color 0xCBD5E1
box wid 2.304 ht 0.340 with .nw at (0.000, -1.260) fill 0x7C83E6 color 0x7C83E6
box wid 0.300 ht 0.065 rad 0.018 at (0.370, -1.283) fill 0xffffff color 0xffffff
line from (0.140, -1.260) to (0.140, -1.600) color white
line from (2.164, -1.260) to (2.164, -1.600) color white
text "require '../src/gpcr/speak'" color white at (1.152, -1.430)

crystal source

require "../src/gpcr/speak"

json block

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

BLINK_SRC = File.read(File.join(__DIR__, "fixtures", "edit_fixture.cr"))

# Helper: run crystal tool format on a string and return the result.
def crystal_format_str(src : String) : String
  tmp = File.tempfile("gpcr_spec", ".cr")
  begin
    tmp.print(src)
    tmp.flush
    tmp.close
    Process.run("crystal", ["tool", "format", tmp.path],
      error: Process::Redirect::Close)
    File.read(tmp.path)
  ensure
    tmp.delete
  end
end

BLINK_FORMATTED = crystal_format_str(BLINK_SRC)

describe "GPCR::Ops — op schema" do
  describe "parse_batch" do
    it "parses a single op from a JSON object" do
      ops = GPCR::Ops.parse_batch(%({"op":"delete","target":{"index":1,"gen":0}}))
      ops.size.should eq(1)
      ops[0].should be_a(GPCR::Ops::DeleteOp)
    end

    it "parses an array of ops" do
      json = %([{"op":"undo","count":2},{"op":"undo","count":1}])
      ops = GPCR::Ops.parse_batch(json)
      ops.size.should eq(2)
      ops.all? { |o| o.is_a?(GPCR::Ops::UndoOp) }.should be_true
    end

    it "raises OpError on unknown op" do
      expect_raises(GPCR::Ops::OpError, /unknown op/) do
        GPCR::Ops.parse_batch(%({"op":"teleport"}))
      end
    end

    it "raises OpError on missing op field" do
      expect_raises(GPCR::Ops::OpError, /missing/) do
        GPCR::Ops.parse_batch(%({"target":{"index":1,"gen":0}}))
      end
    end
  end

  describe "parse_target — IndexTarget" do
    it "parses index+gen" do
      t = GPCR::Ops.parse_target(JSON.parse(%({"index":7,"gen":42})))
      t.should be_a(GPCR::Ops::IndexTarget)
      t = t.as(GPCR::Ops::IndexTarget)
      t.index.should eq(7)
      t.gen.should eq(42)
    end
  end

  describe "parse_target — PathTarget" do
    it "parses path+expect" do
      t = GPCR::Ops.parse_target(JSON.parse(%({"path":"blink/wait[1]","expect":"wait 0.5"})))
      t.should be_a(GPCR::Ops::PathTarget)
      t = t.as(GPCR::Ops::PathTarget)
      t.path.should eq("blink/wait[1]")
      t.expect.should eq("wait 0.5")
    end

    it "parses path without expect" do
      t = GPCR::Ops.parse_target(JSON.parse(%({"path":"blink"})))
      t.should be_a(GPCR::Ops::PathTarget)
    end

    it "raises OpError on invalid target" do
      expect_raises(GPCR::Ops::OpError) do
        GPCR::Ops.parse_target(JSON.parse(%({"something":"else"})))
      end
    end
  end

  describe "op_to_json round-trip" do
    it "round-trips a WrapOp" do
      target = GPCR::Ops::IndexTarget.new(3, 0)
      op = GPCR::Ops::WrapOp.new(target, "repeat", {"times" => "10"})
      json = GPCR::Ops.op_to_json(op)
      j = JSON.parse(json)
      j["op"].as_s.should eq("wrap")
      j["block"].as_s.should eq("repeat")
      j["target"]["index"].as_i.should eq(3)
    end

    it "round-trips a DefOp" do
      op = GPCR::Ops::DefOp.new("command", "flash", [{"count", "Int32"}])
      json = GPCR::Ops.op_to_json(op)
      j = JSON.parse(json)
      j["op"].as_s.should eq("def")
      j["name"].as_s.should eq("flash")
      j["params"][0]["name"].as_s.should eq("count")
    end
  end
end

describe "GPCR::Ops::NodeIndexer" do
  it "assigns flat 1-based indices to all rendered blocks in blink.cr" do
    nodes, params = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    nodes.should_not be_empty
    nodes.map(&.index).should eq((1..nodes.size).to_a)
  end

  it "indexes annotated primitive defs first" do
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    defs = nodes.select { |n| n.kind == :def }
    defs.map(&.name).should contain("on_start")
    defs.map(&.name).should contain("blink")
  end

  it "indexes statements inside graph bodies" do
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    calls = nodes.select { |n| n.kind == :call }
    calls.map(&.name).should contain("on_start")
    calls.map(&.name).should contain("forever")
    calls.map(&.name).should contain("set_user_led")
    calls.map(&.name).should contain("wait")
  end

  it "builds a param map for defs" do
    _, params = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    params["wait"].should eq(["secs"])
    params["set_user_led"].should eq(["state"])
    params["on_start"].should eq([] of String)
  end

  it "assigns line_start/line_end within source bounds" do
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    total_lines = BLINK_SRC.lines.size
    nodes.each do |n|
      n.line_start.should be >= 1
      n.line_end.should be >= n.line_start
      n.line_end.should be <= total_lines + 1
    end
  end
end

describe "GPCR::Ops::Buffer — target resolution" do
  it "resolves an IndexTarget with correct gen" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    node = buf.resolve(GPCR::Ops::IndexTarget.new(1, 0))
    node.index.should eq(1)
  end

  it "rejects a stale generation" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    expect_raises(GPCR::Ops::OpError, /stale generation/) do
      buf.resolve(GPCR::Ops::IndexTarget.new(1, 99))
    end
  end

  it "resolves a PathTarget by def name" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    node = buf.resolve(GPCR::Ops::PathTarget.new("blink", nil))
    node.kind.should eq(:def)
    node.name.should eq("blink")
  end

  it "resolves a PathTarget by path+ordinal" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    node = buf.resolve(GPCR::Ops::PathTarget.new("blink/wait[2]", nil))
    node.kind.should eq(:call)
    node.name.should eq("wait")
  end

  it "rejects a PathTarget with wrong expect" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    expect_raises(GPCR::Ops::OpError, /content mismatch/) do
      buf.resolve(GPCR::Ops::PathTarget.new("blink/wait[1]", "wait 999"))
    end
  end

  it "accepts a PathTarget with matching expect" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    node = buf.resolve(GPCR::Ops::PathTarget.new("blink/wait[1]", "wait 0.5"))
    node.name.should eq("wait")
  end
end

describe "GPCR::Ops::Buffer — apply ops + round-trip on blink.cr" do
  it "delete op removes a statement and result parses as Crystal" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    # Find the first wait call
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    wait_node = nodes.find! { |n| n.kind == :call && n.name == "wait" }

    target = GPCR::Ops::IndexTarget.new(wait_node.index, 0)
    new_src = buf.apply_one(GPCR::Ops::DeleteOp.new(target))

    # blink.cr has two `wait 0.5`; deleting one leaves one
    new_src.scan("wait 0.5").size.should eq(1)
    parser = Crystal::Parser.new(new_src)
    parser.parse # should not raise
  end

  it "undo restores source after delete" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    wait_node = nodes.find! { |n| n.kind == :call && n.name == "wait" }

    buf.apply_one(GPCR::Ops::DeleteOp.new(GPCR::Ops::IndexTarget.new(wait_node.index, 0)))
    buf.source.scan("wait 0.5").size.should eq(1)

    buf.apply_one(GPCR::Ops::UndoOp.new(1))
    buf.source.should eq(BLINK_FORMATTED)
  end

  it "wrap op wraps a statement in a do block and result parses" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    # Wrap the first set_user_led call with repeat 3
    led_node = nodes.find! { |n| n.kind == :call && n.name == "set_user_led" }

    target = GPCR::Ops::IndexTarget.new(led_node.index, 0)
    new_src = buf.apply_one(GPCR::Ops::WrapOp.new(target, "repeat", {"times" => "3"}))

    new_src.should contain("3.times")
    new_src.should contain("set_user_led")
    parser = Crystal::Parser.new(new_src)
    parser.parse
  end

  it "undo restores source after wrap" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    led_node = nodes.find! { |n| n.kind == :call && n.name == "set_user_led" }

    buf.apply_one(GPCR::Ops::WrapOp.new(
      GPCR::Ops::IndexTarget.new(led_node.index, 0),
      "repeat",
      {"times" => "3"}
    ))
    buf.apply_one(GPCR::Ops::UndoOp.new(1))
    buf.source.should eq(BLINK_FORMATTED)
  end

  it "set op changes a call argument" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    wait_node = nodes.find! { |n| n.kind == :call && n.name == "wait" }

    target = GPCR::Ops::IndexTarget.new(wait_node.index, 0)
    new_src = buf.apply_one(GPCR::Ops::SetOp.new(target, "secs", "0.2"))

    new_src.should contain("wait 0.2")
    parser = Crystal::Parser.new(new_src)
    parser.parse
  end

  it "def op appends a new annotated def and result parses" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    new_src = buf.apply_one(GPCR::Ops::DefOp.new("command", "flash", [{"count", "Int32"}]))

    new_src.should contain("def flash")
    new_src.should contain("GPCR::Primitive")
    parser = Crystal::Parser.new(new_src)
    parser.parse
  end

  it "gen increments after each applied op" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    buf.gen.should eq(0)
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    wait_node = nodes.find! { |n| n.kind == :call && n.name == "wait" }

    buf.apply_one(GPCR::Ops::DeleteOp.new(GPCR::Ops::IndexTarget.new(wait_node.index, 0)))
    buf.gen.should eq(1)
  end

  it "gen=1 op after a gen=0 op is accepted" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    wait_nodes = nodes.select { |n| n.kind == :call && n.name == "wait" }

    # First op with gen=0
    buf.apply_one(GPCR::Ops::DeleteOp.new(GPCR::Ops::IndexTarget.new(wait_nodes[0].index, 0)))
    # Second op — gen is now 1; get fresh node index
    nodes2, _ = GPCR::Ops::NodeIndexer.new(buf.source).index
    wait2 = nodes2.find { |n| n.kind == :call && n.name == "wait" }
    if w2 = wait2
      buf.apply_one(GPCR::Ops::DeleteOp.new(GPCR::Ops::IndexTarget.new(w2.index, 1)))
      buf.gen.should eq(2)
    end
  end

  it "stale gen op is rejected" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    wait_node = nodes.find! { |n| n.kind == :call && n.name == "wait" }

    buf.apply_one(GPCR::Ops::DeleteOp.new(GPCR::Ops::IndexTarget.new(wait_node.index, 0)))
    # gen is now 1; op with gen=0 should be rejected
    expect_raises(GPCR::Ops::OpError, /stale generation/) do
      buf.apply_one(GPCR::Ops::DeleteOp.new(GPCR::Ops::IndexTarget.new(1, 0)))
    end
  end

  it "apply_json accepts a JSON batch and applies all ops" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    wait_node = nodes.find! { |n| n.kind == :call && n.name == "wait" }
    json = %([{"op":"delete","target":{"index":#{wait_node.index},"gen":0}}])
    result = buf.apply_json(json)
    # blink.cr has two `wait 0.5`; deleting one leaves one
    result.scan("wait 0.5").size.should eq(1)
  end

  it "full round-trip: scripted edit sequence on blink.cr stays valid Crystal" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index

    # Op 1: wrap first set_user_led with 5.times
    led_node = nodes.find! { |n| n.kind == :call && n.name == "set_user_led" }
    buf.apply_one(GPCR::Ops::WrapOp.new(
      GPCR::Ops::IndexTarget.new(led_node.index, 0),
      "repeat",
      {"times" => "5"}
    ))
    Crystal::Parser.new(buf.source).parse

    # Op 2: delete a wait (find in new index with gen=1)
    nodes2, _ = GPCR::Ops::NodeIndexer.new(buf.source).index
    wait2 = nodes2.find { |n| n.kind == :call && n.name == "wait" }
    if w2 = wait2
      buf.apply_one(GPCR::Ops::DeleteOp.new(GPCR::Ops::IndexTarget.new(w2.index, 1)))
    end
    Crystal::Parser.new(buf.source).parse

    # Op 3: undo 2 steps — source should return to formatted baseline
    buf.apply_one(GPCR::Ops::UndoOp.new(2))
    buf.source.should eq(BLINK_FORMATTED)
  end
end

describe "GPCR::Ops::Buffer — op/kind validity" do
  it "wrap op rejects a def target with a kind hint naming 'definition'" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    def_node = nodes.find! { |n| n.kind == :def }
    target = GPCR::Ops::IndexTarget.new(def_node.index, 0)
    expect_raises(GPCR::Ops::OpError, /definition/) do
      buf.apply_one(GPCR::Ops::WrapOp.new(target, "repeat", {"times" => "10"}))
    end
  end

  it "wrap op on the first set_user_led call round-trips and compiles via crystal build --no-codegen" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    # first set_user_led CALL (not the def)
    call_node = nodes.find! { |n| n.kind == :call && n.name == "set_user_led" }
    target = GPCR::Ops::IndexTarget.new(call_node.index, 0)
    new_src = buf.apply_one(GPCR::Ops::WrapOp.new(target, "repeat", {"times" => "3"}))

    new_src.should contain("3.times")
    new_src.should contain("set_user_led")

    # Syntax check via embedded parser
    Crystal::Parser.new(new_src).parse

    # Build check: write alongside the fixture so require "../../src/gpcr" resolves
    checkout_dir = File.expand_path(File.join(__DIR__, ".."))
    tmp_path = File.join(checkout_dir, "spec", "fixtures", "_edit_fixture_wrap_tmp.cr")
    begin
      File.write(tmp_path, new_src)
      result = Process.run(
        "crystal", ["build", "--no-codegen", tmp_path],
        chdir: checkout_dir,
        error: Process::Redirect::Close,
        output: Process::Redirect::Close
      )
      result.success?.should be_true
    ensure
      File.delete(tmp_path) if File.exists?(tmp_path)
    end
  end

  it "set op rejects a def target" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    def_node = nodes.find! { |n| n.kind == :def }
    target = GPCR::Ops::IndexTarget.new(def_node.index, 0)
    expect_raises(GPCR::Ops::OpError, /definition/) do
      buf.apply_one(GPCR::Ops::SetOp.new(target, "secs", "999"))
    end
  end

  it "rename op rejects a call target" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    call_node = nodes.find! { |n| n.kind == :call }
    target = GPCR::Ops::IndexTarget.new(call_node.index, 0)
    expect_raises(GPCR::Ops::OpError, /call|rename/) do
      buf.apply_one(GPCR::Ops::RenameOp.new(target, "new_name"))
    end
  end

  it "rename op renames an annotated def (line_start points to annotation, not def keyword)" do
    buf = GPCR::Ops::Buffer.new(BLINK_SRC)
    nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
    def_node = nodes.find! { |n| n.kind == :def && n.name == "on_start" }
    target = GPCR::Ops::IndexTarget.new(def_node.index, 0)
    result = buf.apply_one(GPCR::Ops::RenameOp.new(target, "when_started"))
    result.should contain("def when_started")
    result.should_not contain("def on_start")
  end
end

describe "GPCR::Speak — spoken grammar parser" do
  describe "wrap" do
    it "parses 'wrap block 1 with repeat 10'" do
      result = GPCR::Speak.parse("wrap block 1 with repeat 10")
      result.should be_a(GPCR::Ops::WrapOp)
      op = result.as(GPCR::Ops::WrapOp)
      op.target.as(GPCR::Ops::IndexTarget).index.should eq(1)
      op.block.should eq("repeat")
      op.slots["times"].should eq("10")
    end

    it "parses 'wrap block 3 with forever'" do
      result = GPCR::Speak.parse("wrap block 3 with forever")
      result.should be_a(GPCR::Ops::WrapOp)
      op = result.as(GPCR::Ops::WrapOp)
      op.target.as(GPCR::Ops::IndexTarget).index.should eq(3)
      op.block.should eq("forever")
      op.slots.should be_empty
    end

    it "parses 'wrap block two with repeat ten' (word numbers)" do
      result = GPCR::Speak.parse("wrap block two with repeat ten")
      result.should be_a(GPCR::Ops::WrapOp)
      op = result.as(GPCR::Ops::WrapOp)
      op.target.as(GPCR::Ops::IndexTarget).index.should eq(2)
      op.slots["times"].should eq("10")
    end

    it "rejects 'wrap block foo with repeat 10' with a hint" do
      result = GPCR::Speak.parse("wrap block foo with repeat 10")
      result.should be_a(GPCR::Speak::ParseError)
      result.as(GPCR::Speak::ParseError).hint.should contain("not a number")
    end

    it "rejects 'wrap block 1' (missing with clause) with a hint" do
      result = GPCR::Speak.parse("wrap block 1")
      result.should be_a(GPCR::Speak::ParseError)
    end
  end

  describe "add" do
    it "parses 'add wait 0.5 after block 2'" do
      result = GPCR::Speak.parse("add wait 0.5 after block 2")
      result.should be_a(GPCR::Ops::AddOp)
      op = result.as(GPCR::Ops::AddOp)
      op.block.should eq("wait")
      op.slots["secs"].should eq("0.5")
      op.position.should eq("after")
      op.target.as(GPCR::Ops::IndexTarget).index.should eq(2)
    end

    it "parses 'add set_user_led true before block 1'" do
      result = GPCR::Speak.parse("add set_user_led true before block 1")
      result.should be_a(GPCR::Ops::AddOp)
      op = result.as(GPCR::Ops::AddOp)
      op.position.should eq("before")
    end
  end

  describe "set" do
    it "parses 'set secs of block 9 to 0.2'" do
      result = GPCR::Speak.parse("set secs of block 9 to 0.2")
      result.should be_a(GPCR::Ops::SetOp)
      op = result.as(GPCR::Ops::SetOp)
      op.slot.should eq("secs")
      op.target.as(GPCR::Ops::IndexTarget).index.should eq(9)
      op.value.should eq("0.2")
    end

    it "rejects 'set ms block 9 to 200' (missing 'of')" do
      result = GPCR::Speak.parse("set ms block 9 to 200")
      result.should be_a(GPCR::Speak::ParseError)
    end
  end

  describe "delete" do
    it "parses 'delete block 4'" do
      result = GPCR::Speak.parse("delete block 4")
      result.should be_a(GPCR::Ops::DeleteOp)
      op = result.as(GPCR::Ops::DeleteOp)
      op.target.as(GPCR::Ops::IndexTarget).index.should eq(4)
    end

    it "parses 'delete block twelve'" do
      result = GPCR::Speak.parse("delete block twelve")
      result.should be_a(GPCR::Ops::DeleteOp)
      op = result.as(GPCR::Ops::DeleteOp)
      op.target.as(GPCR::Ops::IndexTarget).index.should eq(12)
    end
  end

  describe "move" do
    it "parses 'move block 3 before block 5'" do
      result = GPCR::Speak.parse("move block 3 before block 5")
      result.should be_a(GPCR::Ops::MoveOp)
      op = result.as(GPCR::Ops::MoveOp)
      op.source.as(GPCR::Ops::IndexTarget).index.should eq(3)
      op.dest.as(GPCR::Ops::IndexTarget).index.should eq(5)
      op.position.should eq("before")
    end

    it "parses 'move block 2 after block 7'" do
      result = GPCR::Speak.parse("move block 2 after block 7")
      result.should be_a(GPCR::Ops::MoveOp)
      op = result.as(GPCR::Ops::MoveOp)
      op.position.should eq("after")
    end
  end

  describe "rename" do
    it "parses 'rename block 1 to flash'" do
      result = GPCR::Speak.parse("rename block 1 to flash")
      result.should be_a(GPCR::Ops::RenameOp)
      op = result.as(GPCR::Ops::RenameOp)
      op.target.as(GPCR::Ops::IndexTarget).index.should eq(1)
      op.name.should eq("flash")
    end
  end

  describe "define" do
    it "parses 'define command flash with parameter count'" do
      result = GPCR::Speak.parse("define command flash with parameter count")
      result.should be_a(GPCR::Ops::DefOp)
      op = result.as(GPCR::Ops::DefOp)
      op.kind.should eq("command")
      op.name.should eq("flash")
      op.params.should eq([{"count", "Any"}])
    end

    it "parses 'define reporter join with parameter a and b'" do
      result = GPCR::Speak.parse("define reporter join with parameter a and b")
      result.should be_a(GPCR::Ops::DefOp)
      op = result.as(GPCR::Ops::DefOp)
      op.kind.should eq("reporter")
      op.params.size.should eq(2)
      op.params[0][0].should eq("a")
      op.params[1][0].should eq("b")
    end

    it "parses 'define hat when button pressed' (no params)" do
      result = GPCR::Speak.parse("define hat when_button_pressed")
      result.should be_a(GPCR::Ops::DefOp)
      op = result.as(GPCR::Ops::DefOp)
      op.kind.should eq("hat")
      op.name.should eq("when_button_pressed")
      op.params.should be_empty
    end

    it "rejects unknown kind with hint" do
      result = GPCR::Speak.parse("define widget foo")
      result.should be_a(GPCR::Speak::ParseError)
      result.as(GPCR::Speak::ParseError).hint.should contain("unknown kind")
    end
  end

  describe "undo" do
    it "parses 'undo'" do
      result = GPCR::Speak.parse("undo")
      result.should be_a(GPCR::Ops::UndoOp)
      result.as(GPCR::Ops::UndoOp).count.should eq(1)
    end

    it "parses 'undo 3'" do
      result = GPCR::Speak.parse("undo 3")
      result.should be_a(GPCR::Ops::UndoOp)
      result.as(GPCR::Ops::UndoOp).count.should eq(3)
    end

    it "parses 'undo three'" do
      result = GPCR::Speak.parse("undo three")
      result.should be_a(GPCR::Ops::UndoOp)
      result.as(GPCR::Ops::UndoOp).count.should eq(3)
    end
  end

  describe "commit" do
    it "parses 'commit note fixed the blink timing'" do
      result = GPCR::Speak.parse("commit note fixed the blink timing")
      result.should be_a(GPCR::Ops::CommitOp)
      op = result.as(GPCR::Ops::CommitOp)
      op.note.should eq("fixed the blink timing")
    end
  end

  describe "rejection" do
    it "rejects empty utterance" do
      result = GPCR::Speak.parse("")
      result.should be_a(GPCR::Speak::ParseError)
    end

    it "rejects unknown verb" do
      result = GPCR::Speak.parse("teleport block 1")
      result.should be_a(GPCR::Speak::ParseError)
      result.as(GPCR::Speak::ParseError).hint.should contain("Unknown verb")
    end

    it "every rejected parse includes a hint string" do
      bad_inputs = [
        "wrap",
        "wrap block",
        "wrap block 1",
        "add",
        "set ms",
        "delete",
        "move block 1",
        "rename block",
        "commit",
      ]
      bad_inputs.each do |utt|
        result = GPCR::Speak.parse(utt)
        result.should be_a(GPCR::Speak::ParseError), "expected ParseError for #{utt.inspect}"
        result.as(GPCR::Speak::ParseError).hint.should_not be_empty
      end
    end
  end

  describe "set op slot resolution" do
    it "set op with a named slot resolves to the correct parameter index" do
      buf = GPCR::Ops::Buffer.new(BLINK_SRC)
      nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
      wait_node = nodes.find! { |n| n.kind == :call && n.name == "wait" }
      target = GPCR::Ops::IndexTarget.new(wait_node.index, 0)
      new_src = buf.apply_one(GPCR::Ops::SetOp.new(target, "secs", "1.5"))
      new_src.should contain("wait 1.5")
    end

    it "set op with positional arg0 fallback works when no param map" do
      buf = GPCR::Ops::Buffer.new(BLINK_SRC)
      nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
      wait_node = nodes.find! { |n| n.kind == :call && n.name == "wait" }
      target = GPCR::Ops::IndexTarget.new(wait_node.index, 0)
      new_src = buf.apply_one(GPCR::Ops::SetOp.new(target, "arg0", "2.0"))
      new_src.should contain("wait 2.0")
    end

    it "set op with an unknown named slot rejects with a hint listing available slots" do
      buf = GPCR::Ops::Buffer.new(BLINK_SRC)
      nodes, _ = GPCR::Ops::NodeIndexer.new(BLINK_SRC).index
      wait_node = nodes.find! { |n| n.kind == :call && n.name == "wait" }
      target = GPCR::Ops::IndexTarget.new(wait_node.index, 0)
      expect_raises(GPCR::Ops::OpError, /not found|available slots/) do
        buf.apply_one(GPCR::Ops::SetOp.new(target, "nonexistent_slot", "42"))
      end
    end
  end

  describe "speak → JSON → parse round-trip" do
    it "wrap phrase round-trips through JSON" do
      op1 = GPCR::Speak.parse("wrap block 4 with repeat 7").as(GPCR::Ops::WrapOp)
      json = GPCR::Speak.to_json(op1)
      op2 = GPCR::Ops.parse_batch(json)[0].as(GPCR::Ops::WrapOp)
      op2.block.should eq("repeat")
      op2.slots["times"].should eq("7")
      op2.target.as(GPCR::Ops::IndexTarget).index.should eq(4)
    end

    it "delete phrase round-trips through JSON" do
      op1 = GPCR::Speak.parse("delete block 5").as(GPCR::Ops::DeleteOp)
      json = GPCR::Speak.to_json(op1)
      op2 = GPCR::Ops.parse_batch(json)[0].as(GPCR::Ops::DeleteOp)
      op2.target.as(GPCR::Ops::IndexTarget).index.should eq(5)
    end
  end
end