Login
gpcr/spec/table_spec.cr
Login

[edit]

spec/table_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": []
}

compiler/crystal/syntax

require 'compiler/crystal/syntax'
scale = 0.75
fontscale = 1.500
box wid 2.796 ht 0.340 with .nw at (0.035, -0.455) fill 0xCBD5E1 color 0xCBD5E1
box wid 2.796 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.656, -0.420) to (2.656, -0.760) color white
text "require 'compiler/crystal/syntax'" color white at (1.398, -0.590)

crystal source

require "compiler/crystal/syntax"

json block

{
  "kind": "require",
  "name": "compiler/crystal/syntax",
  "doc": null,
  "children": []
}

../src/gpcr/table

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

crystal source

require "../src/gpcr/table"

json block

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

../src/gpcr/csv_io

require '../src/gpcr/csv_io'
scale = 0.75
fontscale = 1.500
box wid 2.334 ht 0.340 with .nw at (0.035, -1.295) fill 0xCBD5E1 color 0xCBD5E1
box wid 2.334 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.194, -1.260) to (2.194, -1.600) color white
text "require '../src/gpcr/csv_io'" color white at (1.167, -1.430)

crystal source

require "../src/gpcr/csv_io"

json block

{
  "kind": "require",
  "name": "../src/gpcr/csv_io",
  "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, -1.715) fill 0xCBD5E1 color 0xCBD5E1
box wid 2.117 ht 0.340 with .nw at (0.000, -1.680) fill 0x7C83E6 color 0x7C83E6
box wid 0.300 ht 0.065 rad 0.018 at (0.370, -1.703) fill 0xffffff color 0xffffff
line from (0.140, -1.680) to (0.140, -2.020) color white
line from (1.977, -1.680) to (1.977, -2.020) color white
text "require '../src/gpcr/ops'" color white at (1.059, -1.850)

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, -2.135) fill 0xCBD5E1 color 0xCBD5E1
box wid 2.255 ht 0.340 with .nw at (0.000, -2.100) fill 0x7C83E6 color 0x7C83E6
box wid 0.300 ht 0.065 rad 0.018 at (0.370, -2.123) fill 0xffffff color 0xffffff
line from (0.140, -2.100) to (0.140, -2.440) color white
line from (2.115, -2.100) to (2.115, -2.440) color white
text "require '../src/gpcr/apply'" color white at (1.127, -2.270)

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, -2.555) fill 0xCBD5E1 color 0xCBD5E1
box wid 2.304 ht 0.340 with .nw at (0.000, -2.520) fill 0x7C83E6 color 0x7C83E6
box wid 0.300 ht 0.065 rad 0.018 at (0.370, -2.542) fill 0xffffff color 0xffffff
line from (0.140, -2.520) to (0.140, -2.860) color white
line from (2.164, -2.520) to (2.164, -2.860) color white
text "require '../src/gpcr/speak'" color white at (1.152, -2.690)

crystal source

require "../src/gpcr/speak"

json block

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

TABLE_SRC = <<-CRYSTAL
  struct Package
    property name : String
    property version : String
    property enabled : Bool

    def initialize(@name, @version, @enabled)
    end
  end

  packages = [] of Package
  packages << Package.new("binutils", "2.42", true)
  packages << Package.new("gcc", "13.3.0", true)
  CRYSTAL

def crystal_parse_ok?(src : String) : Bool
  Crystal::Parser.new(src).parse
  true
rescue
  false
end

describe "GPCR::Table — model" do
  describe "struct_name_for" do
    it "singularises a plain plural" do
      GPCR::Table.struct_name_for("packages").should eq("Package")
    end

    it "CamelCases a snake_case plural" do
      GPCR::Table.struct_name_for("build_stages").should eq("BuildStage")
    end

    it "handles a single-word name ending in s" do
      GPCR::Table.struct_name_for("robots").should eq("Robot")
    end

    it "leaves a name that doesn't end in s unchanged" do
      GPCR::Table.struct_name_for("config").should eq("Config")
    end
  end

  describe "parse_type" do
    it "maps text to String" do
      GPCR::Table.parse_type("text").should eq("String")
    end

    it "maps number to Int32" do
      GPCR::Table.parse_type("number").should eq("Int32")
    end

    it "maps flag to Bool" do
      GPCR::Table.parse_type("flag").should eq("Bool")
    end

    it "maps float to Float64" do
      GPCR::Table.parse_type("float").should eq("Float64")
    end

    it "falls back to String for unknown types" do
      GPCR::Table.parse_type("widget").should eq("String")
    end
  end

  describe "emit_def" do
    it "produces parseable Crystal" do
      td = GPCR::Table::TableDef.new(
        "packages",
        [
          GPCR::Table::Column.new("name", "String"),
          GPCR::Table::Column.new("version", "String"),
          GPCR::Table::Column.new("enabled", "Bool"),
        ]
      )
      src = GPCR::Table.emit_def(td)
      crystal_parse_ok?(src).should be_true
    end

    it "uses struct not record so cells are mutable" do
      td = GPCR::Table::TableDef.new(
        "items",
        [GPCR::Table::Column.new("x", "Int32")]
      )
      src = GPCR::Table.emit_def(td)
      src.should contain("struct Item")
      src.should contain("property x : Int32")
    end

    it "declares the typed empty array" do
      td = GPCR::Table::TableDef.new(
        "robots",
        [GPCR::Table::Column.new("name", "String")]
      )
      src = GPCR::Table.emit_def(td)
      src.should contain("robots = [] of Robot")
    end
  end
