Login
gpcr/spec/edit_cgi_spec.cr
Login

[edit]

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

../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, -1.715) fill 0xCBD5E1 color 0xCBD5E1
box wid 2.343 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 (2.203, -1.680) to (2.203, -2.020) color white
text "require '../src/gpcr/render'" color white at (1.172, -1.850)

crystal source

require "../src/gpcr/render"

json block

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

../src/gpcr/edit

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

crystal source

require "../src/gpcr/edit"

json block

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

private def fences_balanced?(md : String) : Bool
  open = false
  marker = ""
  md.each_line do |line|
    s = line.rstrip
    unless open
      if m = s.match(/^(`{3,})/)
        marker = m[1]
        open = true
      end
    else
      if s == marker
        open = false
        marker = ""
      end
    end
  end
  !open
end

# GPCR edit CGI spec.
#
# Tests the four key requirements from ticket a3d00815e7 at the module level:
#   (1) "wrap block 8 with repeat 3" round-trips: new source parses, renderer
#       shows the repeat C-shape, fences are balanced.
#   (2) A def-target phrase ("wrap block 1 with repeat 3") produces the
#       kind-aware rejection hint, not a silent failure.
#   (3) "undo" after a wrap op restores the previous source.
#   (4) Fences are balanced after the wrap op is applied.
#
# Uses spec/fixtures/edit_fixture.cr (the standard edit corpus):
#   Block 1 = on_start    (:def — Primitive)
#   Block 2 = set_user_led (:def — Primitive)
#   Block 3 = wait        (:def — Primitive)
#   Block 4 = forever     (:def — Primitive)
#   Block 5 = blink       (:def — Graph)
#   Block 6 = on_start    (call inside blink)
#   Block 7 = forever     (c-shape call inside blink)
#   Block 8 = set_user_led true  (first call inside forever body)
#   ...

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

describe "GPCR::Edit — voice-style edit CGI specs" do
  describe "(1) 'wrap block 8 with repeat 3' round-trips" do
    it "Speak.parse produces a WrapOp for the phrase" do
      result = GPCR::Speak.parse("wrap block 8 with repeat 3")
      result.should be_a(GPCR::Ops::WrapOp)
      op = result.as(GPCR::Ops::WrapOp)
      t = op.target.as(GPCR::Ops::IndexTarget)
      t.index.should eq(8)
      op.block.should eq("repeat")
      op.slots["times"]?.should eq("3")
    end

    it "applying the WrapOp produces source with '3.times do'" do
      source = File.read(EDIT_FIXTURE_PATH)
      buf = GPCR::Ops::Buffer.new(source)
      op = GPCR::Speak.parse("wrap block 8 with repeat 3").as(GPCR::Ops::WrapOp)
      buf.apply_one(op)
      buf.source.should contain("3.times do")
    end

    it "new source parses as valid Crystal" do
      source = File.read(EDIT_FIXTURE_PATH)
      buf = GPCR::Ops::Buffer.new(source)
      op = GPCR::Speak.parse("wrap block 8 with repeat 3").as(GPCR::Ops::WrapOp)
      buf.apply_one(op)
      Crystal::Parser.new(buf.source).parse
      true.should be_true
    end

    it "rendered Markdown shows the repeat C-shape" do
      source = File.read(EDIT_FIXTURE_PATH)
      buf = GPCR::Ops::Buffer.new(source)
      op = GPCR::Speak.parse("wrap block 8 with repeat 3").as(GPCR::Ops::WrapOp)
      buf.apply_one(op)
      md = GPCR::Renderer.new(buf.source).render
      md.should contain("3.times")
    end
  end

  describe "(2) def-target phrase shows kind-aware rejection hint" do
    it "wrapping block 1 (a :def kind) raises OpError with 'definition' in the hint" do
      source = File.read(EDIT_FIXTURE_PATH)
      buf = GPCR::Ops::Buffer.new(source)
      op = GPCR::Speak.parse("wrap block 1 with repeat 3").as(GPCR::Ops::WrapOp)
      raised = false
      msg = ""
      begin
        buf.apply_one(op)
      rescue e : GPCR::Ops::OpError
        raised = true
        msg = e.message.to_s
      end
      raised.should be_true
      msg.should contain("definition")
      msg.should contain("wrap")
    end

    it "the hint mentions the block index" do
      source = File.read(EDIT_FIXTURE_PATH)
      buf = GPCR::Ops::Buffer.new(source)
      op = GPCR::Speak.parse("wrap block 1 with repeat 3").as(GPCR::Ops::WrapOp)
      raised = false
      msg = ""
      begin
        buf.apply_one(op)
      rescue e : GPCR::Ops::OpError
        raised = true
        msg = e.message.to_s
      end
      raised.should be_true
      msg.should contain("block 1")
    end
  end

  describe "(3) 'undo' restores the previous source" do
    it "after wrap then undo, source no longer contains '3.times do'" do
      source = File.read(EDIT_FIXTURE_PATH)
      buf = GPCR::Ops::Buffer.new(source)

      wrap_op = GPCR::Speak.parse("wrap block 8 with repeat 3").as(GPCR::Ops::WrapOp)
      buf.apply_one(wrap_op)
      buf.source.should contain("3.times do")

      undo_op = GPCR::Ops::UndoOp.new(1)
      buf.apply_one(undo_op)
      buf.source.should_not contain("3.times do")
    end

    it "after undo the source still parses as valid Crystal" do
      source = File.read(EDIT_FIXTURE_PATH)
      buf = GPCR::Ops::Buffer.new(source)

      wrap_op = GPCR::Speak.parse("wrap block 8 with repeat 3").as(GPCR::Ops::WrapOp)
      buf.apply_one(wrap_op)

      undo_op = GPCR::Ops::UndoOp.new(1)
      buf.apply_one(undo_op)
      Crystal::Parser.new(buf.source).parse
      true.should be_true
    end

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

  describe "(4) fences balanced after wrap op" do
    it "Markdown fences are balanced in the pre-wrap render" do
      source = File.read(EDIT_FIXTURE_PATH)
      md = GPCR::Renderer.new(source).render
      fences_balanced?(md).should be_true
    end

    it "Markdown fences are balanced after 'wrap block 8 with repeat 3'" do
      source = File.read(EDIT_FIXTURE_PATH)
      buf = GPCR::Ops::Buffer.new(source)
      op = GPCR::Speak.parse("wrap block 8 with repeat 3").as(GPCR::Ops::WrapOp)
      buf.apply_one(op)
      md = GPCR::Renderer.new(buf.source).render
      fences_balanced?(md).should be_true
    end

    it "Markdown fences are balanced after undo" do
      source = File.read(EDIT_FIXTURE_PATH)
      buf = GPCR::Ops::Buffer.new(source)
      op = GPCR::Speak.parse("wrap block 8 with repeat 3").as(GPCR::Ops::WrapOp)
      buf.apply_one(op)
      buf.apply_one(GPCR::Ops::UndoOp.new(1))
      md = GPCR::Renderer.new(buf.source).render
      fences_balanced?(md).should be_true
    end
  end

  describe "(WIP) edit_wip_page_markdown" do
    it "contains the WIP notice" do
      page = edit_wip_page_markdown("/ext/gpcr", "examples/blink.cr")
      page.should contain("Work In Progress")
      page.should contain("Edit-rendering is not yet implemented")
    end

    it "links back to the editor with file_path when provided" do
      page = edit_wip_page_markdown("/ext/gpcr", "examples/blink.cr")
      page.should contain("/ext/gpcr/edit?file=examples/blink.cr")
    end

    it "links back to bare editor when no file_path" do
      page = edit_wip_page_markdown("/ext/gpcr", "")
      page.should contain("/ext/gpcr/edit")
      page.should_not contain("?file=")
    end
  end

  describe "(5) seed-path: first GET on empty state seeds from examples/blink.cr" do
    it "edit_current_source returns examples/blink.cr content when UV has no current file" do
      tmp = File.tempname("gpcr_test_seed", ".fossil")
      begin
        Process.run("fossil", ["init", tmp], env: {"USER" => "gpcr-spec"}, error: Process::Redirect::Close)
        expected = GPCR::Edit::SEED_SOURCE
        result = edit_current_source(tmp)
        result.should eq(expected)
      ensure
        File.delete(tmp) if File.exists?(tmp)
      end
    end
  end

  describe "(6) form structure for Enter-to-submit and action URL" do
    it "form action ends with /edit when no file_path" do
      source = File.read(EDIT_FIXTURE_PATH)
      tmp = File.tempname("gpcr_test_form", ".fossil")
      begin
        Process.run("fossil", ["init", tmp], env: {"USER" => "gpcr-spec"}, error: Process::Redirect::Close)
        page = edit_page_markdown(tmp, source, "", "/ext/gpcr")
        page.should match(/<form[^>]+action="[^"]*\/edit"/)
      ensure
        File.delete(tmp) if File.exists?(tmp)
      end
    end

    it "form contains exactly one submit button" do
      source = File.read(EDIT_FIXTURE_PATH)
      tmp = File.tempname("gpcr_test_form2", ".fossil")
      begin
        Process.run("fossil", ["init", tmp], env: {"USER" => "gpcr-spec"}, error: Process::Redirect::Close)
        page = edit_page_markdown(tmp, source, "", "/ext/gpcr")
        buttons = page.scan(/<button[^>]+type="submit"/)
        buttons.size.should eq(1)
      ensure
        File.delete(tmp) if File.exists?(tmp)
      end
    end

    it "phrase input appears inside the form" do
      source = File.read(EDIT_FIXTURE_PATH)
      tmp = File.tempname("gpcr_test_form3", ".fossil")
      begin
        Process.run("fossil", ["init", tmp], env: {"USER" => "gpcr-spec"}, error: Process::Redirect::Close)
        page = edit_page_markdown(tmp, source, "", "/ext/gpcr")
        form_start = page.index("<form")
        form_end = page.index("</form>")
        input_pos = page.index(%(<input type="text" name="phrase"))
        form_start.should_not be_nil
        form_end.should_not be_nil
        input_pos.should_not be_nil
        input_pos.not_nil!.should be > form_start.not_nil!
        input_pos.not_nil!.should be < form_end.not_nil!
      ensure
        File.delete(tmp) if File.exists?(tmp)
      end
    end

    it "edit_page_markdown returns page content, not a 302 header" do
      source = File.read(EDIT_FIXTURE_PATH)
      tmp = File.tempname("gpcr_test_form4", ".fossil")
      begin
        Process.run("fossil", ["init", tmp], env: {"USER" => "gpcr-spec"}, error: Process::Redirect::Close)
        page = edit_page_markdown(tmp, source, "", "/ext/gpcr")
        page.should_not contain("Status: 302")
        page.should contain("# GPCR Voice-Style Edit")
      ensure
        File.delete(tmp) if File.exists?(tmp)
      end
    end

    it "apply success path renders updated source inline (no redirect)" do
      source = File.read(EDIT_FIXTURE_PATH)
      op = GPCR::Speak.parse("wrap block 8 with repeat 3").as(GPCR::Ops::Op)
      buf = GPCR::Ops::Buffer.new(source)
      buf.apply_one(op)
      new_source = buf.source
      tmp = File.tempname("gpcr_test_apply_inline", ".fossil")
      begin
        Process.run("fossil", ["init", tmp], env: {"USER" => "gpcr-spec"}, error: Process::Redirect::Close)
        page = edit_page_markdown(tmp, new_source, "", "/ext/gpcr")
        page.should contain("# GPCR Voice-Style Edit")
        page.should contain("3.times do")
        page.should_not contain("Status: 302")
        page.should_not contain("edit-wip")
      ensure
        File.delete(tmp) if File.exists?(tmp)
      end
    end

    it "form uses GET method" do
      source = File.read(EDIT_FIXTURE_PATH)
      tmp = File.tempname("gpcr_test_form_method", ".fossil")
      begin
        Process.run("fossil", ["init", tmp], env: {"USER" => "gpcr-spec"}, error: Process::Redirect::Close)
        page = edit_page_markdown(tmp, source, "", "/ext/gpcr")
        page.should match(/<form[^>]+method="get"/)
      ensure
        File.delete(tmp) if File.exists?(tmp)
      end
    end

    it "form includes hidden file input when file_path given" do
      source = File.read(EDIT_FIXTURE_PATH)
      tmp = File.tempname("gpcr_test_form_file", ".fossil")
      begin
        Process.run("fossil", ["init", tmp], env: {"USER" => "gpcr-spec"}, error: Process::Redirect::Close)
        page = edit_page_markdown(tmp, source, "", "/ext/gpcr", "examples/blink.cr")
        page.should match(/<input[^>]+type="hidden"[^>]+name="file"/)
        page.should contain(%[value="examples/blink.cr"])
      ensure
        File.delete(tmp) if File.exists?(tmp)
      end
    end

    it "form does not include hidden file input when no file_path" do
      source = File.read(EDIT_FIXTURE_PATH)
      tmp = File.tempname("gpcr_test_form_nofile", ".fossil")
      begin
        Process.run("fossil", ["init", tmp], env: {"USER" => "gpcr-spec"}, error: Process::Redirect::Close)
        page = edit_page_markdown(tmp, source, "", "/ext/gpcr")
        page.should_not match(/<input[^>]+type="hidden"[^>]+name="file"/)
      ensure
        File.delete(tmp) if File.exists?(tmp)
      end
    end
  end

  # Ticket 6912f4a1e9: no-refresh submit via fetch-swap. The plain-form path
  # (no JS) is exactly the form/route tested in section (6) above, unchanged.
  # These specs cover the progressive-enhancement addition: the swap-target
  # markup the JS asset hooks into, and the asset itself.
  describe "(7) fetch-swap progressive enhancement" do
    it "wraps the rendering in a gpcr-edit-render div, opened and closed as a standalone HTML block" do
      source = File.read(EDIT_FIXTURE_PATH)
      tmp = File.tempname("gpcr_test_swap_render", ".fossil")
      begin
        Process.run("fossil", ["init", tmp], env: {"USER" => "gpcr-spec"}, error: Process::Redirect::Close)
        page = edit_page_markdown(tmp, source, "", "/ext/gpcr")
        page.should contain(%(<div id="gpcr-edit-render">\n\n))
        render_start = page.index(%(<div id="gpcr-edit-render">)).not_nil!
        render_end = page.index("</div>", render_start).not_nil!
        form_start = page.index("<form").not_nil!
        render_end.should be < form_start
      ensure
        File.delete(tmp) if File.exists?(tmp)
      end
    end

    it "wraps Last Result and Edit History in a gpcr-edit-result div, after the form" do
      source = File.read(EDIT_FIXTURE_PATH)
      tmp = File.tempname("gpcr_test_swap_result", ".fossil")
      begin
        Process.run("fossil", ["init", tmp], env: {"USER" => "gpcr-spec"}, error: Process::Redirect::Close)
        page = edit_page_markdown(tmp, source, "", "/ext/gpcr")
        page.should contain(%(<div id="gpcr-edit-result">\n\n))
        form_end = page.index("</form>").not_nil!
        result_start = page.index(%(<div id="gpcr-edit-result">)).not_nil!
        result_end = page.index("</div>", result_start).not_nil!
        result_start.should be > form_end
        last_result_pos = page.index("## Last Result").not_nil!
        history_pos = page.index("## Edit History").not_nil!
        last_result_pos.should be > result_start
        history_pos.should be < result_end
      ensure
        File.delete(tmp) if File.exists?(tmp)
      end
    end

    it "form carries a stable id for the JS asset to hook the submit event" do
      source = File.read(EDIT_FIXTURE_PATH)
      tmp = File.tempname("gpcr_test_swap_formid", ".fossil")
      begin
        Process.run("fossil", ["init", tmp], env: {"USER" => "gpcr-spec"}, error: Process::Redirect::Close)
        page = edit_page_markdown(tmp, source, "", "/ext/gpcr")
        page.should match(/<form id="gpcr-edit-form"[^>]+method="get"/)
      ensure
        File.delete(tmp) if File.exists?(tmp)
      end
    end

    it "references the external edit.js asset via src=, never inline script" do
      source = File.read(EDIT_FIXTURE_PATH)
      tmp = File.tempname("gpcr_test_swap_script", ".fossil")
      begin
        Process.run("fossil", ["init", tmp], env: {"USER" => "gpcr-spec"}, error: Process::Redirect::Close)
        page = edit_page_markdown(tmp, source, "", "/ext/gpcr")
        page.should contain(%(<script src="/ext/gpcr/edit.js" defer></script>))
        page.should_not match(/<script(?![^>]*\bsrc=)[^>]*>[^<]/)
        page.should_not match(/\bon[a-z]+="/)
      ensure
        File.delete(tmp) if File.exists?(tmp)
      end
    end

    it "CSP header (script-src 'self') is still sent, satisfied by the same-origin script" do
      source = File.read(EDIT_FIXTURE_PATH)
      tmp = File.tempname("gpcr_test_swap_csp", ".fossil")
      begin
        Process.run("fossil", ["init", tmp], env: {"USER" => "gpcr-spec"}, error: Process::Redirect::Close)
        buf = IO::Memory.new
        env = {
          "PATH"                => "/usr/bin:/bin",
          "GATEWAY_INTERFACE"   => "CGI/1.1",
          "REQUEST_METHOD"      => "GET",
          "PATH_INFO"           => "edit",
          "QUERY_STRING"        => "",
          "SCRIPT_NAME"         => "/ext/gpcr",
          "FOSSIL_REPOSITORY"   => tmp,
          "FOSSIL_CAPABILITIES" => "ohi",
        }
        bin = File.join(Dir.current, "bin", "gpcr")
        if File.exists?(bin)
          Process.run(bin, env: env, clear_env: true, output: buf, error: Process::Redirect::Close)
          body = buf.to_s
          body.should contain("Content-Security-Policy: script-src 'self'")
          body.should contain(%(<script src="/ext/gpcr/edit.js" defer></script>))
        end
      ensure
        File.delete(tmp) if File.exists?(tmp)
      end
    end

    it "GPCR::Edit::CLIENT_JS is non-empty and targets the swap-region ids" do
      GPCR::Edit::CLIENT_JS.should contain("gpcr-edit-render")
      GPCR::Edit::CLIENT_JS.should contain("gpcr-edit-result")
      GPCR::Edit::CLIENT_JS.should contain("gpcr-edit-form")
      GPCR::Edit::CLIENT_JS.should_not contain("<script")
    end

    it "GET /ext/gpcr/edit.js serves the client JS with an application/javascript Content-Type" do
      bin = File.join(Dir.current, "bin", "gpcr")
      if File.exists?(bin)
        buf = IO::Memory.new
        env = {
          "PATH"                => "/usr/bin:/bin",
          "GATEWAY_INTERFACE"   => "CGI/1.1",
          "REQUEST_METHOD"      => "GET",
          "PATH_INFO"           => "edit.js",
          "QUERY_STRING"        => "",
          "SCRIPT_NAME"         => "/ext/gpcr",
          "FOSSIL_REPOSITORY"   => "/nonexistent/gpcr_editjs_test.fossil",
          "FOSSIL_CAPABILITIES" => "oh",
        }
        Process.run(bin, env: env, clear_env: true, output: buf, error: Process::Redirect::Close)
        body = buf.to_s
        body.should start_with("Content-Type: application/javascript\r\n\r\n")
        body.should contain("gpcr-edit-render")
        body.should_not contain("Status: 403")
      end
    end
  end
end