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
- .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
-
.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. - .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
.check_fields(test, state) ⇒ Object
197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# File 'lib/hecks/behaviors/expectations.rb', line 197 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
188 189 190 191 192 193 194 195 |
# File 'lib/hecks/behaviors/expectations.rb', line 188 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
214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/hecks/behaviors/expectations.rb', line 214 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.
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 |
# File 'lib/hecks/behaviors/expectations.rb', line 155 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
275 |
# File 'lib/hecks/behaviors/expectations.rb', line 275 def error_result(test, ) = Result.new(description: test.description, status: :error, message: ) |
.fail_result(test, message) ⇒ Object
274 |
# File 'lib/hecks/behaviors/expectations.rb', line 274 def fail_result(test, ) = Result.new(description: test.description, status: :fail, message: ) |
.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.
233 234 235 236 237 238 |
# File 'lib/hecks/behaviors/expectations.rb', line 233 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
273 |
# File 'lib/hecks/behaviors/expectations.rb', line 273 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.
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 |
# File 'lib/hecks/behaviors/expectations.rb', line 247 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 |
.run_command(test, runtime, verb) ⇒ Object
102 103 104 105 106 107 108 109 110 111 112 113 114 |
# File 'lib/hecks/behaviors/expectations.rb', line 102 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
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
# File 'lib/hecks/behaviors/expectations.rb', line 46 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
171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 |
# File 'lib/hecks/behaviors/expectations.rb', line 171 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 check_ok(test) || pass_result(test) end |
.run_tested(test, runtime, bluebooks) ⇒ Object
90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/hecks/behaviors/expectations.rb', line 90 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
81 82 83 84 85 86 87 88 |
# File 'lib/hecks/behaviors/expectations.rb', line 81 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] ||= Hecks::Runtime::Loader.boot_files(files, install_facade: false) 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.
127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/hecks/behaviors/expectations.rb', line 127 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 |