Module: Hecks::Behaviors::Expectations
- Defined in:
- lib/hecks/behaviors/expectations.rb
Defined Under Namespace
Classes: Result
Constant Summary collapse
- REFUSAL_CLASSES =
Hecks::Runtime::DOMAIN_REFUSALS
- SPECIAL_KEYS =
%i[ok refused emits count].freeze
Class Method Summary collapse
-
.boot_and_guard(files) ⇒ Object
reset_runtime_state!only drops repository OBJECTS between tests (registry.rb) — sufficient isolation forMemory, whose@recordsis a plain per-instance ivar, so a fresh object really is a fresh store. - .check_fields(test, state) ⇒ Object
- .check_ok(test) ⇒ Object
- .check_refusal(test, error) ⇒ Object
-
.dispatch_command(runtime, verb, args) ⇒ Object
A behaviors test writes a dispatch the way the guide's own chess examples do — receiver identity and command facts side by side (
label: "g", id: "wn", to: { file: 2, rank: 2 }) — and since #335 the dispatcher's ownto:keyword is the ROUTING envelope, so forwarding those kwargs loose collides the moment a domain declares a command fact namedto(chess does: every Move's own destination). - .error_result(test, message) ⇒ Object
- .fail_result(test, message) ⇒ Object
- .field_expectations?(test) ⇒ Boolean
- .guard_memory_only!(runtime) ⇒ Object
-
.normalize(value) ⇒ Object
The real corpus this DSL is proven against writes VO-typed
expectvalues both ways — bare (expect kind: "bishop") and wrapped (expect kind: { value: "bishop" }). - .pass_result(test) ⇒ Object
-
.qualify(command, on_aggregate, bluebooks, kind:) ⇒ Object
A bare
tests/setupcommand name carries no domain —loadscan name more than one bluebook, so resolution searches every aggregate across every bluebook the suite booted for the one that actually declares the command. -
.query_row(test, rows) ⇒ Object
A field expectation on a query names one row's shape, so it only makes sense once the query has settled on exactly one — the same reason
expect status: "sold"on a multi-row answer would be ambiguous about which row it's describing. - .run_command(test, runtime, verb) ⇒ Object
- .run_one(test, suite, runtime: nil) ⇒ Object
- .run_query(test, runtime, verb) ⇒ Object
- .run_tested(test, runtime, bluebooks) ⇒ Object
- .runtime_for(suite) ⇒ Object
-
.settled_state(runtime, verb, result) ⇒ Object
A field expectation reads the aggregate AS IT STANDS once the dispatch and its whole cascade have run — the same "cascades are always on" reading
emits:already commits to.
Class Method Details
.boot_and_guard(files) ⇒ Object
reset_runtime_state! only drops repository OBJECTS between
tests (registry.rb) — sufficient isolation for Memory, whose
@records is a plain per-instance ivar, so a fresh object really
is a fresh store. Against anything else (Sqlite, Postgres) the
rows themselves stay put: tests leak into each other, and a
suite booted against a domain's REAL hecksagon writes to a real
database. Refusing that wiring here, at boot, is the same shape
of guard BindingPolicy already applies to a missing bind — the
project's identity is refusing bad wiring up front, not
discovering it mid-suite.
101 102 103 104 105 |
# File 'lib/hecks/behaviors/expectations.rb', line 101 def boot_and_guard(files) runtime = Hecks::Runtime::Loader.boot_files(files, install_facade: false) guard_memory_only!(runtime) runtime end |
.check_fields(test, state) ⇒ Object
257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 |
# File 'lib/hecks/behaviors/expectations.rb', line 257 def check_fields(test, state) test.expect.each do |key, expected| next if SPECIAL_KEYS.include?(key) unless state.key?(key) || state.key?(key.to_s) return fail_result(test, "expect #{key}: names no field on the tested aggregate — " \ "valid expect keys are ok:, refused:, emits:, count: (queries only), " \ "or a real field name") end actual = normalize(state.key?(key) ? state[key] : state[key.to_s]) exp = normalize(expected) return fail_result(test, "expected #{key}: #{expected.inspect}, got #{actual.inspect}") unless actual == exp end nil end |
.check_ok(test) ⇒ Object
248 249 250 251 252 253 254 255 |
# File 'lib/hecks/behaviors/expectations.rb', line 248 def check_ok(test) return unless test.expect.key?(:ok) expected = test.expect[:ok] return if expected == true || expected.nil? fail_result(test, "expect ok: only accepts true — got #{expected.inspect}") end |
.check_refusal(test, error) ⇒ Object
274 275 276 277 278 279 280 281 282 283 284 285 |
# File 'lib/hecks/behaviors/expectations.rb', line 274 def check_refusal(test, error) expected = test.expect[:refused] return error_result(test, "unexpected refusal (#{error.class}): #{error.}") unless expected msg = error..to_s # hecks's given/ensures refusals render "Command refused — # <description>" (RefusalWording) ; a behaviors file names just # the description — end_with?/include? bridges the prefix. return pass_result(test) if msg == expected || msg.end_with?(expected) || msg.include?(expected) fail_result(test, "expected refused: #{expected.inspect}, got #{msg.inspect}") end |
.dispatch_command(runtime, verb, args) ⇒ Object
A behaviors test writes a dispatch the way the guide's own chess
examples do — receiver identity and command facts side by side
(label: "g", id: "wn", to: { file: 2, rank: 2 }) — and since
#335 the dispatcher's own to: keyword is the ROUTING envelope,
so forwarding those kwargs loose collides the moment a domain
declares a command fact named to (chess does: every Move's own
destination). Found live: every such test failed with "to: does
not recognize file, rank" while this guide promised the spelling
works. ReactionInvocation.build is #335's own seam for turning
mixed facts into the strict envelope — identities lifted into
to:, declared facts into with: — so a behaviors dispatch now
goes through the exact same separation a policy's projection
does. A verb that resolves to no command (a port operation —
"Pizzas::Order.PaymentGateway.Receive") keeps the loose
passthrough: its own input already spells the port form's
to:/with:, which the dispatcher's port branch reads directly.
189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 |
# File 'lib/hecks/behaviors/expectations.rb', line 189 def dispatch_command(runtime, verb, args) invocation = begin Runtime::ReactionInvocation.build(registry: runtime.registry, verb: verb, projected: args, explicit: true) rescue Runtime::UnknownVerb nil end return runtime.dispatch(verb, **args) unless invocation if invocation.key?(:to) runtime.dispatch(verb, to: invocation[:to], with: invocation[:with]) else runtime.dispatch(verb, with: invocation[:with]) end end |
.error_result(test, message) ⇒ Object
335 |
# File 'lib/hecks/behaviors/expectations.rb', line 335 def error_result(test, ) = Result.new(description: test.description, status: :error, message: ) |
.fail_result(test, message) ⇒ Object
334 |
# File 'lib/hecks/behaviors/expectations.rb', line 334 def fail_result(test, ) = Result.new(description: test.description, status: :fail, message: ) |
.field_expectations?(test) ⇒ Boolean
227 228 229 |
# File 'lib/hecks/behaviors/expectations.rb', line 227 def field_expectations?(test) test.expect.keys.any? { |key| !SPECIAL_KEYS.include?(key) } end |
.guard_memory_only!(runtime) ⇒ Object
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/hecks/behaviors/expectations.rb', line 107 def guard_memory_only!(runtime) runtime.registry.bluebooks.each_value do |bluebook| bluebook.aggregates.each do |aggregate| bind = Ports::Persistence::BindingPolicy.resolve(runtime.registry, bluebook.name, aggregate) next if bind.adapter == Ports::Persistence::DEFAULT_ADAPTER raise Malformed, "#{bluebook.name}::#{aggregate.hecks_name} is persisted_by " \ "#{bind.adapter.inspect}, not #{Ports::Persistence::DEFAULT_ADAPTER.inspect} — " \ "a behaviors suite's `loads` must resolve every aggregate to an in-memory " \ "binding, or tests leak state into each other and write to a real database. " \ "Load a Memory-bound sibling hecksagon instead of the domain's real one — see " \ "examples/pizzas/bluebook/pizzas.behaviors's own `loads` comment for the pattern." end end end |
.normalize(value) ⇒ Object
The real corpus this DSL is proven against writes VO-typed
expect values both ways — bare (expect kind: "bishop") and
wrapped (expect kind: { value: "bishop" }). A live record's
field always comes back as a Hecks::Runtime::Value;
normalizing BOTH sides to the same bare-scalar-or-plain-hash
shape is the one comparison that accepts either spelling.
293 294 295 296 297 298 |
# File 'lib/hecks/behaviors/expectations.rb', line 293 def normalize(value) return Hecks::Runtime::Value.materialize_unwrapped(value) if value.is_a?(Hecks::Runtime::Value) return normalize(value[:value]) if value.is_a?(Hash) && value.keys == [:value] value end |
.pass_result(test) ⇒ Object
333 |
# File 'lib/hecks/behaviors/expectations.rb', line 333 def pass_result(test) = Result.new(description: test.description, status: :pass, message: nil) |
.qualify(command, on_aggregate, bluebooks, kind:) ⇒ Object
A bare tests/setup command name carries no domain — loads
can name more than one bluebook, so resolution searches every
aggregate across every bluebook the suite booted for the one that
actually declares the command. on: (when given, only ever on
the TESTED command — setup never receives it, see the DSL
contract) narrows the search to one aggregate by name instead of
searching all of them.
307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 |
# File 'lib/hecks/behaviors/expectations.rb', line 307 def qualify(command, on_aggregate, bluebooks, kind:) return command.to_s if command.to_s.include?(".") members = kind == :query ? :queries : :commands pairs = if on_aggregate bluebooks.filter_map { |bb| (agg = bb.aggregate(on_aggregate)) && [bb, agg] } else bluebooks.flat_map { |bb| bb.aggregates.map { |agg| [bb, agg] } } end candidates = pairs.select { |_, agg| agg.public_send(members).any? { |m| m.hecks_name == command.to_s } } case candidates.size when 0 raise ArgumentError, "no aggregate among #{bluebooks.map(&:name).inspect} declares a #{kind} " \ "named #{command.inspect} — say `on:` if it's ambiguous, or check the spelling" when 1 bluebook, aggregate = candidates.first "#{bluebook.name}::#{aggregate.name}.#{command}" else owners = candidates.map { |bb, agg| "#{bb.name}::#{agg.name}" } raise ArgumentError, "#{command.inspect} is declared on more than one aggregate (#{owners.join(', ')}) " \ "— say `on:` to disambiguate, or use the dotted FQN" end end |
.query_row(test, rows) ⇒ Object
A field expectation on a query names one row's shape, so it only
makes sense once the query has settled on exactly one — the same
reason expect status: "sold" on a multi-row answer would be
ambiguous about which row it's describing. check_fields itself
stays row-shaped (it already is, for run_command's settled
state); this picks which row it reads.
237 238 239 240 241 242 243 244 245 246 |
# File 'lib/hecks/behaviors/expectations.rb', line 237 def query_row(test, rows) return rows unless rows.is_a?(Array) case rows.size when 1 then rows.first when 0 then fail_result(test, "expect names a field, but the query returned no rows") else fail_result(test, "expect names a field, but the query returned #{rows.size} rows — " \ "narrow it with `input`/`expect count: 1` first") end end |
.run_command(test, runtime, verb) ⇒ Object
136 137 138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/hecks/behaviors/expectations.rb', line 136 def run_command(test, runtime, verb) before = runtime.registry.event_log.length result = dispatch_command(runtime, verb, test.input) return fail_result(test, "expected refused: #{test.expect[:refused].inspect} but dispatch succeeded") if test.expect.key?(:refused) if (expected_emits = test.expect[:emits]) actual = runtime.registry.event_log[before..].map(&:name) return fail_result(test, "expected emits: #{expected_emits.inspect}, got #{actual.inspect}") unless actual == expected_emits end check_ok(test) || check_fields(test, settled_state(runtime, verb, result)) || pass_result(test) end |
.run_one(test, suite, runtime: nil) ⇒ Object
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/hecks/behaviors/expectations.rb', line 47 def run_one(test, suite, runtime: nil) runtime ||= runtime_for(suite) runtime.registry.reset_runtime_state! bluebooks = runtime.registry.bluebooks.values current_setup = nil begin test.setups.each do |setup| current_setup = setup dispatch_command(runtime, qualify(setup.command, nil, bluebooks, kind: :command), setup.args) end rescue *REFUSAL_CLASSES => e return error_result(test, "setup #{current_setup&.command.inspect} refused: #{e.}") end run_tested(test, runtime, bluebooks) rescue StandardError => e error_result(test, "#{e.class}: #{e.}") end |
.run_query(test, runtime, verb) ⇒ Object
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/hecks/behaviors/expectations.rb', line 205 def run_query(test, runtime, verb) rows = runtime.query(verb, **test.input) return fail_result(test, "expected refused: #{test.expect[:refused].inspect} but the query succeeded") if test.expect.key?(:refused) if (expected = test.expect[:count]) count = if rows.is_a?(Array) rows.size else (rows.nil? ? 0 : 1) end return fail_result(test, "expected count: #{expected}, got #{count}") if count != expected end return check_ok(test) || pass_result(test) unless field_expectations?(test) row = query_row(test, rows) return row if row.is_a?(Result) check_ok(test) || check_fields(test, row) || pass_result(test) end |
.run_tested(test, runtime, bluebooks) ⇒ Object
124 125 126 127 128 129 130 131 132 133 134 |
# File 'lib/hecks/behaviors/expectations.rb', line 124 def run_tested(test, runtime, bluebooks) verb = qualify(test.tests_command, test.on_aggregate, bluebooks, kind: test.kind) if test.query? run_query(test, runtime, verb) else run_command(test, runtime, verb) end rescue *REFUSAL_CLASSES => e check_refusal(test, e) end |
.runtime_for(suite) ⇒ Object
82 83 84 85 86 87 88 89 |
# File 'lib/hecks/behaviors/expectations.rb', line 82 def runtime_for(suite) files = Array(suite.loads).map { |path| File.(path) } key = files.map { |file| [file, File.exist?(file) ? File.mtime(file).to_f : nil] } RUNTIMES_LOCK.synchronize do RUNTIMES[key] ||= boot_and_guard(files) end end |
.settled_state(runtime, verb, result) ⇒ Object
A field expectation reads the aggregate AS IT STANDS once the
dispatch and its whole cascade have run — the same "cascades are
always on" reading emits: already commits to. Result#state is
the wrong source for that: it snapshots the instance the OUTER
dispatch saved, and a policy's own reentrant dispatch (a ply
advancing off a Moved event, a move count bumping) hydrates and
saves a FRESH record afterward — so a field the cascade wrote
read back stale (found live: expect move_count: 1 got 0 while
emits: saw MoveCountBumped in the same test). The repository
holds the settled record; read it back by the id the dispatch
itself answered with.
161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/hecks/behaviors/expectations.rb', line 161 def settled_state(runtime, verb, result) return result.state || {} unless result.respond_to?(:id) && result.id domain, rest = verb.split("::", 2) aggregate_name = rest.to_s.split(".", 2).first aggregate = runtime.registry.bluebook(domain)&.aggregate(aggregate_name) return result.state || {} unless aggregate record = runtime.registry.repository(domain, aggregate).find(result.id) record ? record.state : (result.state || {}) end |