anima-core 0.3.0 → 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.
Files changed (269) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +27 -1
  3. data/CHANGELOG.md +4 -0
  4. data/README.md +219 -25
  5. data/agents/codebase-analyzer.md +88 -0
  6. data/agents/codebase-pattern-finder.md +83 -0
  7. data/agents/documentation-researcher.md +59 -0
  8. data/agents/thoughts-analyzer.md +102 -0
  9. data/agents/web-search-researcher.md +71 -0
  10. data/anima-core.gemspec +3 -0
  11. data/app/channels/session_channel.rb +76 -28
  12. data/app/jobs/agent_request_job.rb +24 -0
  13. data/app/jobs/analytical_brain_job.rb +33 -0
  14. data/app/jobs/count_event_tokens_job.rb +1 -1
  15. data/app/models/concerns/event/broadcasting.rb +20 -2
  16. data/app/models/event.rb +1 -1
  17. data/app/models/goal.rb +91 -0
  18. data/app/models/session.rb +347 -22
  19. data/config/application.rb +2 -0
  20. data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
  21. data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
  22. data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
  23. data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
  24. data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
  25. data/db/migrate/20260315140843_create_goals.rb +16 -0
  26. data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
  27. data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
  28. data/lib/agent_loop.rb +65 -9
  29. data/lib/agents/definition.rb +116 -0
  30. data/lib/agents/registry.rb +106 -0
  31. data/lib/analytical_brain/runner.rb +276 -0
  32. data/lib/analytical_brain/tools/activate_skill.rb +52 -0
  33. data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
  34. data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
  35. data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
  36. data/lib/analytical_brain/tools/finish_goal.rb +62 -0
  37. data/lib/analytical_brain/tools/read_workflow.rb +58 -0
  38. data/lib/analytical_brain/tools/rename_session.rb +63 -0
  39. data/lib/analytical_brain/tools/set_goal.rb +60 -0
  40. data/lib/analytical_brain/tools/update_goal.rb +60 -0
  41. data/lib/analytical_brain.rb +23 -0
  42. data/lib/anima/cli/mcp/secrets.rb +76 -0
  43. data/lib/anima/cli/mcp.rb +197 -0
  44. data/lib/anima/cli.rb +4 -0
  45. data/lib/anima/installer.rb +168 -0
  46. data/lib/anima/settings.rb +226 -0
  47. data/lib/anima/version.rb +1 -1
  48. data/lib/anima.rb +9 -0
  49. data/lib/credential_store.rb +103 -0
  50. data/lib/environment_probe.rb +232 -0
  51. data/lib/llm/client.rb +29 -10
  52. data/lib/mcp/client_manager.rb +86 -0
  53. data/lib/mcp/config.rb +213 -0
  54. data/lib/mcp/health_check.rb +77 -0
  55. data/lib/mcp/secrets.rb +73 -0
  56. data/lib/mcp/stdio_transport.rb +206 -0
  57. data/lib/providers/anthropic.rb +8 -7
  58. data/lib/shell_session.rb +11 -10
  59. data/lib/skills/definition.rb +97 -0
  60. data/lib/skills/registry.rb +105 -0
  61. data/lib/tools/edit.rb +3 -4
  62. data/lib/tools/mcp_tool.rb +114 -0
  63. data/lib/tools/read.rb +15 -16
  64. data/lib/tools/registry.rb +14 -12
  65. data/lib/tools/request_feature.rb +121 -0
  66. data/lib/tools/return_result.rb +81 -0
  67. data/lib/tools/spawn_specialist.rb +109 -0
  68. data/lib/tools/spawn_subagent.rb +111 -0
  69. data/lib/tools/subagent_prompts.rb +12 -0
  70. data/lib/tools/web_get.rb +8 -9
  71. data/lib/tui/app.rb +332 -43
  72. data/lib/tui/message_store.rb +20 -0
  73. data/lib/tui/screens/chat.rb +207 -20
  74. data/lib/workflows/definition.rb +97 -0
  75. data/lib/workflows/registry.rb +89 -0
  76. data/skills/activerecord/SKILL.md +255 -0
  77. data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
  78. data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
  79. data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
  80. data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
  81. data/skills/activerecord/examples/associations/self_referential.rb +302 -0
  82. data/skills/activerecord/examples/associations/through_associations.rb +203 -0
  83. data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
  84. data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
  85. data/skills/activerecord/examples/basics/inheritance.rb +377 -0
  86. data/skills/activerecord/examples/basics/type_casting.rb +317 -0
  87. data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
  88. data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
  89. data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
  90. data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
  91. data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
  92. data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
  93. data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
  94. data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
  95. data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
  96. data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
  97. data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
  98. data/skills/activerecord/examples/querying/optimization.rb +275 -0
  99. data/skills/activerecord/examples/querying/scopes.rb +260 -0
  100. data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
  101. data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
  102. data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
  103. data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
  104. data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
  105. data/skills/activerecord/references/associations.md +709 -0
  106. data/skills/activerecord/references/basics.md +622 -0
  107. data/skills/activerecord/references/callbacks.md +738 -0
  108. data/skills/activerecord/references/migrations.md +657 -0
  109. data/skills/activerecord/references/querying.md +655 -0
  110. data/skills/activerecord/references/validations.md +596 -0
  111. data/skills/dragonruby/SKILL.md +250 -0
  112. data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
  113. data/skills/dragonruby/examples/audio/background_music.rb +29 -0
  114. data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
  115. data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
  116. data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
  117. data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
  118. data/skills/dragonruby/examples/core/hello_world.rb +24 -0
  119. data/skills/dragonruby/examples/core/labels.rb +22 -0
  120. data/skills/dragonruby/examples/core/sprites.rb +35 -0
  121. data/skills/dragonruby/examples/core/state_management.rb +29 -0
  122. data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
  123. data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
  124. data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
  125. data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
  126. data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
  127. data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
  128. data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
  129. data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
  130. data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
  131. data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
  132. data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
  133. data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
  134. data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
  135. data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
  136. data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
  137. data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
  138. data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
  139. data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
  140. data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
  141. data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
  142. data/skills/dragonruby/examples/input/controller_input.rb +28 -0
  143. data/skills/dragonruby/examples/input/directional_input.rb +24 -0
  144. data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
  145. data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
  146. data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
  147. data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
  148. data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
  149. data/skills/dragonruby/examples/rendering/labels.rb +32 -0
  150. data/skills/dragonruby/examples/rendering/layering.rb +51 -0
  151. data/skills/dragonruby/examples/rendering/solids.rb +61 -0
  152. data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
  153. data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
  154. data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
  155. data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
  156. data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
  157. data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
  158. data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
  159. data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
  160. data/skills/dragonruby/references/audio.md +396 -0
  161. data/skills/dragonruby/references/core.md +385 -0
  162. data/skills/dragonruby/references/distribution.md +434 -0
  163. data/skills/dragonruby/references/entities.md +516 -0
  164. data/skills/dragonruby/references/game-logic/persistence.md +386 -0
  165. data/skills/dragonruby/references/game-logic/state.md +389 -0
  166. data/skills/dragonruby/references/input.md +414 -0
  167. data/skills/dragonruby/references/rendering/animation.md +467 -0
  168. data/skills/dragonruby/references/rendering/primitives.md +403 -0
  169. data/skills/dragonruby/references/scenes.md +443 -0
  170. data/skills/draper-decorators/SKILL.md +344 -0
  171. data/skills/draper-decorators/examples/application_decorator.rb +61 -0
  172. data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
  173. data/skills/draper-decorators/examples/model_decorator.rb +152 -0
  174. data/skills/draper-decorators/references/anti-patterns.md +640 -0
  175. data/skills/draper-decorators/references/patterns.md +507 -0
  176. data/skills/draper-decorators/references/testing.md +559 -0
  177. data/skills/gh-issue.md +182 -0
  178. data/skills/mcp-server/SKILL.md +177 -0
  179. data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
  180. data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
  181. data/skills/mcp-server/examples/http_client.rb +48 -0
  182. data/skills/mcp-server/examples/http_server.rb +97 -0
  183. data/skills/mcp-server/examples/rails_integration.rb +88 -0
  184. data/skills/mcp-server/examples/stdio_server.rb +108 -0
  185. data/skills/mcp-server/examples/streaming_client.rb +95 -0
  186. data/skills/mcp-server/references/gotchas.md +183 -0
  187. data/skills/mcp-server/references/prompts.md +98 -0
  188. data/skills/mcp-server/references/resources.md +53 -0
  189. data/skills/mcp-server/references/server.md +140 -0
  190. data/skills/mcp-server/references/tools.md +146 -0
  191. data/skills/mcp-server/references/transport.md +104 -0
  192. data/skills/ratatui-ruby/SKILL.md +315 -0
  193. data/skills/ratatui-ruby/references/core-concepts.md +340 -0
  194. data/skills/ratatui-ruby/references/events.md +387 -0
  195. data/skills/ratatui-ruby/references/frameworks.md +522 -0
  196. data/skills/ratatui-ruby/references/layout.md +423 -0
  197. data/skills/ratatui-ruby/references/styling.md +268 -0
  198. data/skills/ratatui-ruby/references/testing.md +433 -0
  199. data/skills/ratatui-ruby/references/widgets.md +532 -0
  200. data/skills/rspec/SKILL.md +340 -0
  201. data/skills/rspec/examples/core/basic_structure.rb +69 -0
  202. data/skills/rspec/examples/core/configuration.rb +126 -0
  203. data/skills/rspec/examples/core/hooks.rb +126 -0
  204. data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
  205. data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
  206. data/skills/rspec/examples/core/shared_examples.rb +145 -0
  207. data/skills/rspec/examples/factory_bot/associations.rb +314 -0
  208. data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
  209. data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
  210. data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
  211. data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
  212. data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
  213. data/skills/rspec/examples/factory_bot/traits.rb +293 -0
  214. data/skills/rspec/examples/factory_bot/transients.rb +229 -0
  215. data/skills/rspec/examples/matchers/change.rb +115 -0
  216. data/skills/rspec/examples/matchers/collections.rb +154 -0
  217. data/skills/rspec/examples/matchers/comparisons.rb +79 -0
  218. data/skills/rspec/examples/matchers/composing.rb +155 -0
  219. data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
  220. data/skills/rspec/examples/matchers/equality.rb +58 -0
  221. data/skills/rspec/examples/matchers/errors.rb +136 -0
  222. data/skills/rspec/examples/matchers/output.rb +103 -0
  223. data/skills/rspec/examples/matchers/predicates.rb +87 -0
  224. data/skills/rspec/examples/matchers/truthiness.rb +101 -0
  225. data/skills/rspec/examples/matchers/types.rb +82 -0
  226. data/skills/rspec/examples/matchers/yield.rb +147 -0
  227. data/skills/rspec/examples/mocks/any_instance.rb +172 -0
  228. data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
  229. data/skills/rspec/examples/mocks/constants.rb +177 -0
  230. data/skills/rspec/examples/mocks/doubles.rb +139 -0
  231. data/skills/rspec/examples/mocks/expectations.rb +137 -0
  232. data/skills/rspec/examples/mocks/message_chains.rb +173 -0
  233. data/skills/rspec/examples/mocks/ordering.rb +144 -0
  234. data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
  235. data/skills/rspec/examples/mocks/responses.rb +223 -0
  236. data/skills/rspec/examples/mocks/spies.rb +149 -0
  237. data/skills/rspec/examples/mocks/stubbing.rb +133 -0
  238. data/skills/rspec/examples/rails/channels.rb +250 -0
  239. data/skills/rspec/examples/rails/controller_specs.rb +302 -0
  240. data/skills/rspec/examples/rails/helper_specs.rb +245 -0
  241. data/skills/rspec/examples/rails/job_specs.rb +256 -0
  242. data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
  243. data/skills/rspec/examples/rails/matchers.rb +374 -0
  244. data/skills/rspec/examples/rails/model_specs.rb +193 -0
  245. data/skills/rspec/examples/rails/request_specs.rb +275 -0
  246. data/skills/rspec/examples/rails/routing_specs.rb +276 -0
  247. data/skills/rspec/examples/rails/system_specs.rb +294 -0
  248. data/skills/rspec/examples/rails/transactions.rb +254 -0
  249. data/skills/rspec/examples/rails/view_specs.rb +252 -0
  250. data/skills/rspec/references/core.md +816 -0
  251. data/skills/rspec/references/factory_bot.md +641 -0
  252. data/skills/rspec/references/matchers.md +516 -0
  253. data/skills/rspec/references/mocks.md +381 -0
  254. data/skills/rspec/references/rails.md +528 -0
  255. data/templates/soul.md +40 -0
  256. data/workflows/commit.md +45 -0
  257. data/workflows/create_handoff.md +98 -0
  258. data/workflows/create_note.md +82 -0
  259. data/workflows/create_plan.md +457 -0
  260. data/workflows/decompose_ticket.md +109 -0
  261. data/workflows/feature.md +91 -0
  262. data/workflows/implement_plan.md +87 -0
  263. data/workflows/iterate_plan.md +247 -0
  264. data/workflows/research_codebase.md +210 -0
  265. data/workflows/resume_handoff.md +217 -0
  266. data/workflows/review_pr.md +320 -0
  267. data/workflows/thoughts_init.md +71 -0
  268. data/workflows/validate_plan.md +166 -0
  269. metadata +284 -1