end

describe "GPCR::Ops — table op schema" do
  describe "AddRowOp" do
    it "parses from JSON" do
      j = %({"op":"add-row","table":"packages","columns":["name","version"],"values":["gcc","13.3"]})
      ops = GPCR::Ops.parse_batch(j)
      ops.size.should eq(1)
      op = ops[0].as(GPCR::Ops::AddRowOp)
      op.table.should eq("packages")
      op.columns.should eq(["name", "version"])
      op.values.should eq(["gcc", "13.3"])
    end

    it "round-trips through op_to_json" do
      op = GPCR::Ops::AddRowOp.new("packages", ["name"], ["binutils"])
      json = GPCR::Ops.op_to_json(op)
      reparsed = GPCR::Ops.parse_batch(json).first.as(GPCR::Ops::AddRowOp)
      reparsed.table.should eq("packages")
      reparsed.columns.should eq(["name"])
      reparsed.values.should eq(["binutils"])
    end
  end

  describe "SetCellOp" do
    it "parses from JSON with IndexTarget" do
      j = %({"op":"set-cell","table":"packages","target":{"index":1,"gen":0},"column":"version","value":"2.43"})
      ops = GPCR::Ops.parse_batch(j)
      op = ops[0].as(GPCR::Ops::SetCellOp)
      op.table.should eq("packages")
      op.column.should eq("version")
      op.value.should eq("2.43")
    end

    it "round-trips through op_to_json" do
      target = GPCR::Ops::IndexTarget.new(2, 0)
      op = GPCR::Ops::SetCellOp.new("packages", target, "enabled", "false")
      json = GPCR::Ops.op_to_json(op)
      reparsed = GPCR::Ops.parse_batch(json).first.as(GPCR::Ops::SetCellOp)
      reparsed.table.should eq("packages")
      reparsed.column.should eq("enabled")
    end
  end

  describe "DeleteRowOp" do
    it "parses from JSON" do
      j = %({"op":"delete-row","table":"packages","target":{"index":1,"gen":0}})
      ops = GPCR::Ops.parse_batch(j)
      op = ops[0].as(GPCR::Ops::DeleteRowOp)
      op.table.should eq("packages")
    end

    it "round-trips" do
      target = GPCR::Ops::IndexTarget.new(3, 0)
      op = GPCR::Ops::DeleteRowOp.new("packages", target)
      json = GPCR::Ops.op_to_json(op)
      reparsed = GPCR::Ops.parse_batch(json).first.as(GPCR::Ops::DeleteRowOp)
      reparsed.table.should eq("packages")
    end
  end
end

describe "GPCR::Speak — table grammar" do
  describe "add row" do
    it "parses add row with column-value pairs" do
      result = GPCR::Speak.parse("add row packages name binutils version 2.42")
      op = result.as(GPCR::Ops::AddRowOp)
      op.table.should eq("packages")
      op.columns.should eq(["name", "version"])
      op.values.should eq(["binutils", "2.42"])
    end

    it "coerces word numbers in values" do
      result = GPCR::Speak.parse("add row items order one")
      op = result.as(GPCR::Ops::AddRowOp)
      op.values.should eq(["1"])
    end

    it "returns ParseError for odd-length pairs" do
      result = GPCR::Speak.parse("add row packages name")
      result.should be_a(GPCR::Speak::ParseError)
    end
  end

  describe "set cell" do
    it "parses set COLUMN of row N in TABLE to VALUE" do
      result = GPCR::Speak.parse("set version of row 1 in packages to 2.43")
      op = result.as(GPCR::Ops::SetCellOp)
      op.table.should eq("packages")
      op.column.should eq("version")
      op.value.should eq("2.43")
      target = op.target.as(GPCR::Ops::IndexTarget)
      target.index.should eq(1)
    end

    it "coerces word numbers in the row index" do
      result = GPCR::Speak.parse("set name of row two in packages to musl")
      op = result.as(GPCR::Ops::SetCellOp)
      op.target.as(GPCR::Ops::IndexTarget).index.should eq(2)
    end
  end

  describe "delete row" do
    it "parses delete row N in TABLE" do
      result = GPCR::Speak.parse("delete row 2 in packages")
      op = result.as(GPCR::Ops::DeleteRowOp)
      op.table.should eq("packages")
      op.target.as(GPCR::Ops::IndexTarget).index.should eq(2)
    end

    it "returns ParseError for missing table" do
      result = GPCR::Speak.parse("delete row 1")
      result.should be_a(GPCR::Speak::ParseError)
    end
  end

  describe "disambiguation" do
    it "still routes 'add wait...' to block grammar" do
      result = GPCR::Speak.parse("add wait 0.5 after block 1")
      result.should be_a(GPCR::Ops::AddOp)
    end

    it "still routes 'delete block N' to block grammar" do
      result = GPCR::Speak.parse("delete block 3")
      result.should be_a(GPCR::Ops::DeleteOp)
    end

    it "still routes 'set ms of block N' to block grammar" do
      result = GPCR::Speak.parse("set ms of block 2 to 500")
      result.should be_a(GPCR::Ops::SetOp)
    end
  end
