tina4ruby 3.11.13 → 3.11.15

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
data/lib/tina4/testing.rb CHANGED
@@ -1,340 +1,340 @@
1
- # frozen_string_literal: true
2
- require "json"
3
- require "stringio"
4
-
5
- module Tina4
6
- module Testing
7
- class << self
8
- def suites
9
- @suites ||= []
10
- end
11
-
12
- def results
13
- @results ||= { passed: 0, failed: 0, errors: 0, tests: [] }
14
- end
15
-
16
- def reset!
17
- @suites = []
18
- @inline_registry = []
19
- @results = { passed: 0, failed: 0, errors: 0, tests: [] }
20
- end
21
-
22
- def describe(name, &block)
23
- suite = TestSuite.new(name)
24
- suite.instance_eval(&block)
25
- suites << suite
26
- end
27
-
28
- def run_all(quiet: false, failfast: false)
29
- reset_results
30
- suites.each do |suite|
31
- run_suite(suite, quiet: quiet, failfast: failfast)
32
- break if failfast && results[:failed] > 0
33
- end
34
- # Run inline-registered tests
35
- inline_registry.each do |entry|
36
- run_inline_entry(entry, quiet: quiet)
37
- break if failfast && results[:failed] > 0
38
- end
39
- print_results unless quiet
40
- results
41
- end
42
-
43
- # ── Inline testing (parity with Python/PHP/Node decorator pattern) ──
44
-
45
- # Assertion builder: assert_equal(args, expected)
46
- def assert_equal(args, expected)
47
- { type: :equal, args: args, expected: expected }
48
- end
49
-
50
- # Assertion builder: assert_raises(exception_class, args)
51
- def assert_raises(exception_class, args)
52
- { type: :raises, exception: exception_class, args: args }
53
- end
54
-
55
- # Assertion builder: assert_true(args)
56
- def assert_true(args)
57
- { type: :true, args: args }
58
- end
59
-
60
- # Assertion builder: assert_false(args)
61
- def assert_false(args)
62
- { type: :false, args: args }
63
- end
64
-
65
- # Register a callable with inline assertions (mirrors Python's @tests decorator).
66
- #
67
- # Tina4::Testing.tests(
68
- # Tina4::Testing.assert_equal([5, 3], 8),
69
- # Tina4::Testing.assert_raises(ArgumentError, [nil]),
70
- # ) { |a, b| raise ArgumentError, "b required" if b.nil?; a + b }
71
- #
72
- def tests(*assertions, name: nil, &block)
73
- raise ArgumentError, "tests requires a block" unless block_given?
74
- inline_registry << {
75
- fn: block,
76
- name: name || "anonymous",
77
- assertions: assertions
78
- }
79
- block
80
- end
81
-
82
- def inline_registry
83
- @inline_registry ||= []
84
- end
85
-
86
- private
87
-
88
- def run_inline_entry(entry, quiet: false)
89
- fn = entry[:fn]
90
- name = entry[:name]
91
- puts "\n #{name}" unless entry[:assertions].empty? || quiet
92
-
93
- entry[:assertions].each do |assertion|
94
- args = assertion[:args]
95
- case assertion[:type]
96
- when :equal
97
- begin
98
- result = fn.call(*args)
99
- if result == assertion[:expected]
100
- results[:passed] += 1
101
- puts " \e[32m✓\e[0m #{name}(#{args.inspect}) == #{assertion[:expected].inspect}" unless quiet
102
- else
103
- results[:failed] += 1
104
- puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected #{assertion[:expected].inspect}, got #{result.inspect}" unless quiet
105
- end
106
- rescue => e
107
- results[:errors] += 1
108
- puts " \e[33m!\e[0m #{name}(#{args.inspect}) raised #{e.class}: #{e.message}" unless quiet
109
- end
110
- when :raises
111
- begin
112
- fn.call(*args)
113
- results[:failed] += 1
114
- puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected #{assertion[:exception]} but none raised" unless quiet
115
- rescue assertion[:exception]
116
- results[:passed] += 1
117
- puts " \e[32m✓\e[0m #{name}(#{args.inspect}) raises #{assertion[:exception]}" unless quiet
118
- rescue => e
119
- results[:failed] += 1
120
- puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected #{assertion[:exception]}, got #{e.class}" unless quiet
121
- end
122
- when :true
123
- begin
124
- result = fn.call(*args)
125
- if result
126
- results[:passed] += 1
127
- puts " \e[32m✓\e[0m #{name}(#{args.inspect}) is truthy" unless quiet
128
- else
129
- results[:failed] += 1
130
- puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected truthy, got #{result.inspect}" unless quiet
131
- end
132
- rescue => e
133
- results[:errors] += 1
134
- puts " \e[33m!\e[0m #{name}(#{args.inspect}) raised #{e.class}: #{e.message}" unless quiet
135
- end
136
- when :false
137
- begin
138
- result = fn.call(*args)
139
- if !result
140
- results[:passed] += 1
141
- puts " \e[32m✓\e[0m #{name}(#{args.inspect}) is falsy" unless quiet
142
- else
143
- results[:failed] += 1
144
- puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected falsy, got #{result.inspect}" unless quiet
145
- end
146
- rescue => e
147
- results[:errors] += 1
148
- puts " \e[33m!\e[0m #{name}(#{args.inspect}) raised #{e.class}: #{e.message}" unless quiet
149
- end
150
- end
151
- end
152
- end
153
-
154
- def reset_results
155
- @results = { passed: 0, failed: 0, errors: 0, tests: [] }
156
- end
157
-
158
- def run_suite(suite, quiet: false, failfast: false)
159
- puts "\n #{suite.name}" unless quiet
160
- suite.tests.each do |test|
161
- run_test(suite, test, quiet: quiet)
162
- break if failfast && results[:failed] > 0
163
- end
164
- end
165
-
166
- def run_test(suite, test, quiet: false)
167
- suite.run_before_each
168
- context = TestContext.new
169
- context.instance_eval(&test[:block])
170
- results[:passed] += 1
171
- results[:tests] << { name: test[:name], status: :passed, suite: suite.name }
172
- puts " \e[32m✓\e[0m #{test[:name]}" unless quiet
173
- rescue TestFailure => e
174
- results[:failed] += 1
175
- results[:tests] << { name: test[:name], status: :failed, suite: suite.name, message: e.message }
176
- puts " \e[31m✗\e[0m #{test[:name]}: #{e.message}" unless quiet
177
- rescue => e
178
- results[:errors] += 1
179
- results[:tests] << { name: test[:name], status: :error, suite: suite.name, message: e.message }
180
- puts " \e[33m!\e[0m #{test[:name]}: #{e.message}" unless quiet
181
- ensure
182
- suite.run_after_each
183
- end
184
-
185
- def print_results
186
- total = results[:passed] + results[:failed] + results[:errors]
187
- puts "\n #{total} tests: \e[32m#{results[:passed]} passed\e[0m, " \
188
- "\e[31m#{results[:failed]} failed\e[0m, " \
189
- "\e[33m#{results[:errors]} errors\e[0m\n"
190
- end
191
- end
192
-
193
- class TestFailure < StandardError; end
194
-
195
- class TestSuite
196
- attr_reader :name, :tests
197
-
198
- def initialize(name)
199
- @name = name
200
- @tests = []
201
- @before_each = nil
202
- @after_each = nil
203
- end
204
-
205
- def it(description, &block)
206
- @tests << { name: description, block: block }
207
- end
208
-
209
- def before_each(&block)
210
- @before_each = block
211
- end
212
-
213
- def after_each(&block)
214
- @after_each = block
215
- end
216
-
217
- def run_before_each
218
- @before_each&.call
219
- end
220
-
221
- def run_after_each
222
- @after_each&.call
223
- end
224
- end
225
-
226
- class TestContext
227
- def assert(condition, message = "Assertion failed")
228
- raise TestFailure, message unless condition
229
- end
230
-
231
- def assert_equal(expected, actual, message = nil)
232
- msg = message || "Expected #{expected.inspect}, got #{actual.inspect}"
233
- raise TestFailure, msg unless expected == actual
234
- end
235
-
236
- def assert_not_equal(expected, actual, message = nil)
237
- msg = message || "Expected #{actual.inspect} to not equal #{expected.inspect}"
238
- raise TestFailure, msg if expected == actual
239
- end
240
-
241
- def assert_nil(value, message = nil)
242
- msg = message || "Expected nil, got #{value.inspect}"
243
- raise TestFailure, msg unless value.nil?
244
- end
245
-
246
- def assert_not_nil(value, message = nil)
247
- msg = message || "Expected non-nil value"
248
- raise TestFailure, msg if value.nil?
249
- end
250
-
251
- def assert_includes(collection, item, message = nil)
252
- msg = message || "Expected #{collection.inspect} to include #{item.inspect}"
253
- raise TestFailure, msg unless collection.include?(item)
254
- end
255
-
256
- def assert_raises(exception_class, message = nil)
257
- yield
258
- raise TestFailure, message || "Expected #{exception_class} to be raised"
259
- rescue exception_class
260
- true
261
- end
262
-
263
- def assert_true(value, message = nil)
264
- msg = message || "Expected truthy, got #{value.inspect}"
265
- raise TestFailure, msg unless value
266
- end
267
-
268
- def assert_false(value, message = nil)
269
- msg = message || "Expected falsy, got #{value.inspect}"
270
- raise TestFailure, msg if value
271
- end
272
-
273
- def assert_match(pattern, string, message = nil)
274
- msg = message || "Expected #{string.inspect} to match #{pattern.inspect}"
275
- raise TestFailure, msg unless pattern.match?(string)
276
- end
277
-
278
- def assert_json(response_body)
279
- JSON.parse(response_body)
280
- rescue JSON::ParserError => e
281
- raise TestFailure, "Invalid JSON: #{e.message}"
282
- end
283
-
284
- def assert_status(response, expected_status)
285
- actual = response.is_a?(Array) ? response[0] : response.status
286
- assert_equal(expected_status, actual, "Expected status #{expected_status}, got #{actual}")
287
- end
288
-
289
- # HTTP test helpers
290
- def simulate_request(method, path, body: nil, headers: {}, params: {})
291
- env = build_test_env(method, path, body: body, headers: headers, params: params)
292
- app = Tina4::RackApp.new
293
- app.call(env)
294
- end
295
-
296
- def get(path, headers: {}, params: {})
297
- simulate_request("GET", path, headers: headers, params: params)
298
- end
299
-
300
- def post(path, body: nil, headers: {})
301
- simulate_request("POST", path, body: body, headers: headers)
302
- end
303
-
304
- def put(path, body: nil, headers: {})
305
- simulate_request("PUT", path, body: body, headers: headers)
306
- end
307
-
308
- def delete(path, headers: {})
309
- simulate_request("DELETE", path, headers: headers)
310
- end
311
-
312
- private
313
-
314
- def build_test_env(method, path, body: nil, headers: {}, params: {})
315
- query_string = params.empty? ? "" : URI.encode_www_form(params)
316
- body_str = body.is_a?(Hash) ? JSON.generate(body) : (body || "")
317
- input = StringIO.new(body_str)
318
-
319
- env = {
320
- "REQUEST_METHOD" => method.upcase,
321
- "PATH_INFO" => path,
322
- "QUERY_STRING" => query_string,
323
- "CONTENT_TYPE" => body.is_a?(Hash) ? "application/json" : "text/plain",
324
- "CONTENT_LENGTH" => body_str.length.to_s,
325
- "REMOTE_ADDR" => "127.0.0.1",
326
- "rack.input" => input,
327
- "rack.errors" => StringIO.new,
328
- "rack.url_scheme" => "http"
329
- }
330
-
331
- headers.each do |key, value|
332
- env_key = "HTTP_#{key.upcase.gsub('-', '_')}"
333
- env[env_key] = value
334
- end
335
-
336
- env
337
- end
338
- end
339
- end
340
- end
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "stringio"
4
+
5
+ module Tina4
6
+ module Testing
7
+ class << self
8
+ def suites
9
+ @suites ||= []
10
+ end
11
+
12
+ def results
13
+ @results ||= { passed: 0, failed: 0, errors: 0, tests: [] }
14
+ end
15
+
16
+ def reset!
17
+ @suites = []
18
+ @inline_registry = []
19
+ @results = { passed: 0, failed: 0, errors: 0, tests: [] }
20
+ end
21
+
22
+ def describe(name, &block)
23
+ suite = TestSuite.new(name)
24
+ suite.instance_eval(&block)
25
+ suites << suite
26
+ end
27
+
28
+ def run_all(quiet: false, failfast: false)
29
+ reset_results
30
+ suites.each do |suite|
31
+ run_suite(suite, quiet: quiet, failfast: failfast)
32
+ break if failfast && results[:failed] > 0
33
+ end
34
+ # Run inline-registered tests
35
+ inline_registry.each do |entry|
36
+ run_inline_entry(entry, quiet: quiet)
37
+ break if failfast && results[:failed] > 0
38
+ end
39
+ print_results unless quiet
40
+ results
41
+ end
42
+
43
+ # ── Inline testing (parity with Python/PHP/Node decorator pattern) ──
44
+
45
+ # Assertion builder: assert_equal(args, expected)
46
+ def assert_equal(args, expected)
47
+ { type: :equal, args: args, expected: expected }
48
+ end
49
+
50
+ # Assertion builder: assert_raises(exception_class, args)
51
+ def assert_raises(exception_class, args)
52
+ { type: :raises, exception: exception_class, args: args }
53
+ end
54
+
55
+ # Assertion builder: assert_true(args)
56
+ def assert_true(args)
57
+ { type: :true, args: args }
58
+ end
59
+
60
+ # Assertion builder: assert_false(args)
61
+ def assert_false(args)
62
+ { type: :false, args: args }
63
+ end
64
+
65
+ # Register a callable with inline assertions (mirrors Python's @tests decorator).
66
+ #
67
+ # Tina4::Testing.tests(
68
+ # Tina4::Testing.assert_equal([5, 3], 8),
69
+ # Tina4::Testing.assert_raises(ArgumentError, [nil]),
70
+ # ) { |a, b| raise ArgumentError, "b required" if b.nil?; a + b }
71
+ #
72
+ def tests(*assertions, name: nil, &block)
73
+ raise ArgumentError, "tests requires a block" unless block_given?
74
+ inline_registry << {
75
+ fn: block,
76
+ name: name || "anonymous",
77
+ assertions: assertions
78
+ }
79
+ block
80
+ end
81
+
82
+ def inline_registry
83
+ @inline_registry ||= []
84
+ end
85
+
86
+ private
87
+
88
+ def run_inline_entry(entry, quiet: false)
89
+ fn = entry[:fn]
90
+ name = entry[:name]
91
+ puts "\n #{name}" unless entry[:assertions].empty? || quiet
92
+
93
+ entry[:assertions].each do |assertion|
94
+ args = assertion[:args]
95
+ case assertion[:type]
96
+ when :equal
97
+ begin
98
+ result = fn.call(*args)
99
+ if result == assertion[:expected]
100
+ results[:passed] += 1
101
+ puts " \e[32m✓\e[0m #{name}(#{args.inspect}) == #{assertion[:expected].inspect}" unless quiet
102
+ else
103
+ results[:failed] += 1
104
+ puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected #{assertion[:expected].inspect}, got #{result.inspect}" unless quiet
105
+ end
106
+ rescue => e
107
+ results[:errors] += 1
108
+ puts " \e[33m!\e[0m #{name}(#{args.inspect}) raised #{e.class}: #{e.message}" unless quiet
109
+ end
110
+ when :raises
111
+ begin
112
+ fn.call(*args)
113
+ results[:failed] += 1
114
+ puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected #{assertion[:exception]} but none raised" unless quiet
115
+ rescue assertion[:exception]
116
+ results[:passed] += 1
117
+ puts " \e[32m✓\e[0m #{name}(#{args.inspect}) raises #{assertion[:exception]}" unless quiet
118
+ rescue => e
119
+ results[:failed] += 1
120
+ puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected #{assertion[:exception]}, got #{e.class}" unless quiet
121
+ end
122
+ when :true
123
+ begin
124
+ result = fn.call(*args)
125
+ if result
126
+ results[:passed] += 1
127
+ puts " \e[32m✓\e[0m #{name}(#{args.inspect}) is truthy" unless quiet
128
+ else
129
+ results[:failed] += 1
130
+ puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected truthy, got #{result.inspect}" unless quiet
131
+ end
132
+ rescue => e
133
+ results[:errors] += 1
134
+ puts " \e[33m!\e[0m #{name}(#{args.inspect}) raised #{e.class}: #{e.message}" unless quiet
135
+ end
136
+ when :false
137
+ begin
138
+ result = fn.call(*args)
139
+ if !result
140
+ results[:passed] += 1
141
+ puts " \e[32m✓\e[0m #{name}(#{args.inspect}) is falsy" unless quiet
142
+ else
143
+ results[:failed] += 1
144
+ puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected falsy, got #{result.inspect}" unless quiet
145
+ end
146
+ rescue => e
147
+ results[:errors] += 1
148
+ puts " \e[33m!\e[0m #{name}(#{args.inspect}) raised #{e.class}: #{e.message}" unless quiet
149
+ end
150
+ end
151
+ end
152
+ end
153
+
154
+ def reset_results
155
+ @results = { passed: 0, failed: 0, errors: 0, tests: [] }
156
+ end
157
+
158
+ def run_suite(suite, quiet: false, failfast: false)
159
+ puts "\n #{suite.name}" unless quiet
160
+ suite.tests.each do |test|
161
+ run_test(suite, test, quiet: quiet)
162
+ break if failfast && results[:failed] > 0
163
+ end
164
+ end
165
+
166
+ def run_test(suite, test, quiet: false)
167
+ suite.run_before_each
168
+ context = TestContext.new
169
+ context.instance_eval(&test[:block])
170
+ results[:passed] += 1
171
+ results[:tests] << { name: test[:name], status: :passed, suite: suite.name }
172
+ puts " \e[32m✓\e[0m #{test[:name]}" unless quiet
173
+ rescue TestFailure => e
174
+ results[:failed] += 1
175
+ results[:tests] << { name: test[:name], status: :failed, suite: suite.name, message: e.message }
176
+ puts " \e[31m✗\e[0m #{test[:name]}: #{e.message}" unless quiet
177
+ rescue => e
178
+ results[:errors] += 1
179
+ results[:tests] << { name: test[:name], status: :error, suite: suite.name, message: e.message }
180
+ puts " \e[33m!\e[0m #{test[:name]}: #{e.message}" unless quiet
181
+ ensure
182
+ suite.run_after_each
183
+ end
184
+
185
+ def print_results
186
+ total = results[:passed] + results[:failed] + results[:errors]
187
+ puts "\n #{total} tests: \e[32m#{results[:passed]} passed\e[0m, " \
188
+ "\e[31m#{results[:failed]} failed\e[0m, " \
189
+ "\e[33m#{results[:errors]} errors\e[0m\n"
190
+ end
191
+ end
192
+
193
+ class TestFailure < StandardError; end
194
+
195
+ class TestSuite
196
+ attr_reader :name, :tests
197
+
198
+ def initialize(name)
199
+ @name = name
200
+ @tests = []
201
+ @before_each = nil
202
+ @after_each = nil
203
+ end
204
+
205
+ def it(description, &block)
206
+ @tests << { name: description, block: block }
207
+ end
208
+
209
+ def before_each(&block)
210
+ @before_each = block
211
+ end
212
+
213
+ def after_each(&block)
214
+ @after_each = block
215
+ end
216
+
217
+ def run_before_each
218
+ @before_each&.call
219
+ end
220
+
221
+ def run_after_each
222
+ @after_each&.call
223
+ end
224
+ end
225
+
226
+ class TestContext
227
+ def assert(condition, message = "Assertion failed")
228
+ raise TestFailure, message unless condition
229
+ end
230
+
231
+ def assert_equal(expected, actual, message = nil)
232
+ msg = message || "Expected #{expected.inspect}, got #{actual.inspect}"
233
+ raise TestFailure, msg unless expected == actual
234
+ end
235
+
236
+ def assert_not_equal(expected, actual, message = nil)
237
+ msg = message || "Expected #{actual.inspect} to not equal #{expected.inspect}"
238
+ raise TestFailure, msg if expected == actual
239
+ end
240
+
241
+ def assert_nil(value, message = nil)
242
+ msg = message || "Expected nil, got #{value.inspect}"
243
+ raise TestFailure, msg unless value.nil?
244
+ end
245
+
246
+ def assert_not_nil(value, message = nil)
247
+ msg = message || "Expected non-nil value"
248
+ raise TestFailure, msg if value.nil?
249
+ end
250
+
251
+ def assert_includes(collection, item, message = nil)
252
+ msg = message || "Expected #{collection.inspect} to include #{item.inspect}"
253
+ raise TestFailure, msg unless collection.include?(item)
254
+ end
255
+
256
+ def assert_raises(exception_class, message = nil)
257
+ yield
258
+ raise TestFailure, message || "Expected #{exception_class} to be raised"
259
+ rescue exception_class
260
+ true
261
+ end
262
+
263
+ def assert_true(value, message = nil)
264
+ msg = message || "Expected truthy, got #{value.inspect}"
265
+ raise TestFailure, msg unless value
266
+ end
267
+
268
+ def assert_false(value, message = nil)
269
+ msg = message || "Expected falsy, got #{value.inspect}"
270
+ raise TestFailure, msg if value
271
+ end
272
+
273
+ def assert_match(pattern, string, message = nil)
274
+ msg = message || "Expected #{string.inspect} to match #{pattern.inspect}"
275
+ raise TestFailure, msg unless pattern.match?(string)
276
+ end
277
+
278
+ def assert_json(response_body)
279
+ JSON.parse(response_body)
280
+ rescue JSON::ParserError => e
281
+ raise TestFailure, "Invalid JSON: #{e.message}"
282
+ end
283
+
284
+ def assert_status(response, expected_status)
285
+ actual = response.is_a?(Array) ? response[0] : response.status
286
+ assert_equal(expected_status, actual, "Expected status #{expected_status}, got #{actual}")
287
+ end
288
+
289
+ # HTTP test helpers
290
+ def simulate_request(method, path, body: nil, headers: {}, params: {})
291
+ env = build_test_env(method, path, body: body, headers: headers, params: params)
292
+ app = Tina4::RackApp.new
293
+ app.call(env)
294
+ end
295
+
296
+ def get(path, headers: {}, params: {})
297
+ simulate_request("GET", path, headers: headers, params: params)
298
+ end
299
+
300
+ def post(path, body: nil, headers: {})
301
+ simulate_request("POST", path, body: body, headers: headers)
302
+ end
303
+
304
+ def put(path, body: nil, headers: {})
305
+ simulate_request("PUT", path, body: body, headers: headers)
306
+ end
307
+
308
+ def delete(path, headers: {})
309
+ simulate_request("DELETE", path, headers: headers)
310
+ end
311
+
312
+ private
313
+
314
+ def build_test_env(method, path, body: nil, headers: {}, params: {})
315
+ query_string = params.empty? ? "" : URI.encode_www_form(params)
316
+ body_str = body.is_a?(Hash) ? JSON.generate(body) : (body || "")
317
+ input = StringIO.new(body_str)
318
+
319
+ env = {
320
+ "REQUEST_METHOD" => method.upcase,
321
+ "PATH_INFO" => path,
322
+ "QUERY_STRING" => query_string,
323
+ "CONTENT_TYPE" => body.is_a?(Hash) ? "application/json" : "text/plain",
324
+ "CONTENT_LENGTH" => body_str.length.to_s,
325
+ "REMOTE_ADDR" => "127.0.0.1",
326
+ "rack.input" => input,
327
+ "rack.errors" => StringIO.new,
328
+ "rack.url_scheme" => "http"
329
+ }
330
+
331
+ headers.each do |key, value|
332
+ env_key = "HTTP_#{key.upcase.gsub('-', '_')}"
333
+ env[env_key] = value
334
+ end
335
+
336
+ env
337
+ end
338
+ end
339
+ end
340
+ end