@@ -0,0 +1,344 @@
1
+ ---
2
+ name: draper-decorators
3
+ description: "Draper decorator patterns for Rails views. Activate when creating or testing decorators, moving formatting logic out of models/views, editing *_decorator.rb files, or working in app/decorators/."
4
+ ---
5
+
6
+ # Draper Decorators for Rails
7
+
8
+ This skill provides guidance for creating effective Draper decorators in Rails applications.
9
+
10
+ ## Philosophy
11
+
12
+ Decorators implement separation of concerns between **business logic** (models) and **presentation logic** (views). A decorator wraps a model to add view-specific methods without polluting the model.
13
+
14
+ **What belongs in decorators:**
15
+ - Date/time formatting (`created_at.strftime("%B %d, %Y")`)
16
+ - String concatenation (`"#{first_name} #{last_name}"`)
17
+ - HTML generation (`h.content_tag(:span, status, class: css_class)`)
18
+ - Conditional rendering based on state
19
+ - Number formatting (currency, percentages)
20
+ - CSS class generation based on object state
21
+
22
+ **What does NOT belong in decorators:**
23
+ - Business logic (validations, calculations, state changes)
24
+ - Database queries (use includes in controllers)
25
+ - Anything not directly related to presentation
26
+
27
+ ## Basic Structure
28
+
29
+ ```ruby
30
+ # app/decorators/user_decorator.rb
31
+ class UserDecorator < ApplicationDecorator
32
+ delegate_all
33
+
34
+ def full_name
35
+ "#{first_name} #{last_name}"
36
+ end
37
+
38
+ def formatted_created_at
39
+ created_at.strftime("%B %d, %Y")
40
+ end
41
+
42
+ def status_badge
43
+ css_class = active? ? "badge-success" : "badge-secondary"
44
+ h.content_tag(:span, status, class: "badge #{css_class}")
45
+ end
46
+ end
47
+ ```
48
+
49
+ ## Delegation Strategies
50
+
51
+ ### Option 1: `delegate_all` (Convenient)
52
+
53
+ Delegates all methods to the wrapped object via `method_missing`. Use for most decorators.
54
+
55
+ ```ruby
56
+ class ProductDecorator < ApplicationDecorator
57
+ delegate_all
58
+
59
+ def formatted_price
60
+ h.number_to_currency(price)
61
+ end
62
+ end
63
+ ```
64
+
65
+ ### Option 2: Explicit Delegation (Strict)
66
+
67
+ Explicitly declare which methods to delegate. Use for larger apps where control matters.
68
+
69
+ ```ruby
70
+ class ProductDecorator < ApplicationDecorator
71
+ delegate :id, :name, :price, :created_at, :persisted?
72
+
73
+ def formatted_price
74
+ h.number_to_currency(price)
75
+ end
76
+ end
77
+ ```
78
+
79
+ ## Accessing the Wrapped Object
80
+
81
+ Three equivalent ways to access the model:
82
+
83
+ ```ruby
84
+ class ArticleDecorator < ApplicationDecorator
85
+ delegate_all
86
+
87
+ def display_title
88
+ object.title.upcase # via 'object'
89
+ model.title.upcase # via 'model' (alias)
90
+ article.title.upcase # via model name (auto-generated)
91
+ end
92
+ end
93
+ ```
94
+
95
+ ## Accessing Rails Helpers
96
+
97
+ Use `h` or `helpers` to access view helpers:
98
+
99
+ ```ruby
100
+ class PostDecorator < ApplicationDecorator
101
+ delegate_all
102
+
103
+ def formatted_body
104
+ h.simple_format(body)
105
+ end
106
+
107
+ def edit_link
108
+ h.link_to("Edit", h.edit_post_path(object), class: "btn")
109
+ end
110
+
111
+ def publication_date
112
+ h.l(published_at, format: :long) # l is localize alias
113
+ end
114
+ end
115
+ ```
116
+
117
+ ## Decorating in Controllers
118
+
119
+ Decorate **at the last moment**, right before rendering:
120
+
121
+ ```ruby
122
+ class PostsController < ApplicationController
123
+ def show
124
+ @post = Post.find(params[:id]).decorate
125
+ end
126
+
127
+ def index
128
+ @posts = Post.includes(:author).all.decorate
129
+ end
130
+ end
131
+ ```
132
+
133
+ **Critical:** Always use `includes` BEFORE decorating to avoid N+1 queries.
134
+
135
+ ## Association Decoration
136
+
137
+ Use `decorates_association` to auto-decorate associations:
138
+
139
+ ```ruby
140
+ class PostDecorator < ApplicationDecorator
141
+ delegate_all
142
+ decorates_association :author
143
+ decorates_association :comments
144
+ decorates_association :recent_comments, scope: :recent
145
+ end
146
+ ```
147
+
148
+ In views, `@post.author` returns `AuthorDecorator`, not `Author`.
149
+
150
+ ## Context Passing
151
+
152
+ Pass extra data to decorators via context:
153
+
154
+ ```ruby
155
+ # Controller
156
+ @product = Product.find(params[:id]).decorate(context: { current_user: })
157
+
158
+ # Decorator
159
+ class ProductDecorator < ApplicationDecorator
160
+ delegate_all
161
+
162
+ def admin_price_info
163
+ return unless context[:current_user]&.admin?
164
+ "Cost: #{h.number_to_currency(cost)} | Margin: #{margin}%"
165
+ end
166
+ end
167
+ ```
168
+
169
+ ## Collection Decoration
170
+
171
+ ```ruby
172
+ # Auto-infers decorator from model
173
+ @products = Product.all.decorate
174
+
175
+ # Explicit decorator
176
+ @products = ProductDecorator.decorate_collection(Product.all)
177
+
178
+ # With pagination (use custom collection decorator)
179
+ class PaginatingDecorator < Draper::CollectionDecorator
180
+ delegate :current_page, :total_pages, :limit_value
181
+ end
182
+
183
+ class ProductDecorator < ApplicationDecorator
184
+ def self.collection_decorator_class
185
+ PaginatingDecorator
186
+ end
187
+ end
188
+ ```
189
+
190
+ ## Testing Decorators
191
+
192
+ Place specs in `spec/decorators/`. Draper auto-configures RSpec integration.
193
+
194
+ ### Basic Pattern
195
+
196
+ ```ruby
197
+ # spec/decorators/user_decorator_spec.rb
198
+ require 'rails_helper'
199
+
200
+ RSpec.describe UserDecorator do
201
+ subject(:decorator) { described_class.new(user) }
202
+
203
+ let(:user) { build_stubbed(:user, first_name: "John", last_name: "Doe") }
204
+
205
+ describe "#full_name" do
206
+ subject(:full_name) { decorator.full_name }
207
+
208
+ it "combines first and last name" do
209
+ expect(full_name).to eq("John Doe")
210
+ end
211
+ end
212
+
213
+ describe "#formatted_created_at" do
214
+ subject(:formatted_date) { decorator.formatted_created_at }
215
+
216
+ let(:user) { build_stubbed(:user, created_at: Time.zone.parse("2024-01-15")) }
217
+
218
+ it "formats date in long format" do
219
+ expect(formatted_date).to eq("January 15, 2024")
220
+ end
221
+ end
222
+ end
223
+ ```
224
+
225
+ ### Testing with Helpers
226
+
227
+ Access helpers via `helpers` method in tests:
228
+
229
+ ```ruby
230
+ RSpec.describe PostDecorator do
231
+ subject(:decorator) { described_class.new(post) }
232
+
233
+ let(:post) { create(:post) }
234
+
235
+ it "generates correct path" do
236
+ expect(decorator.edit_link).to include(helpers.edit_post_path(post))
237
+ end
238
+ end
239
+ ```
240
+
241
+ ### Testing HTML Output with Capybara
242
+
243
+ ```ruby
244
+ RSpec.describe StatusDecorator do
245
+ subject(:decorator) { described_class.new(order) }
246
+
247
+ describe "#status_badge" do
248
+ subject(:badge) { decorator.status_badge }
249
+
250
+ context "when completed" do
251
+ let(:order) { build_stubbed(:order, :completed) }
252
+
253
+ it "renders success badge" do
254
+ markup = Capybara.string(badge)
255
+ expect(markup).to have_css("span.badge-success", text: "Completed")
256
+ end
257
+ end
258
+ end
259
+ end
260
+ ```
261
+
262
+ ## Common Anti-Patterns
263
+
264
+ ### Fat Decorator
265
+
266
+ Split large decorators into context-specific ones:
267
+
268
+ ```ruby
269
+ # Instead of one 500-line UserDecorator, use:
270
+ class Users::ProfileDecorator < ApplicationDecorator
271
+ # Profile-related presentation
272
+ end
273
+
274
+ class Users::AdminDecorator < ApplicationDecorator
275
+ # Admin panel presentation
276
+ end
277
+ ```
278
+
279
+ ### N+1 Queries
280
+
281
+ ```ruby
282
+ # BAD - triggers N+1
283
+ @posts = Post.all.decorate
284
+ # In decorator: author.name triggers query per post
285
+
286
+ # GOOD - eager load first
287
+ @posts = Post.includes(:author).all.decorate
288
+ ```
289
+
290
+ ### Decorating Too Early
291
+
292
+ ```ruby
293
+ # BAD - decorated objects in business logic
294
+ def publish(decorated_post)
295
+ decorated_post.update(published: true)
296
+ end
297
+
298
+ # GOOD - use models for business logic
299
+ def publish(post)
300
+ post.update(published: true)
301
+ end
302
+ # Decorate only in controller before render
303
+ ```
304
+
305
+ ### Using Decorators in Models
306
+
307
+ ```ruby
308
+ # BAD - model references decorator
309
+ class Post < ApplicationRecord
310
+ def display_title
311
+ PostDecorator.new(self).formatted_title
312
+ end
313
+ end
314
+
315
+ # GOOD - keep models unaware of decorators
316
+ ```
317
+
318
+ ## Quick Reference
319
+
320
+ | Method | Purpose |
321
+ |--------|---------|
322
+ | `object` / `model` | Access wrapped object |
323
+ | `h` / `helpers` | Access view helpers |
324
+ | `context` | Access passed context hash |
325
+ | `delegate_all` | Delegate all methods to object |
326
+ | `decorates_association` | Auto-decorate associations |
327
+ | `decorate` | Decorate single object |
328
+ | `decorate_collection` | Decorate collection |
329
+
330
+ ## Additional Resources
331
+
332
+ ### Reference Files
333
+
334
+ For detailed patterns and examples:
335
+ - **`references/patterns.md`** - Advanced patterns, association decoration, context handling
336
+ - **`references/testing.md`** - Comprehensive RSpec testing guide
337
+ - **`references/anti-patterns.md`** - Detailed anti-patterns with solutions
338
+
339
+ ### Example Files
340
+
341
+ Working examples in `examples/`:
342
+ - **`examples/application_decorator.rb`** - Base decorator template
343
+ - **`examples/model_decorator.rb`** - Full decorator example
344
+ - **`examples/decorator_spec.rb`** - Complete spec template
@@ -0,0 +1,61 @@
1
+ # app/decorators/application_decorator.rb
2
+ #
3
+ # Base decorator with shared presentation methods.
4
+ # All decorators should inherit from this class.
5
+ #
6
+ class ApplicationDecorator < Draper::Decorator
7
+ # Common date formatting
8
+ #
9
+ # @param date [Time, Date, nil] the date to format
10
+ # @param format [Symbol] the I18n format key
11
+ # @return [String] formatted date or "N/A"
12
+ def formatted_date(date = created_at, format: :long)
13
+ return "N/A" if date.blank?
14
+
15
+ h.l(date, format:)
16
+ end
17
+
18
+ # Relative time (e.g., "2 hours ago")
19
+ #
20
+ # @param time [Time] the time to format
21
+ # @return [String] relative time string
22
+ def time_ago(time = created_at)
23
+ "#{h.time_ago_in_words(time)} ago"
24
+ end
25
+
26
+ # Currency formatting
27
+ #
28
+ # @param amount [Numeric, nil] the amount to format
29
+ # @return [String] formatted currency
30
+ def formatted_currency(amount)
31
+ return "$0.00" if amount.blank?
32
+
33
+ h.number_to_currency(amount)
34
+ end
35
+
36
+ # Truncate text with word boundary
37
+ #
38
+ # @param text [String] text to truncate
39
+ # @param length [Integer] maximum length
40
+ # @return [String] truncated text
41
+ def truncated_text(text, length: 100)
42
+ h.truncate(text.to_s, length:, separator: " ")
43
+ end
44
+
45
+ # Safe boolean display
46
+ #
47
+ # @param value [Boolean] the boolean to display
48
+ # @return [String] "Yes" or "No"
49
+ def boolean_display(value)
50
+ value ? "Yes" : "No"
51
+ end
52
+
53
+ # Status badge helper
54
+ #
55
+ # @param text [String] badge text
56
+ # @param type [Symbol] badge type (:success, :warning, :danger, :info, :secondary)
57
+ # @return [String] HTML span element
58
+ def badge(text, type: :secondary)
59
+ h.content_tag(:span, text, class: "badge badge-#{type}")
60
+ end
61
+ end
@@ -0,0 +1,253 @@
1
+ # spec/decorators/post_decorator_spec.rb
2
+ #
3
+ # Complete decorator spec template demonstrating best practices.
4
+ #
5
+ require "rails_helper"
6
+
7
+ RSpec.describe PostDecorator do
8
+ subject(:decorator) { described_class.new(post, context:) }
9
+
10
+ let(:post) { build_stubbed(:post, attributes) }
11
+ let(:attributes) { {} }
12
+ let(:context) { {} }
13
+
14
+ describe "#formatted_title" do
15
+ subject(:formatted_title) { decorator.formatted_title }
16
+
17
+ let(:attributes) { { title: "my post title" } }
18
+
19
+ it "titleizes the title" do
20
+ expect(formatted_title).to eq("My Post Title")
21
+ end
22
+
23
+ context "with max_length" do
24
+ subject(:formatted_title) { decorator.formatted_title(max_length: 10) }
25
+
26
+ let(:attributes) { { title: "a very long post title" } }
27
+
28
+ it "truncates to specified length" do
29
+ expect(formatted_title.length).to be <= 13 # includes "..."
30
+ end
31
+ end
32
+ end
33
+
34
+ describe "#status_badge" do
35
+ subject(:badge) { decorator.status_badge }
36
+
37
+ context "when published" do
38
+ let(:post) { build_stubbed(:post, :published) }
39
+
40
+ it "renders success badge" do
41
+ markup = Capybara.string(badge)
42
+
43
+ expect(markup).to have_css("span.badge.badge-success", text: "Published")
44
+ end
45
+ end
46
+
47
+ context "when draft" do
48
+ let(:post) { build_stubbed(:post, :draft) }
49
+
50
+ it "renders warning badge" do
51
+ markup = Capybara.string(badge)
52
+
53
+ expect(markup).to have_css("span.badge.badge-warning", text: "Draft")
54
+ end
55
+ end
56
+ end
57
+
58
+ describe "#publication_date" do
59
+ subject(:publication_date) { decorator.publication_date }
60
+
61
+ context "when published" do
62
+ let(:attributes) { { published_at: Time.zone.parse("2024-03-15") } }
63
+
64
+ it "formats the date" do
65
+ expect(publication_date).to eq("March 15, 2024")
66
+ end
67
+ end
68
+
69
+ context "when not published" do
70
+ let(:attributes) { { published_at: nil } }
71
+
72
+ it "returns not published message" do
73
+ expect(publication_date).to eq("Not published")
74
+ end
75
+ end
76
+ end
77
+
78
+ describe "#reading_time" do
79
+ subject(:reading_time) { decorator.reading_time }
80
+
81
+ let(:attributes) { { body: "word " * 400 } }
82
+
83
+ it "calculates reading time" do
84
+ expect(reading_time).to eq("2 min read")
85
+ end
86
+ end
87
+
88
+ describe "#excerpt" do
89
+ subject(:excerpt) { decorator.excerpt }
90
+
91
+ let(:attributes) { { body: "a " * 150 } }
92
+
93
+ it "truncates body" do
94
+ expect(excerpt.length).to be <= 203 # 200 + "..."
95
+ end
96
+
97
+ it "truncates at word boundary" do
98
+ expect(excerpt).not_to end_with("a...")
99
+ end
100
+ end
101
+
102
+ describe "#edit_link" do
103
+ subject(:link) { decorator.edit_link }
104
+
105
+ context "without current_user" do
106
+ let(:context) { {} }
107
+
108
+ it "returns nil" do
109
+ expect(link).to be_nil
110
+ end
111
+ end
112
+
113
+ context "with user who cannot edit" do
114
+ let(:context) { { current_user: build_stubbed(:user) } }
115
+
116
+ before do
117
+ allow(context[:current_user]).to receive(:can?).with(:edit, post).and_return(false)
118
+ end
119
+
120
+ it "returns nil" do
121
+ expect(link).to be_nil
122
+ end
123
+ end
124
+
125
+ context "with user who can edit" do
126
+ let(:post) { create(:post) }
127
+ let(:context) { { current_user: build_stubbed(:user) } }
128
+
129
+ before do
130
+ allow(context[:current_user]).to receive(:can?).with(:edit, post).and_return(true)
131
+ end
132
+
133
+ it "renders edit link" do
134
+ markup = Capybara.string(link)
135
+
136
+ expect(markup).to have_link("Edit", href: "/posts/#{post.id}/edit")
137
+ end
138
+
139
+ it "has button classes" do
140
+ markup = Capybara.string(link)
141
+
142
+ expect(markup).to have_css("a.btn.btn-sm.btn-secondary")
143
+ end
144
+ end
145
+ end
146
+
147
+ describe "#delete_link" do
148
+ subject(:link) { decorator.delete_link }
149
+
150
+ context "with user who can delete" do
151
+ let(:post) { create(:post) }
152
+ let(:context) { { current_user: build_stubbed(:user) } }
153
+
154
+ before do
155
+ allow(context[:current_user]).to receive(:can?).with(:delete, post).and_return(true)
156
+ end
157
+
158
+ it "renders delete link with confirmation" do
159
+ markup = Capybara.string(link)
160
+
161
+ expect(markup).to have_css("a[data-confirm='Are you sure?']", text: "Delete")
162
+ end
163
+ end
164
+ end
165
+
166
+ describe "#action_buttons" do
167
+ subject(:buttons) { decorator.action_buttons }
168
+
169
+ context "with no permissions" do
170
+ let(:context) { {} }
171
+
172
+ it "returns nil" do
173
+ expect(buttons).to be_nil
174
+ end
175
+ end
176
+
177
+ context "with edit permission" do
178
+ let(:post) { create(:post) }
179
+ let(:context) { { current_user: build_stubbed(:user) } }
180
+
181
+ before do
182
+ allow(context[:current_user]).to receive(:can?).with(:edit, post).and_return(true)
183
+ allow(context[:current_user]).to receive(:can?).with(:delete, post).and_return(false)
184
+ end
185
+
186
+ it "renders button group" do
187
+ markup = Capybara.string(buttons)
188
+
189
+ expect(markup).to have_css("div.btn-group")
190
+ expect(markup).to have_link("Edit")
191
+ expect(markup).not_to have_link("Delete")
192
+ end
193
+ end
194
+ end
195
+
196
+ describe "associations" do
197
+ describe "#author" do
198
+ subject(:author) { decorator.author }
199
+
200
+ let(:post) { create(:post) }
201
+
202
+ it "returns decorated author" do
203
+ expect(author).to be_decorated_with(AuthorDecorator)
204
+ end
205
+ end
206
+
207
+ describe "#comments" do
208
+ subject(:comments) { decorator.comments }
209
+
210
+ let(:post) { create(:post) }
211
+
212
+ before { create_list(:comment, 3, post:) }
213
+
214
+ it "returns decorated comments" do
215
+ expect(comments).to all(be_decorated_with(CommentDecorator))
216
+ end
217
+
218
+ it "includes all comments" do
219
+ expect(comments.size).to eq(3)
220
+ end
221
+ end
222
+ end
223
+
224
+ describe "#author_info" do
225
+ subject(:info) { decorator.author_info }
226
+
227
+ let(:post) { create(:post) }
228
+
229
+ it "renders author info block" do
230
+ markup = Capybara.string(info)
231
+
232
+ expect(markup).to have_css("div.author-info")
233
+ expect(markup).to have_css("span.author-name")
234
+ expect(markup).to have_css("span.post-date")
235
+ end
236
+ end
237
+
238
+ describe "#meta_info" do
239
+ subject(:meta) { decorator.meta_info }
240
+
241
+ let(:post) { create(:post, :with_category) }
242
+
243
+ before { create_list(:comment, 2, post:) }
244
+
245
+ it "renders meta information" do
246
+ markup = Capybara.string(meta)
247
+
248
+ expect(markup).to have_css("div.post-meta")
249
+ expect(markup).to have_link(post.category.name)
250
+ expect(markup.text).to include("2 comments")
251
+ end
252
+ end
253
+ end