end

describe "GPCR::Ops — apply engine round-trips" do
  describe "apply_add_row" do
    it "inserts a new row after the last existing row" do
      buf = GPCR::Ops::Buffer.new(TABLE_SRC)
      op = GPCR::Ops::AddRowOp.new("packages", ["name", "version", "enabled"], ["musl", "1.2.5", "false"])
      buf.apply_one(op)
      buf.source.should contain("musl")
      buf.source.should contain("Package.new(\"musl\", \"1.2.5\", false)")
    end

    it "result parses as valid Crystal" do
      buf = GPCR::Ops::Buffer.new(TABLE_SRC)
      op = GPCR::Ops::AddRowOp.new("packages", ["name", "version", "enabled"], ["musl", "1.2.5", "false"])
      buf.apply_one(op)
      crystal_parse_ok?(buf.source).should be_true
    end

    it "inserts numeric values without quotes" do
      src = <<-CR
        struct Stage
          property order : Int32
          property name : String

          def initialize(@order, @name)
          end
        end

        build_stages = [] of Stage
        build_stages << Stage.new(1, "binutils")
        CR
      buf = GPCR::Ops::Buffer.new(src)
      op = GPCR::Ops::AddRowOp.new("build_stages", ["order", "name"], ["2", "gcc"])
      buf.apply_one(op)
      buf.source.should contain("Stage.new(2, \"gcc\")")
    end
  end

  describe "apply_set_cell" do
    it "rewrites the targeted column in a row" do
      buf = GPCR::Ops::Buffer.new(TABLE_SRC)
      target = GPCR::Ops::IndexTarget.new(1, buf.gen)
      op = GPCR::Ops::SetCellOp.new("packages", target, "version", "2.43")
      buf.apply_one(op)
      buf.source.should contain("Package.new(\"binutils\", \"2.43\", true)")
    end

    it "result parses as valid Crystal" do
      buf = GPCR::Ops::Buffer.new(TABLE_SRC)
      target = GPCR::Ops::IndexTarget.new(2, buf.gen)
      op = GPCR::Ops::SetCellOp.new("packages", target, "enabled", "false")
      buf.apply_one(op)
      crystal_parse_ok?(buf.source).should be_true
    end

    it "raises OpError on stale generation" do
      buf = GPCR::Ops::Buffer.new(TABLE_SRC)
      target = GPCR::Ops::IndexTarget.new(1, 99)
      op = GPCR::Ops::SetCellOp.new("packages", target, "version", "2.43")
      expect_raises(GPCR::Ops::OpError, /stale generation/) { buf.apply_one(op) }
    end
  end

  describe "apply_delete_row" do
    it "removes the targeted row" do
      buf = GPCR::Ops::Buffer.new(TABLE_SRC)
      target = GPCR::Ops::IndexTarget.new(1, buf.gen)
      op = GPCR::Ops::DeleteRowOp.new("packages", target)
      buf.apply_one(op)
      buf.source.should_not contain("binutils")
      buf.source.should contain("gcc")
    end

    it "result parses as valid Crystal" do
      buf = GPCR::Ops::Buffer.new(TABLE_SRC)
      target = GPCR::Ops::IndexTarget.new(2, buf.gen)
      op = GPCR::Ops::DeleteRowOp.new("packages", target)
      buf.apply_one(op)
      crystal_parse_ok?(buf.source).should be_true
    end
  end

  describe "undo after table op" do
    it "restores source after add-row + undo" do
      buf = GPCR::Ops::Buffer.new(TABLE_SRC)
      original = buf.source
      op = GPCR::Ops::AddRowOp.new("packages", ["name", "version", "enabled"], ["musl", "1.2.5", "false"])
      buf.apply_one(op)
      buf.apply_one(GPCR::Ops::UndoOp.new)
      buf.source.should eq(original)
    end
  end
end

describe "GPCR::CSVio — round-trip" do
  it "writes and reads back headers and rows" do
    path = File.join(Dir.tempdir, "gpcr_test_#{Process.pid}.csv")
    begin
      headers = ["name", "version", "enabled"]
      rows = [["binutils", "2.42", "true"], ["gcc", "13.3.0", "true"]]
      GPCR::CSVio.write(path, headers, rows)
      h2, r2 = GPCR::CSVio.read(path)
      h2.should eq(headers)
      r2.should eq(rows)
    ensure
      File.delete(path) if File.exists?(path)
    end
  end

  it "returns empty arrays for an empty file" do
    path = File.join(Dir.tempdir, "gpcr_test_empty_#{Process.pid}.csv")
    begin
      File.write(path, "")
      h, r = GPCR::CSVio.read(path)
      h.should be_empty
      r.should be_empty
    ensure
      File.delete(path) if File.exists?(path)
    end
  end
end