anima-core 0.2.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +27 -1
- data/CHANGELOG.md +19 -0
- data/README.md +213 -43
- data/agents/codebase-analyzer.md +88 -0
- data/agents/codebase-pattern-finder.md +83 -0
- data/agents/documentation-researcher.md +59 -0
- data/agents/thoughts-analyzer.md +102 -0
- data/agents/web-search-researcher.md +71 -0
- data/anima-core.gemspec +3 -0
- data/app/channels/session_channel.rb +195 -45
- data/app/decorators/user_message_decorator.rb +16 -5
- data/app/jobs/agent_request_job.rb +55 -2
- data/app/jobs/analytical_brain_job.rb +33 -0
- data/app/jobs/count_event_tokens_job.rb +15 -4
- data/app/models/concerns/event/broadcasting.rb +81 -0
- data/app/models/event.rb +20 -1
- data/app/models/goal.rb +91 -0
- data/app/models/session.rb +366 -21
- data/config/application.rb +2 -0
- data/config/initializers/event_subscribers.rb +0 -1
- data/config/routes.rb +0 -6
- data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
- data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
- data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
- data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
- data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
- data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
- data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
- data/db/migrate/20260315140843_create_goals.rb +16 -0
- data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
- data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
- data/lib/agent_loop.rb +65 -6
- data/lib/agents/definition.rb +116 -0
- data/lib/agents/registry.rb +106 -0
- data/lib/analytical_brain/runner.rb +276 -0
- data/lib/analytical_brain/tools/activate_skill.rb +52 -0
- data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
- data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
- data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
- data/lib/analytical_brain/tools/finish_goal.rb +62 -0
- data/lib/analytical_brain/tools/read_workflow.rb +58 -0
- data/lib/analytical_brain/tools/rename_session.rb +63 -0
- data/lib/analytical_brain/tools/set_goal.rb +60 -0
- data/lib/analytical_brain/tools/update_goal.rb +60 -0
- data/lib/analytical_brain.rb +23 -0
- data/lib/anima/cli/mcp/secrets.rb +76 -0
- data/lib/anima/cli/mcp.rb +197 -0
- data/lib/anima/cli.rb +5 -40
- data/lib/anima/installer.rb +168 -0
- data/lib/anima/settings.rb +226 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +9 -0
- data/lib/credential_store.rb +103 -0
- data/lib/environment_probe.rb +232 -0
- data/lib/events/subscribers/persister.rb +1 -0
- data/lib/events/user_message.rb +17 -0
- data/lib/llm/client.rb +29 -10
- data/lib/mcp/client_manager.rb +86 -0
- data/lib/mcp/config.rb +213 -0
- data/lib/mcp/health_check.rb +77 -0
- data/lib/mcp/secrets.rb +73 -0
- data/lib/mcp/stdio_transport.rb +206 -0
- data/lib/providers/anthropic.rb +11 -20
- data/lib/shell_session.rb +11 -10
- data/lib/skills/definition.rb +97 -0
- data/lib/skills/registry.rb +105 -0
- data/lib/tools/edit.rb +226 -0
- data/lib/tools/mcp_tool.rb +114 -0
- data/lib/tools/read.rb +151 -0
- data/lib/tools/registry.rb +14 -12
- data/lib/tools/request_feature.rb +121 -0
- data/lib/tools/return_result.rb +81 -0
- data/lib/tools/spawn_specialist.rb +109 -0
- data/lib/tools/spawn_subagent.rb +111 -0
- data/lib/tools/subagent_prompts.rb +12 -0
- data/lib/tools/web_get.rb +8 -9
- data/lib/tools/write.rb +86 -0
- data/lib/tui/app.rb +985 -26
- data/lib/tui/cable_client.rb +69 -31
- data/lib/tui/message_store.rb +103 -8
- data/lib/tui/screens/chat.rb +293 -45
- data/lib/workflows/definition.rb +97 -0
- data/lib/workflows/registry.rb +89 -0
- data/skills/activerecord/SKILL.md +255 -0
- data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
- data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
- data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
- data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
- data/skills/activerecord/examples/associations/self_referential.rb +302 -0
- data/skills/activerecord/examples/associations/through_associations.rb +203 -0
- data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
- data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
- data/skills/activerecord/examples/basics/inheritance.rb +377 -0
- data/skills/activerecord/examples/basics/type_casting.rb +317 -0
- data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
- data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
- data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
- data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
- data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
- data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
- data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
- data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
- data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
- data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
- data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
- data/skills/activerecord/examples/querying/optimization.rb +275 -0
- data/skills/activerecord/examples/querying/scopes.rb +260 -0
- data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
- data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
- data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
- data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
- data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
- data/skills/activerecord/references/associations.md +709 -0
- data/skills/activerecord/references/basics.md +622 -0
- data/skills/activerecord/references/callbacks.md +738 -0
- data/skills/activerecord/references/migrations.md +657 -0
- data/skills/activerecord/references/querying.md +655 -0
- data/skills/activerecord/references/validations.md +596 -0
- data/skills/dragonruby/SKILL.md +250 -0
- data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
- data/skills/dragonruby/examples/audio/background_music.rb +29 -0
- data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
- data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
- data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
- data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
- data/skills/dragonruby/examples/core/hello_world.rb +24 -0
- data/skills/dragonruby/examples/core/labels.rb +22 -0
- data/skills/dragonruby/examples/core/sprites.rb +35 -0
- data/skills/dragonruby/examples/core/state_management.rb +29 -0
- data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
- data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
- data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
- data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
- data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
- data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
- data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
- data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
- data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
- data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
- data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
- data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
- data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
- data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
- data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
- data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
- data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
- data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
- data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
- data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
- data/skills/dragonruby/examples/input/controller_input.rb +28 -0
- data/skills/dragonruby/examples/input/directional_input.rb +24 -0
- data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
- data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
- data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
- data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
- data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
- data/skills/dragonruby/examples/rendering/labels.rb +32 -0
- data/skills/dragonruby/examples/rendering/layering.rb +51 -0
- data/skills/dragonruby/examples/rendering/solids.rb +61 -0
- data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
- data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
- data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
- data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
- data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
- data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
- data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
- data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
- data/skills/dragonruby/references/audio.md +396 -0
- data/skills/dragonruby/references/core.md +385 -0
- data/skills/dragonruby/references/distribution.md +434 -0
- data/skills/dragonruby/references/entities.md +516 -0
- data/skills/dragonruby/references/game-logic/persistence.md +386 -0
- data/skills/dragonruby/references/game-logic/state.md +389 -0
- data/skills/dragonruby/references/input.md +414 -0
- data/skills/dragonruby/references/rendering/animation.md +467 -0
- data/skills/dragonruby/references/rendering/primitives.md +403 -0
- data/skills/dragonruby/references/scenes.md +443 -0
- data/skills/draper-decorators/SKILL.md +344 -0
- data/skills/draper-decorators/examples/application_decorator.rb +61 -0
- data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
- data/skills/draper-decorators/examples/model_decorator.rb +152 -0
- data/skills/draper-decorators/references/anti-patterns.md +640 -0
- data/skills/draper-decorators/references/patterns.md +507 -0
- data/skills/draper-decorators/references/testing.md +559 -0
- data/skills/gh-issue.md +182 -0
- data/skills/mcp-server/SKILL.md +177 -0
- data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
- data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
- data/skills/mcp-server/examples/http_client.rb +48 -0
- data/skills/mcp-server/examples/http_server.rb +97 -0
- data/skills/mcp-server/examples/rails_integration.rb +88 -0
- data/skills/mcp-server/examples/stdio_server.rb +108 -0
- data/skills/mcp-server/examples/streaming_client.rb +95 -0
- data/skills/mcp-server/references/gotchas.md +183 -0
- data/skills/mcp-server/references/prompts.md +98 -0
- data/skills/mcp-server/references/resources.md +53 -0
- data/skills/mcp-server/references/server.md +140 -0
- data/skills/mcp-server/references/tools.md +146 -0
- data/skills/mcp-server/references/transport.md +104 -0
- data/skills/ratatui-ruby/SKILL.md +315 -0
- data/skills/ratatui-ruby/references/core-concepts.md +340 -0
- data/skills/ratatui-ruby/references/events.md +387 -0
- data/skills/ratatui-ruby/references/frameworks.md +522 -0
- data/skills/ratatui-ruby/references/layout.md +423 -0
- data/skills/ratatui-ruby/references/styling.md +268 -0
- data/skills/ratatui-ruby/references/testing.md +433 -0
- data/skills/ratatui-ruby/references/widgets.md +532 -0
- data/skills/rspec/SKILL.md +340 -0
- data/skills/rspec/examples/core/basic_structure.rb +69 -0
- data/skills/rspec/examples/core/configuration.rb +126 -0
- data/skills/rspec/examples/core/hooks.rb +126 -0
- data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
- data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
- data/skills/rspec/examples/core/shared_examples.rb +145 -0
- data/skills/rspec/examples/factory_bot/associations.rb +314 -0
- data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
- data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
- data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
- data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
- data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
- data/skills/rspec/examples/factory_bot/traits.rb +293 -0
- data/skills/rspec/examples/factory_bot/transients.rb +229 -0
- data/skills/rspec/examples/matchers/change.rb +115 -0
- data/skills/rspec/examples/matchers/collections.rb +154 -0
- data/skills/rspec/examples/matchers/comparisons.rb +79 -0
- data/skills/rspec/examples/matchers/composing.rb +155 -0
- data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
- data/skills/rspec/examples/matchers/equality.rb +58 -0
- data/skills/rspec/examples/matchers/errors.rb +136 -0
- data/skills/rspec/examples/matchers/output.rb +103 -0
- data/skills/rspec/examples/matchers/predicates.rb +87 -0
- data/skills/rspec/examples/matchers/truthiness.rb +101 -0
- data/skills/rspec/examples/matchers/types.rb +82 -0
- data/skills/rspec/examples/matchers/yield.rb +147 -0
- data/skills/rspec/examples/mocks/any_instance.rb +172 -0
- data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
- data/skills/rspec/examples/mocks/constants.rb +177 -0
- data/skills/rspec/examples/mocks/doubles.rb +139 -0
- data/skills/rspec/examples/mocks/expectations.rb +137 -0
- data/skills/rspec/examples/mocks/message_chains.rb +173 -0
- data/skills/rspec/examples/mocks/ordering.rb +144 -0
- data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
- data/skills/rspec/examples/mocks/responses.rb +223 -0
- data/skills/rspec/examples/mocks/spies.rb +149 -0
- data/skills/rspec/examples/mocks/stubbing.rb +133 -0
- data/skills/rspec/examples/rails/channels.rb +250 -0
- data/skills/rspec/examples/rails/controller_specs.rb +302 -0
- data/skills/rspec/examples/rails/helper_specs.rb +245 -0
- data/skills/rspec/examples/rails/job_specs.rb +256 -0
- data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
- data/skills/rspec/examples/rails/matchers.rb +374 -0
- data/skills/rspec/examples/rails/model_specs.rb +193 -0
- data/skills/rspec/examples/rails/request_specs.rb +275 -0
- data/skills/rspec/examples/rails/routing_specs.rb +276 -0
- data/skills/rspec/examples/rails/system_specs.rb +294 -0
- data/skills/rspec/examples/rails/transactions.rb +254 -0
- data/skills/rspec/examples/rails/view_specs.rb +252 -0
- data/skills/rspec/references/core.md +816 -0
- data/skills/rspec/references/factory_bot.md +641 -0
- data/skills/rspec/references/matchers.md +516 -0
- data/skills/rspec/references/mocks.md +381 -0
- data/skills/rspec/references/rails.md +528 -0
- data/templates/soul.md +40 -0
- data/workflows/commit.md +45 -0
- data/workflows/create_handoff.md +98 -0
- data/workflows/create_note.md +82 -0
- data/workflows/create_plan.md +457 -0
- data/workflows/decompose_ticket.md +109 -0
- data/workflows/feature.md +91 -0
- data/workflows/implement_plan.md +87 -0
- data/workflows/iterate_plan.md +247 -0
- data/workflows/research_codebase.md +210 -0
- data/workflows/resume_handoff.md +217 -0
- data/workflows/review_pr.md +320 -0
- data/workflows/thoughts_init.md +71 -0
- data/workflows/validate_plan.md +166 -0
- metadata +290 -3
- data/app/controllers/api/sessions_controller.rb +0 -25
- data/lib/events/subscribers/action_cable_bridge.rb +0 -59
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# RSpec Mocks: Message Expectations (expect) Examples
|
|
2
|
+
# Source: rspec-mocks gem features/basics/expecting_messages.feature
|
|
3
|
+
|
|
4
|
+
# expect(...).to receive - message must be called
|
|
5
|
+
RSpec.describe "message expectations" do
|
|
6
|
+
describe "basic expectations" do
|
|
7
|
+
it "fails if message is not received" do
|
|
8
|
+
dbl = double("collaborator")
|
|
9
|
+
expect(dbl).to receive(:foo)
|
|
10
|
+
|
|
11
|
+
# Without calling dbl.foo, this would fail with:
|
|
12
|
+
# "(Double 'collaborator').foo(*(any args)) expected: 1 time..."
|
|
13
|
+
dbl.foo
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it "passes when message is received" do
|
|
17
|
+
dbl = double("collaborator")
|
|
18
|
+
expect(dbl).to receive(:foo)
|
|
19
|
+
dbl.foo
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe "negative expectations" do
|
|
24
|
+
it "fails if forbidden message is received" do
|
|
25
|
+
dbl = double("collaborator")
|
|
26
|
+
expect(dbl).not_to receive(:foo)
|
|
27
|
+
|
|
28
|
+
# Calling dbl.foo here would fail immediately
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe "custom failure messages" do
|
|
33
|
+
it "provides context when expectation fails" do
|
|
34
|
+
dbl = double("collaborator")
|
|
35
|
+
expect(dbl).to receive(:foo), "dbl should call :foo during authentication"
|
|
36
|
+
dbl.foo
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe "expect vs allow" do
|
|
41
|
+
it "allow permits but doesn't require calls" do
|
|
42
|
+
dbl = double("collaborator")
|
|
43
|
+
allow(dbl).to receive(:foo)
|
|
44
|
+
# Not calling foo is fine
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "expect requires calls" do
|
|
48
|
+
dbl = double("collaborator")
|
|
49
|
+
expect(dbl).to receive(:foo)
|
|
50
|
+
dbl.foo # Must be called
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Practical example: verifying collaborator interactions
|
|
56
|
+
RSpec.describe Account do
|
|
57
|
+
subject(:account) { build(:account, logger:, balance: 1000) }
|
|
58
|
+
|
|
59
|
+
let(:logger) { instance_double("Logger") }
|
|
60
|
+
|
|
61
|
+
describe "#close" do
|
|
62
|
+
it "logs the closure event" do
|
|
63
|
+
expect(logger).to receive(:info).with("Account closed")
|
|
64
|
+
account.close
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "logs the final balance" do
|
|
68
|
+
expect(logger).to receive(:info).with(/Balance: \d+/)
|
|
69
|
+
account.close
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe "#withdraw" do
|
|
74
|
+
context "with sufficient funds" do
|
|
75
|
+
it "does not log warnings" do
|
|
76
|
+
allow(logger).to receive(:info)
|
|
77
|
+
expect(logger).not_to receive(:warn)
|
|
78
|
+
|
|
79
|
+
account.withdraw(100)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
context "with insufficient funds" do
|
|
84
|
+
it "logs a warning" do
|
|
85
|
+
allow(logger).to receive(:info)
|
|
86
|
+
expect(logger).to receive(:warn).with(/insufficient funds/i)
|
|
87
|
+
|
|
88
|
+
account.withdraw(10_000)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Practical example: event publishing
|
|
95
|
+
RSpec.describe OrderService do
|
|
96
|
+
subject(:service) { build(:order_service, publisher:) }
|
|
97
|
+
|
|
98
|
+
let(:publisher) { instance_double("EventPublisher") }
|
|
99
|
+
let(:order) { build(:order) }
|
|
100
|
+
|
|
101
|
+
describe "#complete" do
|
|
102
|
+
it "publishes order completed event" do
|
|
103
|
+
expect(publisher).to receive(:publish).with(
|
|
104
|
+
"order.completed",
|
|
105
|
+
hash_including(order_id: order.id)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
service.complete(order)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "publishes exactly one event" do
|
|
112
|
+
expect(publisher).to receive(:publish).once
|
|
113
|
+
service.complete(order)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Combining expect and allow on same double
|
|
119
|
+
RSpec.describe "mixed expectations and stubs" do
|
|
120
|
+
subject(:processor) { build(:payment_processor, gateway:, logger:) }
|
|
121
|
+
|
|
122
|
+
let(:gateway) { instance_double("PaymentGateway") }
|
|
123
|
+
let(:logger) { instance_double("Logger") }
|
|
124
|
+
|
|
125
|
+
describe "#process" do
|
|
126
|
+
it "charges gateway and logs result" do
|
|
127
|
+
# Stub the gateway response
|
|
128
|
+
allow(gateway).to receive(:charge).and_return(success: true)
|
|
129
|
+
|
|
130
|
+
# Expect logging to happen
|
|
131
|
+
expect(logger).to receive(:info).with(/Payment processed/)
|
|
132
|
+
|
|
133
|
+
processor.process(amount: 100)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# RSpec Mocks: Message Chains Examples
|
|
2
|
+
# Source: rspec-mocks gem features/working_with_legacy_code/message_chains.feature
|
|
3
|
+
|
|
4
|
+
# WARNING: receive_message_chain violates Law of Demeter.
|
|
5
|
+
# It often indicates:
|
|
6
|
+
# - Code knows too much about collaborator structure
|
|
7
|
+
# - Need for facade or wrapper
|
|
8
|
+
# - Missing encapsulation
|
|
9
|
+
#
|
|
10
|
+
# Use only for legacy code. Prefer proper encapsulation.
|
|
11
|
+
|
|
12
|
+
# receive_message_chain - stub chained method calls
|
|
13
|
+
RSpec.describe "receive_message_chain" do
|
|
14
|
+
describe "syntax forms" do
|
|
15
|
+
it "accepts dot-separated string" do
|
|
16
|
+
dbl = double("collaborator")
|
|
17
|
+
allow(dbl).to receive_message_chain("foo.bar.baz") { :result }
|
|
18
|
+
|
|
19
|
+
expect(dbl.foo.bar.baz).to eq(:result)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "accepts symbols with hash for final return" do
|
|
23
|
+
dbl = double("collaborator")
|
|
24
|
+
allow(dbl).to receive_message_chain(:foo, :bar, baz: :result)
|
|
25
|
+
|
|
26
|
+
expect(dbl.foo.bar.baz).to eq(:result)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "accepts symbols with block" do
|
|
30
|
+
dbl = double("collaborator")
|
|
31
|
+
allow(dbl).to receive_message_chain(:foo, :bar, :baz) { :result }
|
|
32
|
+
|
|
33
|
+
expect(dbl.foo.bar.baz).to eq(:result)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
describe "intermediate objects" do
|
|
38
|
+
it "creates intermediates automatically" do
|
|
39
|
+
dbl = double("collaborator")
|
|
40
|
+
allow(dbl).to receive_message_chain(:a, :b, :c) { "end" }
|
|
41
|
+
|
|
42
|
+
# Each call returns a new double
|
|
43
|
+
intermediate = dbl.a.b
|
|
44
|
+
expect(intermediate.c).to eq("end")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe "with any_instance_of" do
|
|
49
|
+
it "stubs chains on any instance" do
|
|
50
|
+
allow_any_instance_of(User).to receive_message_chain("account.balance") { 100 }
|
|
51
|
+
|
|
52
|
+
user = User.new
|
|
53
|
+
expect(user.account.balance).to eq(100)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# ActiveRecord example - common legacy pattern
|
|
59
|
+
RSpec.describe "ActiveRecord chaining" do
|
|
60
|
+
# Legacy code might do: Article.recent.published.limit(5)
|
|
61
|
+
describe "stubbing scope chains" do
|
|
62
|
+
it "stubs the entire chain" do
|
|
63
|
+
articles = build_list(:article, 3)
|
|
64
|
+
allow(Article).to receive_message_chain("recent.published.limit") { articles }
|
|
65
|
+
|
|
66
|
+
expect(Article.recent.published.limit(5)).to eq(articles)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Better alternatives
|
|
72
|
+
RSpec.describe "alternatives to message chains" do
|
|
73
|
+
# PROBLEM: Code uses deep chains
|
|
74
|
+
class LegacyReportGenerator
|
|
75
|
+
def generate
|
|
76
|
+
User.active.verified.with_orders.map(&:email)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# BAD: Stubbing chain
|
|
81
|
+
describe LegacyReportGenerator do
|
|
82
|
+
subject(:generator) { LegacyReportGenerator.new }
|
|
83
|
+
|
|
84
|
+
it "generates report with message chain" do
|
|
85
|
+
users = build_list(:user, 2, email: "test@example.com")
|
|
86
|
+
allow(User).to receive_message_chain("active.verified.with_orders") { users }
|
|
87
|
+
|
|
88
|
+
expect(generator.generate).to eq(["test@example.com", "test@example.com"])
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# BETTER: Extract scope into model method
|
|
93
|
+
class User < ApplicationRecord
|
|
94
|
+
def self.eligible_for_report
|
|
95
|
+
active.verified.with_orders
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class BetterReportGenerator
|
|
100
|
+
def initialize(user_scope: User)
|
|
101
|
+
@user_scope = user_scope
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def generate
|
|
105
|
+
@user_scope.eligible_for_report.map(&:email)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
describe BetterReportGenerator do
|
|
110
|
+
subject(:generator) { BetterReportGenerator.new(user_scope:) }
|
|
111
|
+
|
|
112
|
+
let(:user_scope) { class_double("User") }
|
|
113
|
+
|
|
114
|
+
it "generates report with single stub" do
|
|
115
|
+
users = build_list(:user, 2, email: "test@example.com")
|
|
116
|
+
allow(user_scope).to receive(:eligible_for_report).and_return(users)
|
|
117
|
+
|
|
118
|
+
expect(generator.generate).to eq(["test@example.com", "test@example.com"])
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# BEST: Inject the query result directly
|
|
123
|
+
class CleanReportGenerator
|
|
124
|
+
def initialize(user_repository:)
|
|
125
|
+
@user_repository = user_repository
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def generate
|
|
129
|
+
@user_repository.eligible_users.map(&:email)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
describe CleanReportGenerator do
|
|
134
|
+
subject(:generator) { CleanReportGenerator.new(user_repository:) }
|
|
135
|
+
|
|
136
|
+
let(:user_repository) { instance_double("UserRepository") }
|
|
137
|
+
|
|
138
|
+
it "generates report with injected dependency" do
|
|
139
|
+
users = build_list(:user, 2)
|
|
140
|
+
allow(users).to receive(:map).and_return(["a@test.com", "b@test.com"])
|
|
141
|
+
allow(user_repository).to receive(:eligible_users).and_return(users)
|
|
142
|
+
|
|
143
|
+
result = generator.generate
|
|
144
|
+
expect(result).to eq(["a@test.com", "b@test.com"])
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# When message chains might be acceptable
|
|
150
|
+
RSpec.describe "acceptable chain usage" do
|
|
151
|
+
describe "null object chains" do
|
|
152
|
+
it "stubs deep configuration access" do
|
|
153
|
+
# Configuration objects often have deep chains
|
|
154
|
+
config = double("config").as_null_object
|
|
155
|
+
allow(config).to receive_message_chain("database.connection.pool_size") { 5 }
|
|
156
|
+
|
|
157
|
+
expect(config.database.connection.pool_size).to eq(5)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
describe "test setup helpers" do
|
|
162
|
+
# In test setup, brevity might outweigh purity
|
|
163
|
+
it "quickly stubs Rails request chain" do
|
|
164
|
+
controller = double("controller")
|
|
165
|
+
allow(controller).to receive_message_chain("request.headers.[]")
|
|
166
|
+
.with("Authorization")
|
|
167
|
+
.and_return("Bearer token123")
|
|
168
|
+
|
|
169
|
+
expect(controller.request.headers["Authorization"]).to eq("Bearer token123")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# RSpec Mocks: Message Order Examples
|
|
2
|
+
# Source: rspec-mocks gem features/setting_constraints/message_order.feature
|
|
3
|
+
|
|
4
|
+
# NOTE: Ordered expectations can make specs brittle.
|
|
5
|
+
# Use only when message order is truly important.
|
|
6
|
+
|
|
7
|
+
# .ordered - enforce message sequence
|
|
8
|
+
RSpec.describe "message ordering" do
|
|
9
|
+
describe "basic ordering" do
|
|
10
|
+
it "requires messages in declared order" do
|
|
11
|
+
dbl = double("collaborator")
|
|
12
|
+
|
|
13
|
+
expect(dbl).to receive(:step_1).ordered
|
|
14
|
+
expect(dbl).to receive(:step_2).ordered
|
|
15
|
+
expect(dbl).to receive(:step_3).ordered
|
|
16
|
+
|
|
17
|
+
dbl.step_1
|
|
18
|
+
dbl.step_2
|
|
19
|
+
dbl.step_3
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe "across multiple doubles" do
|
|
24
|
+
it "enforces order across collaborators" do
|
|
25
|
+
collaborator_1 = double("first")
|
|
26
|
+
collaborator_2 = double("second")
|
|
27
|
+
|
|
28
|
+
expect(collaborator_1).to receive(:step_1).ordered
|
|
29
|
+
expect(collaborator_2).to receive(:step_2).ordered
|
|
30
|
+
expect(collaborator_1).to receive(:step_3).ordered
|
|
31
|
+
|
|
32
|
+
collaborator_1.step_1
|
|
33
|
+
collaborator_2.step_2
|
|
34
|
+
collaborator_1.step_3
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
describe "with have_received" do
|
|
39
|
+
it "verifies order using spies" do
|
|
40
|
+
invitation = spy("invitation")
|
|
41
|
+
|
|
42
|
+
invitation.prepare
|
|
43
|
+
invitation.send_email
|
|
44
|
+
invitation.log_delivery
|
|
45
|
+
|
|
46
|
+
expect(invitation).to have_received(:prepare).ordered
|
|
47
|
+
expect(invitation).to have_received(:send_email).ordered
|
|
48
|
+
expect(invitation).to have_received(:log_delivery).ordered
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
describe "partial ordering" do
|
|
53
|
+
it "only ordered expectations must be in sequence" do
|
|
54
|
+
dbl = double("collaborator")
|
|
55
|
+
|
|
56
|
+
allow(dbl).to receive(:any_time) # Not ordered
|
|
57
|
+
expect(dbl).to receive(:first).ordered
|
|
58
|
+
expect(dbl).to receive(:second).ordered
|
|
59
|
+
|
|
60
|
+
dbl.any_time # Can be called anytime
|
|
61
|
+
dbl.first
|
|
62
|
+
dbl.any_time # Still fine
|
|
63
|
+
dbl.second
|
|
64
|
+
dbl.any_time # Still fine
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Practical example: transaction workflow
|
|
70
|
+
RSpec.describe PaymentProcessor do
|
|
71
|
+
subject(:processor) { build(:payment_processor, gateway:, ledger:) }
|
|
72
|
+
|
|
73
|
+
let(:gateway) { instance_double("PaymentGateway") }
|
|
74
|
+
let(:ledger) { instance_double("Ledger") }
|
|
75
|
+
let(:payment) { build(:payment, amount: 100) }
|
|
76
|
+
|
|
77
|
+
describe "#process" do
|
|
78
|
+
context "when order matters for consistency" do
|
|
79
|
+
it "validates before charging" do
|
|
80
|
+
# Must validate first, then charge
|
|
81
|
+
expect(gateway).to receive(:validate).ordered.and_return(true)
|
|
82
|
+
expect(gateway).to receive(:charge).ordered.and_return(true)
|
|
83
|
+
allow(ledger).to receive(:record)
|
|
84
|
+
|
|
85
|
+
processor.process(payment)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "records in ledger after successful charge" do
|
|
89
|
+
allow(gateway).to receive(:validate).and_return(true)
|
|
90
|
+
|
|
91
|
+
expect(gateway).to receive(:charge).ordered.and_return(true)
|
|
92
|
+
expect(ledger).to receive(:record).ordered
|
|
93
|
+
|
|
94
|
+
processor.process(payment)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Practical example: state machine transitions
|
|
101
|
+
RSpec.describe OrderStateMachine do
|
|
102
|
+
subject(:machine) { build(:order_state_machine, logger:) }
|
|
103
|
+
|
|
104
|
+
let(:logger) { spy("logger") }
|
|
105
|
+
|
|
106
|
+
describe "#complete" do
|
|
107
|
+
it "transitions through states in order" do
|
|
108
|
+
machine.complete
|
|
109
|
+
|
|
110
|
+
expect(logger).to have_received(:log).with("pending -> processing").ordered
|
|
111
|
+
expect(logger).to have_received(:log).with("processing -> shipped").ordered
|
|
112
|
+
expect(logger).to have_received(:log).with("shipped -> delivered").ordered
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# When NOT to use ordering
|
|
118
|
+
RSpec.describe "prefer unordered when possible" do
|
|
119
|
+
subject(:notifier) { build(:multi_channel_notifier, email:, sms:, push:) }
|
|
120
|
+
|
|
121
|
+
let(:email) { spy("email") }
|
|
122
|
+
let(:sms) { spy("sms") }
|
|
123
|
+
let(:push) { spy("push") }
|
|
124
|
+
|
|
125
|
+
describe "#notify_all" do
|
|
126
|
+
# BAD: Using ordered when order doesn't matter
|
|
127
|
+
# it "sends all notifications in order" do
|
|
128
|
+
# expect(email).to receive(:send).ordered
|
|
129
|
+
# expect(sms).to receive(:send).ordered
|
|
130
|
+
# expect(push).to receive(:send).ordered
|
|
131
|
+
# notifier.notify_all
|
|
132
|
+
# end
|
|
133
|
+
|
|
134
|
+
# GOOD: Just verify all are called
|
|
135
|
+
it "sends all notifications" do
|
|
136
|
+
notifier.notify_all
|
|
137
|
+
|
|
138
|
+
expect(email).to have_received(:send)
|
|
139
|
+
expect(sms).to have_received(:send)
|
|
140
|
+
expect(push).to have_received(:send)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# RSpec Mocks: Receive Counts Examples
|
|
2
|
+
# Source: rspec-mocks gem features/setting_constraints/receive_counts.feature
|
|
3
|
+
|
|
4
|
+
# NOTE: Default expectation is once unless specified
|
|
5
|
+
|
|
6
|
+
# Exact counts
|
|
7
|
+
RSpec.describe "exact receive counts" do
|
|
8
|
+
describe "once (default)" do
|
|
9
|
+
it "expects exactly one call" do
|
|
10
|
+
dbl = double("collaborator")
|
|
11
|
+
expect(dbl).to receive(:foo).once
|
|
12
|
+
dbl.foo
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe "twice" do
|
|
17
|
+
it "expects exactly two calls" do
|
|
18
|
+
dbl = double("collaborator")
|
|
19
|
+
expect(dbl).to receive(:foo).twice
|
|
20
|
+
dbl.foo
|
|
21
|
+
dbl.foo
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
describe "exactly(n).times" do
|
|
26
|
+
it "expects specific number of calls" do
|
|
27
|
+
dbl = double("collaborator")
|
|
28
|
+
expect(dbl).to receive(:foo).exactly(3).times
|
|
29
|
+
3.times { dbl.foo }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "uses .time for singular" do
|
|
33
|
+
dbl = double("collaborator")
|
|
34
|
+
expect(dbl).to receive(:foo).exactly(1).time
|
|
35
|
+
dbl.foo
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Minimum counts
|
|
41
|
+
RSpec.describe "at_least counts" do
|
|
42
|
+
describe "at_least(:once)" do
|
|
43
|
+
it "passes with one or more calls" do
|
|
44
|
+
dbl = double("collaborator")
|
|
45
|
+
expect(dbl).to receive(:foo).at_least(:once)
|
|
46
|
+
dbl.foo
|
|
47
|
+
dbl.foo # Additional calls are fine
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe "at_least(:twice)" do
|
|
52
|
+
it "passes with two or more calls" do
|
|
53
|
+
dbl = double("collaborator")
|
|
54
|
+
expect(dbl).to receive(:foo).at_least(:twice)
|
|
55
|
+
dbl.foo
|
|
56
|
+
dbl.foo
|
|
57
|
+
dbl.foo # Additional calls are fine
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe "at_least(n).times" do
|
|
62
|
+
it "passes with n or more calls" do
|
|
63
|
+
dbl = double("collaborator")
|
|
64
|
+
expect(dbl).to receive(:foo).at_least(3).times
|
|
65
|
+
5.times { dbl.foo }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Maximum counts
|
|
71
|
+
RSpec.describe "at_most counts" do
|
|
72
|
+
describe "at_most(:once)" do
|
|
73
|
+
it "passes with zero or one call" do
|
|
74
|
+
dbl = double("collaborator")
|
|
75
|
+
expect(dbl).to receive(:foo).at_most(:once)
|
|
76
|
+
dbl.foo
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "also passes with zero calls" do
|
|
80
|
+
dbl = double("collaborator")
|
|
81
|
+
expect(dbl).to receive(:foo).at_most(:once)
|
|
82
|
+
# Not called - still passes
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
describe "at_most(:twice)" do
|
|
87
|
+
it "passes with up to two calls" do
|
|
88
|
+
dbl = double("collaborator")
|
|
89
|
+
expect(dbl).to receive(:foo).at_most(:twice)
|
|
90
|
+
dbl.foo
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
describe "at_most(n).times" do
|
|
95
|
+
it "passes with n or fewer calls" do
|
|
96
|
+
dbl = double("collaborator")
|
|
97
|
+
expect(dbl).to receive(:foo).at_most(3).times
|
|
98
|
+
2.times { dbl.foo }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# have_received with counts
|
|
104
|
+
RSpec.describe "have_received with counts" do
|
|
105
|
+
it "verifies exact count" do
|
|
106
|
+
invitation = spy("invitation")
|
|
107
|
+
invitation.deliver
|
|
108
|
+
invitation.deliver
|
|
109
|
+
|
|
110
|
+
expect(invitation).to have_received(:deliver).twice
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it "verifies minimum count" do
|
|
114
|
+
invitation = spy("invitation")
|
|
115
|
+
5.times { invitation.deliver }
|
|
116
|
+
|
|
117
|
+
expect(invitation).to have_received(:deliver).at_least(3).times
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "verifies maximum count" do
|
|
121
|
+
invitation = spy("invitation")
|
|
122
|
+
2.times { invitation.deliver }
|
|
123
|
+
|
|
124
|
+
expect(invitation).to have_received(:deliver).at_most(5).times
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Practical example
|
|
129
|
+
RSpec.describe BankAccount do
|
|
130
|
+
subject(:account) { build(:bank_account, logger:) }
|
|
131
|
+
|
|
132
|
+
let(:logger) { instance_double("Logger") }
|
|
133
|
+
|
|
134
|
+
describe "#transfer" do
|
|
135
|
+
context "successful transfer" do
|
|
136
|
+
before do
|
|
137
|
+
allow(logger).to receive(:info)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "logs exactly twice (start and end)" do
|
|
141
|
+
expect(logger).to receive(:info).twice
|
|
142
|
+
|
|
143
|
+
account.transfer(to: build(:bank_account), amount: 100)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
describe "#batch_transfer" do
|
|
149
|
+
let(:recipients) { build_list(:bank_account, 5) }
|
|
150
|
+
|
|
151
|
+
before do
|
|
152
|
+
allow(logger).to receive(:info)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it "logs at least once per recipient" do
|
|
156
|
+
expect(logger).to receive(:info).at_least(5).times
|
|
157
|
+
|
|
158
|
+
account.batch_transfer(recipients:, amount: 50)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Practical example: rate limiting
|
|
164
|
+
RSpec.describe RateLimitedClient do
|
|
165
|
+
subject(:client) { build(:rate_limited_client, api:) }
|
|
166
|
+
|
|
167
|
+
let(:api) { instance_double("ExternalApi") }
|
|
168
|
+
|
|
169
|
+
describe "#fetch_all" do
|
|
170
|
+
before do
|
|
171
|
+
allow(api).to receive(:fetch).and_return(data: [])
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it "makes at most 10 API calls" do
|
|
175
|
+
expect(api).to receive(:fetch).at_most(10).times
|
|
176
|
+
|
|
177
|
+
client.fetch_all(max_pages: 20)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|