spec/ops_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/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)→ /pikchrshow
crystal source
require "../src/gpcr/ops"
json block
{
"kind": "require",
"name": "../src/gpcr/ops",
"doc": null,
"children": []
}
../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)→ /pikchrshow
crystal source
require "../src/gpcr/apply"
json block
{
"kind": "require",
"name": "../src/gpcr/apply",
"doc": null,
"children": []
}
../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)→ /pikchrshow
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