spec_forge 0.7.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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -9
  3. data/README.md +125 -203
  4. data/bin/spec_forge +1 -1
  5. data/flake.lock +76 -4
  6. data/flake.nix +5 -4
  7. data/lib/spec_forge/attribute/chainable.rb +6 -6
  8. data/lib/spec_forge/attribute/environment.rb +45 -0
  9. data/lib/spec_forge/attribute/factory.rb +26 -17
  10. data/lib/spec_forge/attribute/faker.rb +6 -1
  11. data/lib/spec_forge/attribute/generate.rb +114 -0
  12. data/lib/spec_forge/attribute/literal.rb +1 -14
  13. data/lib/spec_forge/attribute/matcher.rb +6 -2
  14. data/lib/spec_forge/attribute/parameterized.rb +20 -22
  15. data/lib/spec_forge/attribute/resolvable_array.rb +16 -16
  16. data/lib/spec_forge/attribute/resolvable_hash.rb +17 -16
  17. data/lib/spec_forge/attribute/resolvable_struct.rb +67 -0
  18. data/lib/spec_forge/attribute/template.rb +118 -0
  19. data/lib/spec_forge/attribute/transform.rb +14 -19
  20. data/lib/spec_forge/attribute/variable.rb +31 -31
  21. data/lib/spec_forge/attribute.rb +54 -100
  22. data/lib/spec_forge/blueprint.rb +27 -0
  23. data/lib/spec_forge/cli/docs/generate.rb +28 -8
  24. data/lib/spec_forge/cli/docs.rb +5 -2
  25. data/lib/spec_forge/cli/init.rb +4 -4
  26. data/lib/spec_forge/cli/new.rb +78 -27
  27. data/lib/spec_forge/cli/run.rb +84 -52
  28. data/lib/spec_forge/cli/serve.rb +6 -0
  29. data/lib/spec_forge/cli.rb +6 -14
  30. data/lib/spec_forge/configuration.rb +212 -78
  31. data/lib/spec_forge/documentation/{loader → builder}/cache.rb +26 -23
  32. data/lib/spec_forge/documentation/builder/compiler.rb +373 -0
  33. data/lib/spec_forge/documentation/builder/extractor.rb +75 -0
  34. data/lib/spec_forge/documentation/builder.rb +77 -329
  35. data/lib/spec_forge/documentation/document/operation.rb +4 -4
  36. data/lib/spec_forge/documentation/document.rb +0 -6
  37. data/lib/spec_forge/documentation/generator.rb +88 -0
  38. data/lib/spec_forge/documentation/{generators/openapi → openapi/v3_0}/error_formatter.rb +2 -2
  39. data/lib/spec_forge/documentation/openapi/v3_0/example.rb +1 -1
  40. data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +1 -1
  41. data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +22 -6
  42. data/lib/spec_forge/documentation/openapi/v3_0/response.rb +29 -7
  43. data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +20 -2
  44. data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +1 -1
  45. data/lib/spec_forge/documentation/openapi/v3_0.rb +116 -0
  46. data/lib/spec_forge/documentation/openapi.rb +40 -12
  47. data/lib/spec_forge/documentation.rb +1 -7
  48. data/lib/spec_forge/error.rb +215 -41
  49. data/lib/spec_forge/factory.rb +38 -18
  50. data/lib/spec_forge/forge/action.rb +41 -0
  51. data/lib/spec_forge/forge/actions/call.rb +33 -0
  52. data/lib/spec_forge/forge/actions/debug.rb +47 -0
  53. data/lib/spec_forge/forge/actions/expect.rb +44 -0
  54. data/lib/spec_forge/forge/actions/request.rb +65 -0
  55. data/lib/spec_forge/forge/actions/store.rb +31 -0
  56. data/lib/spec_forge/forge/callbacks.rb +80 -0
  57. data/lib/spec_forge/forge/context.rb +41 -0
  58. data/lib/spec_forge/forge/display.rb +503 -0
  59. data/lib/spec_forge/forge/hooks.rb +131 -0
  60. data/lib/spec_forge/forge/runner/array_io.rb +81 -0
  61. data/lib/spec_forge/forge/runner/content_validator.rb +92 -0
  62. data/lib/spec_forge/forge/runner/header_validator.rb +66 -0
  63. data/lib/spec_forge/forge/runner/reporter.rb +56 -0
  64. data/lib/spec_forge/forge/runner/schema_validator.rb +113 -0
  65. data/lib/spec_forge/forge/runner.rb +118 -0
  66. data/lib/spec_forge/forge/timer.rb +94 -0
  67. data/lib/spec_forge/forge/variables.rb +38 -0
  68. data/lib/spec_forge/forge.rb +207 -133
  69. data/lib/spec_forge/http/backend.rb +49 -143
  70. data/lib/spec_forge/http/client.rb +14 -17
  71. data/lib/spec_forge/http/request.rb +37 -84
  72. data/lib/spec_forge/http/verb.rb +4 -0
  73. data/lib/spec_forge/http.rb +0 -5
  74. data/lib/spec_forge/loader/filter.rb +85 -0
  75. data/lib/spec_forge/loader/step_processor.rb +282 -0
  76. data/lib/spec_forge/loader.rb +105 -220
  77. data/lib/spec_forge/normalizer/default.rb +1 -1
  78. data/lib/spec_forge/normalizer/structure.rb +140 -0
  79. data/lib/spec_forge/normalizer/transformers.rb +168 -0
  80. data/lib/spec_forge/normalizer/validators.rb +50 -8
  81. data/lib/spec_forge/normalizer.rb +76 -119
  82. data/lib/spec_forge/normalizers/callback.yml +38 -0
  83. data/lib/spec_forge/normalizers/configuration.yml +59 -9
  84. data/lib/spec_forge/normalizers/factory.yml +53 -2
  85. data/lib/spec_forge/normalizers/factory_reference.yml +63 -2
  86. data/lib/spec_forge/normalizers/json_schema.yml +79 -0
  87. data/lib/spec_forge/normalizers/step.yml +506 -0
  88. data/lib/spec_forge/step/call.rb +36 -0
  89. data/lib/spec_forge/step/expect.rb +110 -0
  90. data/lib/spec_forge/step/source.rb +22 -0
  91. data/lib/spec_forge/step.rb +129 -0
  92. data/lib/spec_forge/type.rb +115 -66
  93. data/lib/spec_forge/version.rb +1 -1
  94. data/lib/spec_forge.rb +44 -106
  95. data/lib/templates/forge_helper.rb.tt +43 -22
  96. data/lib/templates/new_blueprint.yml.tt +54 -0
  97. metadata +75 -44
  98. data/lib/spec_forge/attribute/global.rb +0 -96
  99. data/lib/spec_forge/attribute/store.rb +0 -65
  100. data/lib/spec_forge/backtrace_formatter.rb +0 -50
  101. data/lib/spec_forge/callbacks.rb +0 -88
  102. data/lib/spec_forge/context/callbacks.rb +0 -91
  103. data/lib/spec_forge/context/global.rb +0 -72
  104. data/lib/spec_forge/context/store.rb +0 -131
  105. data/lib/spec_forge/context/variables.rb +0 -91
  106. data/lib/spec_forge/context.rb +0 -36
  107. data/lib/spec_forge/core_ext/rspec.rb +0 -55
  108. data/lib/spec_forge/core_ext.rb +0 -5
  109. data/lib/spec_forge/documentation/generators/base.rb +0 -81
  110. data/lib/spec_forge/documentation/generators/openapi/base.rb +0 -100
  111. data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +0 -65
  112. data/lib/spec_forge/documentation/generators/openapi.rb +0 -59
  113. data/lib/spec_forge/documentation/generators.rb +0 -17
  114. data/lib/spec_forge/documentation/loader.rb +0 -159
  115. data/lib/spec_forge/documentation/openapi/base.rb +0 -33
  116. data/lib/spec_forge/filter.rb +0 -86
  117. data/lib/spec_forge/normalizer/definition.rb +0 -248
  118. data/lib/spec_forge/normalizers/_shared.yml +0 -74
  119. data/lib/spec_forge/normalizers/constraint.yml +0 -8
  120. data/lib/spec_forge/normalizers/expectation.yml +0 -47
  121. data/lib/spec_forge/normalizers/global_context.yml +0 -28
  122. data/lib/spec_forge/normalizers/spec.yml +0 -50
  123. data/lib/spec_forge/runner/adapter.rb +0 -183
  124. data/lib/spec_forge/runner/callbacks.rb +0 -246
  125. data/lib/spec_forge/runner/debug_proxy.rb +0 -213
  126. data/lib/spec_forge/runner/listener.rb +0 -54
  127. data/lib/spec_forge/runner/metadata.rb +0 -58
  128. data/lib/spec_forge/runner/state.rb +0 -98
  129. data/lib/spec_forge/runner.rb +0 -75
  130. data/lib/spec_forge/spec/expectation/constraint.rb +0 -127
  131. data/lib/spec_forge/spec/expectation.rb +0 -68
  132. data/lib/spec_forge/spec.rb +0 -68
  133. data/lib/templates/new_spec.yml.tt +0 -43
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Forge
5
+ class Runner
6
+ #
7
+ # Validates JSON content against expected matchers
8
+ #
9
+ # Recursively walks through the response body and expected values,
10
+ # running RSpec matchers at each leaf node and collecting failures.
11
+ #
12
+ class ContentValidator
13
+ #
14
+ # Creates a new content validator
15
+ #
16
+ # @param data [Hash, Array] The response data to validate
17
+ # @param expected [Hash, Array] The expected content matchers
18
+ #
19
+ # @return [ContentValidator] A new validator instance
20
+ #
21
+ def initialize(data, expected)
22
+ @data = data
23
+ @expected = expected
24
+ @failures = []
25
+ end
26
+
27
+ #
28
+ # Validates the data against expected content matchers
29
+ #
30
+ # @return [void]
31
+ #
32
+ # @raise [Error::ContentValidationFailure] If validation fails
33
+ #
34
+ def validate!
35
+ check_content(@data, @expected, path: "")
36
+
37
+ raise Error::ContentValidationFailure.new(@failures) if @failures.any?
38
+ end
39
+
40
+ private
41
+
42
+ def failure!(path, message)
43
+ @failures << {path:, message:}
44
+ end
45
+
46
+ def check_content(data, expected, path:)
47
+ case expected
48
+ when Hash
49
+ check_hash(data, expected, path:)
50
+ when Array
51
+ check_array(data, expected, path:)
52
+ else
53
+ run_matcher(data, expected, path:)
54
+ end
55
+ end
56
+
57
+ def check_hash(data, expected, path:)
58
+ expected.each do |key, expected_value|
59
+ new_path = path.empty? ? ".#{key}" : "#{path}.#{key}"
60
+
61
+ actual_key = [key.to_sym, key.to_s].detect { |k| data.respond_to?(:key?) && data.key?(k) }
62
+ actual_value = data[actual_key]
63
+
64
+ if actual_value.nil? && actual_key.nil?
65
+ failure!(new_path, "key not found")
66
+ next
67
+ end
68
+
69
+ check_content(actual_value, expected_value, path: new_path)
70
+ end
71
+ end
72
+
73
+ def check_array(data, expected, path:)
74
+ if !data.is_a?(Array)
75
+ failure!(path, "expected array, got #{data.class}")
76
+ return
77
+ end
78
+
79
+ expected.each_with_index do |expected_item, index|
80
+ check_content(data[index], expected_item, path: "#{path}[#{index}]")
81
+ end
82
+ end
83
+
84
+ def run_matcher(data, matcher, path:)
85
+ return if matcher.matches?(data)
86
+
87
+ failure!(path, matcher.failure_message.lstrip)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Forge
5
+ class Runner
6
+ #
7
+ # Validates HTTP response headers against expected matchers
8
+ #
9
+ # Performs case-insensitive header name matching and runs
10
+ # RSpec matchers against header values.
11
+ #
12
+ class HeaderValidator
13
+ #
14
+ # Creates a new header validator
15
+ #
16
+ # @param headers [Hash] The response headers to validate
17
+ # @param expected [Hash] The expected header matchers
18
+ #
19
+ # @return [HeaderValidator] A new validator instance
20
+ #
21
+ def initialize(headers, expected)
22
+ @headers = headers
23
+ @expected = expected
24
+ @failures = []
25
+ end
26
+
27
+ #
28
+ # Validates the headers against expected matchers
29
+ #
30
+ # @return [void]
31
+ #
32
+ # @raise [Error::HeaderValidationFailure] If validation fails
33
+ #
34
+ def validate!
35
+ @expected.each do |key, matcher|
36
+ actual_key = find_header_key(key)
37
+
38
+ if actual_key.nil?
39
+ failure!(key, "header not found")
40
+ next
41
+ end
42
+
43
+ actual_value = @headers[actual_key]
44
+ next if matcher.matches?(actual_value)
45
+
46
+ failure!(key, matcher.failure_message.lstrip)
47
+ end
48
+
49
+ raise Error::HeaderValidationFailure.new(@failures) if @failures.any?
50
+ end
51
+
52
+ private
53
+
54
+ def find_header_key(key)
55
+ return key if @headers.key?(key)
56
+
57
+ @headers.keys.find { |k| k.to_s.downcase == key.to_s.downcase }
58
+ end
59
+
60
+ def failure!(header, message)
61
+ @failures << {header:, message:}
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Forge
5
+ class Runner
6
+ #
7
+ # RSpec formatter listener for tracking test results
8
+ #
9
+ # Receives notifications from RSpec when examples pass or fail,
10
+ # updating the forge's statistics and display accordingly.
11
+ #
12
+ class Reporter
13
+ #
14
+ # Creates a new reporter for the given forge instance
15
+ #
16
+ # @param forge [Forge, nil] The forge instance to report to
17
+ #
18
+ # @return [Reporter] A new reporter instance
19
+ #
20
+ def initialize(forge = nil)
21
+ @forge = forge
22
+ @stats = forge&.stats
23
+ @display = forge&.display
24
+ end
25
+
26
+ #
27
+ # Called when an RSpec example fails
28
+ #
29
+ # @param notification [RSpec::Core::Notifications::ExampleNotification] The failure notification
30
+ #
31
+ # @return [void]
32
+ #
33
+ def example_failed(notification)
34
+ return if @stats.nil? || @display.nil?
35
+
36
+ @stats[:failed] += 1
37
+ @display.expectation_failed(notification.example.description, indent: 1)
38
+ end
39
+
40
+ #
41
+ # Called when an RSpec example passes
42
+ #
43
+ # @param notification [RSpec::Core::Notifications::ExampleNotification] The success notification
44
+ #
45
+ # @return [void]
46
+ #
47
+ def example_passed(notification)
48
+ return if @stats.nil? || @display.nil?
49
+
50
+ @stats[:passed] += 1
51
+ @display.expectation_passed(notification.example.description, indent: 1)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Forge
5
+ class Runner
6
+ #
7
+ # Validates JSON structure against a schema definition
8
+ #
9
+ # Validates that response data matches the expected types and structure
10
+ # defined in shape: or schema: blocks. Supports nested objects, arrays
11
+ # with patterns, and nullable types.
12
+ #
13
+ class SchemaValidator
14
+ #
15
+ # Creates a new schema validator
16
+ #
17
+ # @param data [Hash, Array] The response data to validate
18
+ # @param schema [Hash] The schema definition to validate against
19
+ #
20
+ # @return [SchemaValidator] A new validator instance
21
+ #
22
+ def initialize(data, schema)
23
+ @data = data
24
+ @schema = schema
25
+ @failures = []
26
+ end
27
+
28
+ #
29
+ # Validates the data against the schema definition
30
+ #
31
+ # @return [void]
32
+ #
33
+ # @raise [Error::SchemaValidationFailure] If validation fails
34
+ #
35
+ def validate!
36
+ check_schema(@data, @schema, path: "")
37
+
38
+ raise Error::SchemaValidationFailure.new(@failures) if @failures.size > 0
39
+ end
40
+
41
+ private
42
+
43
+ def failure!(path, expected_type, actual_value)
44
+ @failures << {
45
+ path: path.empty? ? "root" : path,
46
+ expected_type:,
47
+ actual_value:,
48
+ actual_type: actual_value.class
49
+ }
50
+ end
51
+
52
+ def check_schema(data, schema, path:)
53
+ check_type(data, schema[:type], path:)
54
+ check_structure(data, schema[:structure], path:) if schema[:structure]
55
+ check_pattern(data, schema[:pattern], path:) if schema[:pattern]
56
+ end
57
+
58
+ def check_type(data, expected_types, path:)
59
+ return if expected_types.any? { |type| data.is_a?(type) }
60
+
61
+ failure!(path, expected_types, data)
62
+ end
63
+
64
+ def check_structure(data, schema, path:)
65
+ case schema
66
+ when Hash
67
+ check_hash_structure(data, schema, path:)
68
+ when Array
69
+ check_array_structure(data, schema, path:)
70
+ end
71
+ end
72
+
73
+ def check_pattern(data, schema, path:)
74
+ # Only arrays allowed
75
+ check_type(data, [Array], path:)
76
+
77
+ data.each_with_index do |value, index|
78
+ check_schema(value, schema, path: "#{path}[#{index}]")
79
+ end
80
+ end
81
+
82
+ def check_hash_structure(data, structure, path:)
83
+ structure.each do |key, expected|
84
+ check_hash_key(
85
+ data, key, expected,
86
+ path: path.empty? ? ".#{key}" : "#{path}.#{key}"
87
+ )
88
+ end
89
+ end
90
+
91
+ def check_hash_key(data, key, expected, path:)
92
+ actual_key = [key.to_sym, key.to_s].detect { |k| data.respond_to?(:key?) && data.key?(k) }
93
+
94
+ if actual_key
95
+ check_schema(data[actual_key], expected, path:)
96
+ return
97
+ end
98
+
99
+ # Key is missing - only fail if not optional
100
+ return if expected[:optional]
101
+
102
+ failure!(path, expected[:type], nil)
103
+ end
104
+
105
+ def check_array_structure(data, structure, path:)
106
+ structure.each_with_index do |expected, index|
107
+ check_schema(data[index], expected, path: "#{path}[#{index}]")
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Forge
5
+ #
6
+ # Executes expectations using RSpec as the underlying test framework
7
+ #
8
+ # Runner wraps RSpec to run individual expectation blocks, capturing
9
+ # results and formatting them for display. It creates isolated RSpec
10
+ # example groups for each expectation.
11
+ #
12
+ class Runner
13
+ # @return [ArrayIO] Output stream for RSpec formatter
14
+ attr_reader :output_io
15
+
16
+ # @return [StringIO] Error stream for RSpec formatter
17
+ attr_reader :error_io
18
+
19
+ #
20
+ # Creates a new RSpec runner with the specified CLI arguments
21
+ #
22
+ # @param cli_args [Array<String>] Command line arguments for RSpec configuration
23
+ #
24
+ # @return [Runner] A new runner instance
25
+ #
26
+ def initialize(cli_args = [])
27
+ options = RSpec::Core::ConfigurationOptions.new(cli_args)
28
+
29
+ @configuration = RSpec.configuration.deep_dup
30
+ @configuration.reset
31
+
32
+ @world = RSpec::Core::World.new
33
+ @runner = RSpec::Core::Runner.new(options, @configuration, @world)
34
+
35
+ @output_io = ArrayIO.new
36
+ @error_io = StringIO.new
37
+ @runner.configure(@error_io, @output_io)
38
+ end
39
+
40
+ #
41
+ # Runs an expectation and returns any failed examples
42
+ #
43
+ # @param forge [Forge] The forge instance
44
+ # @param step [Step] The current step
45
+ # @param expectation [Step::Expect] The expectation to run
46
+ #
47
+ # @return [Array<Hash>] List of failed examples (empty if all passed)
48
+ #
49
+ def run(forge, step, expectation)
50
+ configure_formatters(forge)
51
+
52
+ @runner.run_specs([create_example_group(forge, step, expectation)])
53
+
54
+ entry = @output_io.entries.last.to_h
55
+ entry[:examples].reject { |ex| ex[:status] == "passed" }
56
+ end
57
+
58
+ private
59
+
60
+ def configure_formatters(forge)
61
+ # Resetting the configuration also means resetting the Formatters/Reporters.
62
+ @configuration.reset
63
+ @configuration.add_formatter(RSpec::Core::Formatters::JsonFormatter)
64
+
65
+ # Make sure to load a formatter first and register to its reporter.
66
+ # Otherwise RSpec will default the reporter.
67
+ @configuration.formatter_loader.reporter.register_listener(
68
+ Reporter.new(forge), :example_passed, :example_failed
69
+ )
70
+ end
71
+
72
+ def create_example_group(forge, step, expectation)
73
+ RSpec::Core::ExampleGroup.describe(step.source.to_s, :spec_forge) do
74
+ let(:response) { forge.variables[:response] }
75
+
76
+ let(:headers) { response[:headers] }
77
+ let(:body) { response[:body].is_a?(Hash) ? response[:body].deep_symbolize_keys : response[:body] }
78
+
79
+ ############################################################
80
+ # Status check
81
+ if (status_matcher = expectation.status_matcher)
82
+ it "Status" do
83
+ expect(response[:status]).to status_matcher
84
+ end
85
+ end
86
+
87
+ ############################################################
88
+ # Headers check
89
+ if (headers_matcher = expectation.headers_matcher)
90
+ it "Headers" do
91
+ HeaderValidator.new(headers, headers_matcher).validate!
92
+ end
93
+ end
94
+
95
+ ############################################################
96
+ # JSON checks
97
+ if (json_size_matcher = expectation.json_size_matcher)
98
+ it "JSON size" do
99
+ expect(body.size).to json_size_matcher
100
+ end
101
+ end
102
+
103
+ if (schema_structure = expectation.json_schema)
104
+ it "JSON schema" do
105
+ SchemaValidator.new(body, schema_structure).validate!
106
+ end
107
+ end
108
+
109
+ if (content_matcher = expectation.json_content_matcher)
110
+ it "JSON content" do
111
+ ContentValidator.new(body, content_matcher).validate!
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Forge
5
+ #
6
+ # Simple timer for tracking execution duration
7
+ #
8
+ # Used to measure how long a forge run takes from start to finish.
9
+ #
10
+ class Timer
11
+ # @return [Time, nil] Time when the timer started
12
+ attr_reader :started_at
13
+
14
+ # @return [Time, nil] Time when the timer stopped
15
+ attr_reader :stopped_at
16
+
17
+ #
18
+ # Creates a new timer in the reset state
19
+ #
20
+ # @return [Timer] A new timer instance
21
+ #
22
+ def initialize
23
+ reset
24
+ end
25
+
26
+ #
27
+ # Resets the timer to its initial state
28
+ #
29
+ # @return [Timer] self for chaining
30
+ #
31
+ def reset
32
+ @started_at = nil
33
+ @stopped_at = nil
34
+
35
+ self
36
+ end
37
+
38
+ #
39
+ # Starts the timer
40
+ #
41
+ # @return [Timer] self for chaining
42
+ #
43
+ def start
44
+ reset
45
+
46
+ @started_at ||= Time.current
47
+
48
+ self
49
+ end
50
+
51
+ #
52
+ # Stops the timer
53
+ #
54
+ # @return [Timer] self for chaining
55
+ #
56
+ def stop
57
+ return self if @started_at.nil?
58
+
59
+ @stopped_at ||= Time.current
60
+
61
+ self
62
+ end
63
+
64
+ #
65
+ # Returns whether the timer has been started
66
+ #
67
+ # @return [Boolean] True if the timer has been started
68
+ #
69
+ def started?
70
+ !started_at.nil?
71
+ end
72
+
73
+ #
74
+ # Returns whether the timer has been stopped
75
+ #
76
+ # @return [Boolean] True if the timer has been stopped
77
+ #
78
+ def stopped?
79
+ !stopped_at.nil?
80
+ end
81
+
82
+ #
83
+ # Returns the elapsed time in seconds
84
+ #
85
+ # @return [Float] Seconds elapsed since start (or 0 if not started)
86
+ #
87
+ def time_elapsed
88
+ return 0 if started_at.nil?
89
+
90
+ (stopped_at || Time.current) - started_at
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Forge
5
+ #
6
+ # Hash-based storage for runtime variables
7
+ #
8
+ # Manages both static (global) variables that persist across blueprints
9
+ # and dynamic variables that are cleared between blueprints. Static
10
+ # variables are restored when clear is called.
11
+ #
12
+ class Variables < Hash
13
+ #
14
+ # Creates a new Variables hash with static and dynamic values
15
+ #
16
+ # @param static [Hash] Variables that persist across blueprint clears
17
+ # @param dynamic [Hash] Variables that are cleared between blueprints
18
+ #
19
+ # @return [Variables] A new variables instance
20
+ #
21
+ def initialize(static: {}, dynamic: {})
22
+ @static = static.deep_dup
23
+
24
+ merge!(@static, dynamic.deep_dup)
25
+ end
26
+
27
+ #
28
+ # Clears dynamic variables while preserving static ones
29
+ #
30
+ # @return [Variables] self
31
+ #
32
+ def clear
33
+ super
34
+ merge!(@static)
35
+ end
36
+ end
37
+ end
38
+ end