Add focused smoke tests for the remaining 8 gem classes + adapter-raises edge
Sandi S2 (smoke coverage was 4/12) + Tobi T7 (adapter exception path
untested) consensus actions. 38 new tests across 9 files take total
from 15 -> 53 tests, 50 -> 134 assertions.
Per-class breakdown:
session_test.rb (2 tests)
Contract on initialize parameters (positional record + keyword
callables) and public surface (ensure!/recreate!/abort!/just_created?).
AR fixtures stay in the host suite.
turn_test.rb (4 tests)
Required + optional keyword arg contracts (locks the 9 required
+ 8 optional keys against drift). Public surface = [:call] only.
Result struct exercised as a value object with status predicates
and cost/token delegation.
message_artifacts_test.rb (2 tests)
Contract on initialize parameters; public surface = [:attach_from].
impostor_test.rb (3 tests)
Initialize keyword contract; delegation to ActiveStorage attachment
via a Struct double on #filename.
sandbox_test.rb (5 tests)
Real tmpdir instantiation: #path / #exists? / #files (Enumerator
when block-less, yields SandboxFile values when files present) /
#file lookup-by-basename returns nil for missing.
sandbox_file_test.rb (4 tests)
Real tmpdir + file: basic readers, marcel-backed content_type
detection, #safe? size-cap rejection, #as_artifact identity
conversion returns Opencode::Artifact.
transform_test.rb (8 tests)
Documents the abstract contract (source_filename / destination_filename /
render all raise NotImplementedError). A trivial concrete subclass
inside the test exercises the default implementations of
#applies_to?, #trusted?, #owned_filenames, and #purge_impostors?
that delegate to the two abstract filename methods.
tool_display_test.rb (5 tests)
Known tool canonicalization, status predicates (running/completed/
errored/in_flight/terminal), unknown-tool fallback, nil-part
tolerance (callers sometimes pass non-tool parts_json entries).
uploaded_files_prompt_test.rb (3 tests)
Initialize keyword contract; #text returns raw content + empty
sandbox_file_names map when no files attached; public surface check.
Plus 2 new tests in error_reporter_test.rb (C4):
- test_adapter_exceptions_propagate: adapter that raises must
propagate, not silently swallow — operators need to know the
error tracker is broken.
- test_report_returns_adapter_return_value: report passes through
the adapter's return value verbatim (Rails.error.report returns
the error itself; callers can chain).
Faithful to actual implementations: every test was first written from
the API I expected, then corrected against the class internals when
errors surfaced. The corrections themselves document the contract:
SandboxFile expects a String sandbox_prefix with trailing separator
(not a Pathname), Transform's filename methods are abstract not
nil-defaulting, etc.
This commit is contained in:
@@ -47,4 +47,29 @@ class Opencode::ErrorReporterTest < Minitest::Test
|
|||||||
Opencode::ErrorReporter.report(RuntimeError.new("kaboom"))
|
Opencode::ErrorReporter.report(RuntimeError.new("kaboom"))
|
||||||
assert invoked, "Adapter should be invoked even with no kwargs"
|
assert invoked, "Adapter should be invoked even with no kwargs"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_adapter_exceptions_propagate
|
||||||
|
# If the host's adapter itself raises (Honeybadger HTTP failure,
|
||||||
|
# Sentry quota error, etc.) the gem must propagate — silently
|
||||||
|
# swallowing the adapter's own errors would hide an outage from
|
||||||
|
# operators who think their error tracker is healthy.
|
||||||
|
Opencode::ErrorReporter.adapter = ->(_error, **_opts) {
|
||||||
|
raise StandardError, "adapter blew up"
|
||||||
|
}
|
||||||
|
|
||||||
|
raised = assert_raises(StandardError) do
|
||||||
|
Opencode::ErrorReporter.report(RuntimeError.new("original"))
|
||||||
|
end
|
||||||
|
assert_equal "adapter blew up", raised.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_report_returns_adapter_return_value
|
||||||
|
# Useful for hosts wanting Rails.error.report's standard return
|
||||||
|
# (the error itself). Verifies the call shape doesn't transform it.
|
||||||
|
sentinel = Object.new
|
||||||
|
Opencode::ErrorReporter.adapter = ->(_error, **_opts) { sentinel }
|
||||||
|
|
||||||
|
result = Opencode::ErrorReporter.report(StandardError.new("x"))
|
||||||
|
assert_same sentinel, result
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
26
test/opencode/impostor_test.rb
Normal file
26
test/opencode/impostor_test.rb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
# Contract smoke for Opencode::Impostor. Wraps an ActiveStorage
|
||||||
|
# attachment that's been replaced by a Transform-rendered artifact.
|
||||||
|
# Behavioral tests against real ActiveStorage::Attachment live in
|
||||||
|
# the host application.
|
||||||
|
class Opencode::ImpostorTest < Minitest::Test
|
||||||
|
def test_initialize_takes_attachment_keyword
|
||||||
|
params = Opencode::Impostor.instance_method(:initialize).parameters
|
||||||
|
assert_includes params, [ :keyreq, :attachment ],
|
||||||
|
"Impostor must require an attachment: keyword (ActiveStorage::Attachment-like)"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_public_api
|
||||||
|
assert_equal %i[filename purge!].sort,
|
||||||
|
Opencode::Impostor.instance_methods(false).sort
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_filename_delegates_to_attachment
|
||||||
|
attachment_double = Struct.new(:filename).new("legacy.html")
|
||||||
|
impostor = Opencode::Impostor.new(attachment: attachment_double)
|
||||||
|
assert_equal "legacy.html", impostor.filename
|
||||||
|
end
|
||||||
|
end
|
||||||
26
test/opencode/message_artifacts_test.rb
Normal file
26
test/opencode/message_artifacts_test.rb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
# Contract smoke for Opencode::MessageArtifacts (the idempotent
|
||||||
|
# ActiveStorage-backed artifact attachment pipeline). Behavioral
|
||||||
|
# coverage — including ActiveStorage attachment, Transform application,
|
||||||
|
# error reporting via Opencode::ErrorReporter — lives in the host app.
|
||||||
|
class Opencode::MessageArtifactsTest < Minitest::Test
|
||||||
|
def test_initialize_takes_message_feature_and_optional_transforms
|
||||||
|
params = Opencode::MessageArtifacts.instance_method(:initialize).parameters
|
||||||
|
by_kind = params.group_by(&:first).transform_values { |list| list.map(&:last) }
|
||||||
|
|
||||||
|
assert_includes by_kind[:keyreq], :message,
|
||||||
|
"MessageArtifacts must require a message: keyword"
|
||||||
|
assert_includes by_kind[:keyreq], :feature,
|
||||||
|
"MessageArtifacts must require a feature: keyword (used in error reports)"
|
||||||
|
assert_includes by_kind[:key] || [], :transforms,
|
||||||
|
"MessageArtifacts must accept an optional transforms: keyword"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_public_api_is_attach_from
|
||||||
|
assert_equal [ :attach_from ], Opencode::MessageArtifacts.instance_methods(false),
|
||||||
|
"MessageArtifacts's only public verb is #attach_from"
|
||||||
|
end
|
||||||
|
end
|
||||||
62
test/opencode/sandbox_file_test.rb
Normal file
62
test/opencode/sandbox_file_test.rb
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
require "fileutils"
|
||||||
|
require "tmpdir"
|
||||||
|
require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for
|
||||||
|
|
||||||
|
# Smoke test for Opencode::SandboxFile: instantiate against a real
|
||||||
|
# pathname, verify basename/size/content/content_type readers and the
|
||||||
|
# identity conversion to Opencode::Artifact via #as_artifact.
|
||||||
|
class Opencode::SandboxFileTest < Minitest::Test
|
||||||
|
def setup
|
||||||
|
@tmpdir = Dir.mktmpdir("opencode-rails-sandbox-file-test-")
|
||||||
|
@path = File.join(@tmpdir, "notes.md")
|
||||||
|
File.write(@path, "# hello\nworld\n")
|
||||||
|
# SandboxFile uses `start_with?` against this prefix to detect path
|
||||||
|
# escape; it expects a String with trailing separator so that
|
||||||
|
# /sandbox-1 doesn't false-positive on /sandbox-10/foo.
|
||||||
|
@sandbox_prefix = File.join(@tmpdir, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_basic_readers
|
||||||
|
file = Opencode::SandboxFile.new(
|
||||||
|
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal "notes.md", file.basename
|
||||||
|
assert file.size.positive?
|
||||||
|
assert_equal "# hello\nworld\n", file.content
|
||||||
|
assert file.safe?, "small text file inside sandbox should be safe"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_content_type_detection_via_marcel
|
||||||
|
file = Opencode::SandboxFile.new(
|
||||||
|
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
|
||||||
|
)
|
||||||
|
# Marcel detects .md as text/markdown.
|
||||||
|
assert_match(/markdown|text/, file.content_type)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_safe_rejects_files_over_size_cap
|
||||||
|
file = Opencode::SandboxFile.new(
|
||||||
|
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 5
|
||||||
|
)
|
||||||
|
refute file.safe?, "file larger than max_bytes must be unsafe"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_as_artifact_returns_opencode_artifact_value
|
||||||
|
file = Opencode::SandboxFile.new(
|
||||||
|
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
|
||||||
|
)
|
||||||
|
|
||||||
|
artifact = file.as_artifact
|
||||||
|
assert_instance_of Opencode::Artifact, artifact
|
||||||
|
assert_equal "notes.md", artifact.filename
|
||||||
|
assert_equal "# hello\nworld\n", artifact.content
|
||||||
|
end
|
||||||
|
end
|
||||||
53
test/opencode/sandbox_test.rb
Normal file
53
test/opencode/sandbox_test.rb
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
require "fileutils"
|
||||||
|
require "tmpdir"
|
||||||
|
require "marcel" # SandboxFile (yielded by Sandbox#files) needs marcel
|
||||||
|
|
||||||
|
# Smoke test for Opencode::Sandbox: instantiate against a real tmpdir,
|
||||||
|
# verify #path, #exists?, and that #files / #file return empty / nil
|
||||||
|
# when no sandbox files are present. Behavioral coverage of actual file
|
||||||
|
# enumeration lives in the host application where AIGL + Blackline +
|
||||||
|
# Raven sandbox configurations exercise the path.
|
||||||
|
class Opencode::SandboxTest < Minitest::Test
|
||||||
|
def setup
|
||||||
|
@tmpdir = Dir.mktmpdir("opencode-rails-sandbox-test-")
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_path_and_exists_when_directory_present
|
||||||
|
sandbox = Opencode::Sandbox.new(path: @tmpdir)
|
||||||
|
assert_equal @tmpdir, sandbox.path
|
||||||
|
assert sandbox.exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_exists_false_when_path_missing
|
||||||
|
sandbox = Opencode::Sandbox.new(path: File.join(@tmpdir, "missing"))
|
||||||
|
refute sandbox.exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_files_returns_enumerator_yielding_nothing_when_empty
|
||||||
|
sandbox = Opencode::Sandbox.new(path: @tmpdir)
|
||||||
|
# No block given => Enumerator.
|
||||||
|
assert_kind_of Enumerator, sandbox.files
|
||||||
|
assert_equal [], sandbox.files.to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_files_yields_sandbox_files_for_real_entries
|
||||||
|
File.write(File.join(@tmpdir, "notes.md"), "x")
|
||||||
|
File.write(File.join(@tmpdir, "map.md"), "y")
|
||||||
|
|
||||||
|
sandbox = Opencode::Sandbox.new(path: @tmpdir)
|
||||||
|
basenames = sandbox.files.map(&:basename).sort
|
||||||
|
assert_equal %w[map.md notes.md], basenames
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_file_returns_nil_for_missing_relative_name
|
||||||
|
sandbox = Opencode::Sandbox.new(path: @tmpdir)
|
||||||
|
assert_nil sandbox.file("nope.txt")
|
||||||
|
end
|
||||||
|
end
|
||||||
27
test/opencode/session_test.rb
Normal file
27
test/opencode/session_test.rb
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
# Contract smoke for Opencode::Session. Behavioral coverage (idempotent
|
||||||
|
# ensure!/recreate!/abort! with row-level locking, race-safety,
|
||||||
|
# permissions_for callable handoff) lives in the host application
|
||||||
|
# where AR fixtures + a real ActiveRecord row exist.
|
||||||
|
class Opencode::SessionTest < Minitest::Test
|
||||||
|
def test_initialize_takes_record_and_two_keyword_callables
|
||||||
|
params = Opencode::Session.instance_method(:initialize).parameters
|
||||||
|
|
||||||
|
assert_includes params, [ :req, :record ],
|
||||||
|
"Session must take a positional record (an AR row with #with_lock, #title, etc.)"
|
||||||
|
assert_includes params, [ :keyreq, :permissions_for ],
|
||||||
|
"Session must require a permissions_for: callable (host-injected per-product permissions)"
|
||||||
|
assert_includes params, [ :key, :on_error ],
|
||||||
|
"Session must accept an optional on_error: callable for adapter-style error reporting"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_public_api_is_ensure_recreate_abort_just_created
|
||||||
|
methods = Opencode::Session.instance_methods(false).sort
|
||||||
|
assert_equal %i[abort! ensure! just_created? recreate!].sort, methods.sort,
|
||||||
|
"Session's public surface should be exactly: ensure!/recreate!/abort!/just_created?. " \
|
||||||
|
"Found: #{methods.inspect}"
|
||||||
|
end
|
||||||
|
end
|
||||||
55
test/opencode/tool_display_test.rb
Normal file
55
test/opencode/tool_display_test.rb
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
# Smoke tests for Opencode::ToolDisplay — the view-model that converts
|
||||||
|
# raw tool-part hashes into Turbo-Stream-friendly props. We exercise
|
||||||
|
# the predicate surface for the canonical 'read' tool plus the
|
||||||
|
# unknown-tool fallback. Exhaustive per-tool render tests live in the
|
||||||
|
# host where the renderer templates are exercised.
|
||||||
|
class Opencode::ToolDisplayTest < Minitest::Test
|
||||||
|
def test_known_read_tool_canonicalization
|
||||||
|
display = Opencode::ToolDisplay.new(
|
||||||
|
"type" => "tool", "tool" => "read", "status" => "completed",
|
||||||
|
"input" => { "filePath" => "/sandbox/notes.md" }
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal "read", display.canonical_tool
|
||||||
|
assert display.known?
|
||||||
|
assert display.completed?
|
||||||
|
assert display.terminal?
|
||||||
|
refute display.errored?
|
||||||
|
refute display.in_flight?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_running_status
|
||||||
|
display = Opencode::ToolDisplay.new("type" => "tool", "tool" => "read", "status" => "running")
|
||||||
|
assert display.in_flight?
|
||||||
|
refute display.terminal?
|
||||||
|
refute display.completed?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_errored_status
|
||||||
|
display = Opencode::ToolDisplay.new(
|
||||||
|
"type" => "tool", "tool" => "edit", "status" => "error", "error" => "permission denied"
|
||||||
|
)
|
||||||
|
assert display.errored?
|
||||||
|
assert display.terminal?
|
||||||
|
refute display.completed?
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_unknown_tool_falls_back_gracefully
|
||||||
|
display = Opencode::ToolDisplay.new("type" => "tool", "tool" => "wat", "status" => "completed")
|
||||||
|
refute display.known?,
|
||||||
|
"Unknown tools must not claim to be known — host renderer dispatches a fallback view"
|
||||||
|
refute_nil display.canonical_tool,
|
||||||
|
"Unknown tools still need a canonical_tool so DOM ids stay stable"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_nil_part_initializes_safely
|
||||||
|
# ToolDisplay tolerates a nil part because callers sometimes pass
|
||||||
|
# message.parts_json entries that aren't tool parts.
|
||||||
|
display = Opencode::ToolDisplay.new(nil)
|
||||||
|
refute display.known?
|
||||||
|
end
|
||||||
|
end
|
||||||
69
test/opencode/transform_test.rb
Normal file
69
test/opencode/transform_test.rb
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
# Smoke test for Opencode::Transform — the base class for
|
||||||
|
# content-rewriting transforms. It is intentionally abstract:
|
||||||
|
# #source_filename, #destination_filename, and #render all raise
|
||||||
|
# NotImplementedError. Subclasses (host-side) provide the meat.
|
||||||
|
# These tests document the abstract contract.
|
||||||
|
class Opencode::TransformTest < Minitest::Test
|
||||||
|
def test_source_filename_is_abstract
|
||||||
|
err = assert_raises(NotImplementedError) { Opencode::Transform.new.source_filename }
|
||||||
|
assert_match(/must implement #source_filename/, err.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_destination_filename_is_abstract
|
||||||
|
err = assert_raises(NotImplementedError) { Opencode::Transform.new.destination_filename }
|
||||||
|
assert_match(/must implement #destination_filename/, err.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_render_is_abstract
|
||||||
|
err = assert_raises(NotImplementedError) { Opencode::Transform.new.render(Object.new) }
|
||||||
|
assert_match(/must implement #render/, err.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_purge_impostors_defaults_to_false
|
||||||
|
refute Opencode::Transform.new.purge_impostors?,
|
||||||
|
"Default #purge_impostors? must be false — conservative opt-in by subclasses"
|
||||||
|
end
|
||||||
|
|
||||||
|
# A trivial concrete subclass exercises the defaults that DO exist
|
||||||
|
# (#applies_to?, #trusted?, #owned_filenames all delegate to the
|
||||||
|
# two abstract filename methods).
|
||||||
|
class FakeTransform < Opencode::Transform
|
||||||
|
def source_filename = "agent-output.json"
|
||||||
|
def destination_filename = "rendered.html"
|
||||||
|
end
|
||||||
|
|
||||||
|
Attachment = Struct.new(:filename, keyword_init: true)
|
||||||
|
Basenamed = Struct.new(:basename, keyword_init: true)
|
||||||
|
|
||||||
|
def test_applies_to_matches_source_filename_by_default
|
||||||
|
transform = FakeTransform.new
|
||||||
|
matching = Basenamed.new(basename: "agent-output.json")
|
||||||
|
other = Basenamed.new(basename: "something-else.json")
|
||||||
|
|
||||||
|
assert transform.applies_to?(matching)
|
||||||
|
refute transform.applies_to?(other)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_trusted_matches_destination_filename_by_default
|
||||||
|
transform = FakeTransform.new
|
||||||
|
trusted = Attachment.new(filename: "rendered.html")
|
||||||
|
untrusted = Attachment.new(filename: "agent-output.json")
|
||||||
|
|
||||||
|
assert transform.trusted?(trusted)
|
||||||
|
refute transform.trusted?(untrusted)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_owned_filenames_is_source_and_destination
|
||||||
|
assert_equal %w[agent-output.json rendered.html],
|
||||||
|
FakeTransform.new.owned_filenames
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_error_is_a_subclass_of_standarderror
|
||||||
|
assert_operator Opencode::Transform::Error, :<, StandardError,
|
||||||
|
"Transform::Error must be rescuable by `rescue StandardError`"
|
||||||
|
end
|
||||||
|
end
|
||||||
62
test/opencode/turn_test.rb
Normal file
62
test/opencode/turn_test.rb
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
# Contract smoke for Opencode::Turn (the orchestrator) and its inner
|
||||||
|
# Result value object. Behavioral coverage (the full send -> stream ->
|
||||||
|
# recover -> finalize loop) lives in the host application — Turn needs
|
||||||
|
# an Opencode::Client, an AR Message, a subject record, etc., which are
|
||||||
|
# all integration-level concerns.
|
||||||
|
class Opencode::TurnTest < Minitest::Test
|
||||||
|
REQUIRED_INIT_KEYS = %i[
|
||||||
|
message subject query_text client session_for observer_factory
|
||||||
|
system_context agent_name tracer
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
OPTIONAL_INIT_KEYS = %i[
|
||||||
|
on_finalized on_turn_finished on_activity_tick
|
||||||
|
empty_stream_retry_delay final_exchange_timeout
|
||||||
|
final_exchange_retry_delay error_fallback_content error_feature
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
def test_required_keyword_arguments
|
||||||
|
params = Opencode::Turn.instance_method(:initialize).parameters
|
||||||
|
required = params.select { |kind, _| kind == :keyreq }.map(&:last).sort
|
||||||
|
|
||||||
|
assert_equal REQUIRED_INIT_KEYS.sort, required,
|
||||||
|
"Turn's required keyword args drifted. Expected: #{REQUIRED_INIT_KEYS.sort}, got: #{required}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_optional_keyword_arguments_match_documented_surface
|
||||||
|
params = Opencode::Turn.instance_method(:initialize).parameters
|
||||||
|
optional = params.select { |kind, _| kind == :key }.map(&:last).sort
|
||||||
|
|
||||||
|
assert_equal OPTIONAL_INIT_KEYS.sort, optional,
|
||||||
|
"Turn's optional keyword args drifted. Expected: #{OPTIONAL_INIT_KEYS.sort}, got: #{optional}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_public_surface_is_call_only
|
||||||
|
# Turn is an orchestrator; the only public verb is #call. Everything
|
||||||
|
# else is internal. Locking this prevents helpers from accidentally
|
||||||
|
# bleeding into the public API.
|
||||||
|
assert_equal [ :call ], Opencode::Turn.instance_methods(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_result_is_a_value_object_with_status_predicates
|
||||||
|
fake_message = Struct.new(:cost, :input_tokens, :output_tokens, keyword_init: true).new(
|
||||||
|
cost: 0.012, input_tokens: 100, output_tokens: 50
|
||||||
|
)
|
||||||
|
result = Opencode::Turn::Result.new(
|
||||||
|
status: :completed, message: fake_message, duration_ms: 1234
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.completed?
|
||||||
|
refute result.cancelled?
|
||||||
|
refute result.errored?
|
||||||
|
refute result.failed?
|
||||||
|
assert_equal 1234, result.duration_ms
|
||||||
|
assert_equal 0.012, result.cost
|
||||||
|
assert_equal 100, result.input_tokens
|
||||||
|
assert_equal 50, result.output_tokens
|
||||||
|
end
|
||||||
|
end
|
||||||
55
test/opencode/uploaded_files_prompt_test.rb
Normal file
55
test/opencode/uploaded_files_prompt_test.rb
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
require "fileutils"
|
||||||
|
require "tmpdir"
|
||||||
|
|
||||||
|
# Smoke test for Opencode::UploadedFilesPrompt. The sandbox_path:
|
||||||
|
# inversion (per D14 in the design doc) means we can exercise the
|
||||||
|
# happy path against a tmpdir without needing Rails / AR / ActiveStorage
|
||||||
|
# fixtures. Behavioral tests covering ActiveStorage attached_blob
|
||||||
|
# enumeration live in the host application.
|
||||||
|
class Opencode::UploadedFilesPromptTest < Minitest::Test
|
||||||
|
# Minimal stub for a user message: #content (the raw user text) and
|
||||||
|
# #files (an ActiveStorage-like collection with #attached?). Real
|
||||||
|
# behavior is exercised in the host's test suite.
|
||||||
|
FakeMessage = Struct.new(:content, :files, keyword_init: true)
|
||||||
|
EmptyFiles = Struct.new(:attached) do
|
||||||
|
def attached? = attached
|
||||||
|
end
|
||||||
|
|
||||||
|
def setup
|
||||||
|
@tmpdir = Dir.mktmpdir("opencode-rails-uploaded-prompt-test-")
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_initialize_takes_three_required_keywords
|
||||||
|
params = Opencode::UploadedFilesPrompt.instance_method(:initialize).parameters
|
||||||
|
required = params.select { |kind, _| kind == :keyreq }.map(&:last).sort
|
||||||
|
|
||||||
|
assert_equal %i[sandbox_name_for sandbox_path user_message], required,
|
||||||
|
"UploadedFilesPrompt requires user_message:, sandbox_path:, sandbox_name_for:"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_text_returns_raw_content_when_no_files_attached
|
||||||
|
message = FakeMessage.new(content: "hello world", files: EmptyFiles.new(false))
|
||||||
|
prompt = Opencode::UploadedFilesPrompt.new(
|
||||||
|
user_message: message,
|
||||||
|
sandbox_path: @tmpdir,
|
||||||
|
sandbox_name_for: ->(file) { file.filename.to_s }
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal "hello world", prompt.text,
|
||||||
|
"No attached files => text is the raw user content"
|
||||||
|
assert_equal({}, prompt.sandbox_file_names,
|
||||||
|
"No attached files => sandbox_file_names map stays empty")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_public_surface
|
||||||
|
assert_equal %i[sandbox_file_names text].sort,
|
||||||
|
Opencode::UploadedFilesPrompt.instance_methods(false).sort
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user