From c927f79a8400c5a4fb4e2a96c9de7b62ae8141bc Mon Sep 17 00:00:00 2001 From: Sam Schmidt Date: Fri, 30 Jan 2026 15:42:57 -0500 Subject: [PATCH] Add `run cog` test helper and Ruby cog test --- .claude/commands/write-tests.md | 23 ++++ test/roast/cogs/cmd_test.rb | 62 +++++++++++ test/roast/cogs/ruby_test.rb | 181 ++++++++++++++++++++++++++++++++ test/test_helper.rb | 21 ++++ 4 files changed, 287 insertions(+) create mode 100644 test/roast/cogs/ruby_test.rb diff --git a/.claude/commands/write-tests.md b/.claude/commands/write-tests.md index 6a43b6a8..a1d16682 100644 --- a/.claude/commands/write-tests.md +++ b/.claude/commands/write-tests.md @@ -40,6 +40,29 @@ Accepts either: - **Do not test simple data classes** - Classes that only have `attr_reader`/`attr_accessor` and an initializer don't need tests unless they have complex initialization logic - **Follow existing test conventions** in the project +## Test Helpers + +### `run_cog(cog, config: nil, scope_value: nil, scope_index: 0)` + +Run a cog through the full async execution path for integration testing. Use this when testing cog execution rather than testing individual Input/Output/Config classes in isolation. + +```ruby +test "cog executes and returns expected output" do + cog = MyCog.new(:test_cog, ->(_input, _scope, _index) { "some value" }) + + run_cog(cog) + + assert cog.succeeded? + assert_equal "expected", cog.output.value +end +``` + +Parameters: +- `cog` - The cog instance to run +- `config:` - Optional config (defaults to cog's config class) +- `scope_value:` - Optional executor scope value passed to input proc +- `scope_index:` - Optional executor scope index passed to input proc + ## Test File Structure ```ruby diff --git a/test/roast/cogs/cmd_test.rb b/test/roast/cogs/cmd_test.rb index 985cf651..51fe20d4 100644 --- a/test/roast/cogs/cmd_test.rb +++ b/test/roast/cogs/cmd_test.rb @@ -301,6 +301,68 @@ def setup assert_nil output.integer end end + + class ExecuteTest < ActiveSupport::TestCase + test "run! executes command and captures stdout" do + cog = Cmd.new(:echo_test, ->(_input, _scope, _index) { "echo hello world" }) + + run_cog(cog) + + assert cog.succeeded? + assert_equal "hello world", cog.output.text + end + + test "run! executes command with arguments from array" do + cog = Cmd.new(:echo_args, ->(_input, _scope, _index) { ["echo", "foo", "bar"] }) + + run_cog(cog) + + assert cog.succeeded? + assert_equal "foo bar", cog.output.text + end + + test "run! marks cog as failed when command fails with fail_on_error" do + cog = Cmd.new(:failing_cmd, ->(_input, _scope, _index) { "exit 1" }) + + run_cog(cog) + + assert cog.failed? + end + + test "run! succeeds when command fails with no_fail_on_error" do + cog = Cmd.new(:failing_cmd, ->(_input, _scope, _index) { "exit 42" }) + config = Config.new + config.no_fail_on_error! + + run_cog(cog, config: config) + + assert cog.succeeded? + assert_equal 42, cog.output.status.exitstatus + end + + test "run! captures stderr" do + cog = Cmd.new(:stderr_test, ->(_input, _scope, _index) { "echo error >&2" }) + config = Config.new + config.no_fail_on_error! + + run_cog(cog, config: config) + + assert cog.succeeded? + assert_equal "error\n", cog.output.err + end + + test "run! allows setting command via input block" do + cog = Cmd.new(:input_block, ->(input, _scope, _index) { + input.command = "echo" + input.args = ["configured", "via", "input"] + }) + + run_cog(cog) + + assert cog.succeeded? + assert_equal "configured via input", cog.output.text + end + end end end end diff --git a/test/roast/cogs/ruby_test.rb b/test/roast/cogs/ruby_test.rb new file mode 100644 index 00000000..b8281b67 --- /dev/null +++ b/test/roast/cogs/ruby_test.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require "test_helper" + +module Roast + module Cogs + class Ruby < Cog + class InputTest < ActiveSupport::TestCase + def setup + @input = Input.new + end + + # validate! tests + test "validate! raises error when value is nil and coerce has not run" do + assert_raises(Cog::Input::InvalidInputError) do + @input.validate! + end + end + + test "validate! succeeds when value is set" do + @input.value = "test" + + assert_nothing_raised do + @input.validate! + end + end + + test "validate! succeeds when value is nil but coerce has run" do + @input.coerce(nil) + + assert_nothing_raised do + @input.validate! + end + end + + # coerce tests + test "coerce sets value from input return value" do + @input.coerce("hello world") + + assert_equal "hello world", @input.value + end + + test "coerce accepts any Ruby object" do + object = { key: "value", count: 42 } + @input.coerce(object) + + assert_same object, @input.value + end + end + + class OutputTest < ActiveSupport::TestCase + # Constructor tests + test "initialize sets value" do + output = Output.new("test value") + + assert_equal "test value", output.value + end + + # [] bracket access tests + test "bracket access returns hash value" do + output = Output.new({ name: "Alice", age: 30 }) + + assert_equal "Alice", output[:name] + assert_equal 30, output[:age] + end + + # call tests + test "call invokes value when it is a Proc" do + output = Output.new(->(x) { x * 2 }) + + assert_equal 10, output.call(5) + end + + test "call passes block to Proc value" do + output = Output.new(->(items, &block) { items.map(&block) }) + + assert_equal [2, 4, 6], output.call([1, 2, 3]) { |n| n * 2 } + end + + test "call invokes Proc from hash when given symbol key" do + output = Output.new({ double: ->(_key, x) { x * 2 } }) + + assert_equal 10, output.call(:double, 5) + end + + test "call raises ArgumentError when value is hash and first arg is not symbol" do + output = Output.new({ key: "value" }) + + assert_raises(ArgumentError) do + output.call("not a symbol") + end + end + + test "call raises NoMethodError when hash key is not a Proc" do + output = Output.new({ key: "not a proc" }) + + assert_raises(NoMethodError) do + output.call(:key) + end + end + + # method_missing delegation tests + test "method_missing delegates to value when value responds to method" do + output = Output.new("hello world") + + assert_equal "HELLO WORLD", output.upcase + assert_equal 11, output.length + end + + test "method_missing accesses hash key when value is hash" do + output = Output.new({ name: "Bob", score: 100 }) + + assert_equal "Bob", output.name + assert_equal 100, output.score + end + + test "method_missing calls Proc in hash with arguments" do + output = Output.new({ greet: ->(name) { "Hello, #{name}!" } }) + + assert_equal "Hello, World!", output.greet("World") + end + + test "method_missing raises NoMethodError for unknown method" do + output = Output.new("test") + + assert_raises(NoMethodError) do + output.nonexistent_method + end + end + + # respond_to_missing? tests + test "respond_to? returns true for methods on value" do + output = Output.new("hello") + + assert output.respond_to?(:upcase) + assert output.respond_to?(:length) + end + + test "respond_to? returns true for hash keys" do + output = Output.new({ name: "Alice" }) + + assert output.respond_to?(:name) + end + + test "respond_to? returns false for unknown methods" do + output = Output.new("test") + + refute output.respond_to?(:nonexistent_method) + end + + test "respond_to? returns false for hash key that does not exist" do + output = Output.new({ name: "Alice" }) + + refute output.respond_to?(:missing_key) + end + end + + class ExecuteTest < ActiveSupport::TestCase + test "execute returns Output with input value" do + cog = Ruby.new(:test_cog, ->(_input) {}) + input = Input.new + input.value = { name: "test", count: 42 } + + output = cog.execute(input) + + assert_instance_of Output, output + assert_equal({ name: "test", count: 42 }, output.value) + end + + test "run! executes Ruby code and returns result in output" do + cog = Ruby.new(:compute_cog, ->(_input, _scope, _index) { [1, 2, 3, 4, 5].map { |n| n * 2 }.sum }) + + run_cog(cog) + + assert cog.succeeded? + assert_equal 30, cog.output.value + end + end + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 4317a951..6ea127b9 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -40,6 +40,27 @@ def with_env(key, value) ENV[key] = original end +# Run a cog through the full async execution path for integration testing. +# +# @param cog [Roast::Cog] The cog instance to run +# @param config [Roast::Cog::Config] Optional config (defaults to cog's config class) +# @param scope_value [Object] Optional executor scope value passed to input proc +# @param scope_index [Integer] Optional executor scope index passed to input proc +# @return [Roast::Cog] The cog after execution completes +def run_cog(cog, config: nil, scope_value: nil, scope_index: 0) + config ||= cog.class.config_class.new + + Sync do + barrier = Async::Barrier.new + input_context = Roast::CogInputContext.new + + cog.run!(barrier, config, input_context, scope_value, scope_index) + barrier.wait + end + + cog +end + VCR.configure do |config| config.cassette_library_dir = "test/fixtures/vcr_cassettes" config.hook_into :webmock