ace-test-runner 0.18.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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/test/runner.yml +35 -0
  3. data/.ace-defaults/test/suite.yml +31 -0
  4. data/.ace-defaults/test-runner/config.yml +61 -0
  5. data/CHANGELOG.md +626 -0
  6. data/LICENSE +21 -0
  7. data/README.md +42 -0
  8. data/Rakefile +14 -0
  9. data/exe/ace-test +26 -0
  10. data/exe/ace-test-suite +149 -0
  11. data/lib/ace/test_runner/atoms/command_builder.rb +165 -0
  12. data/lib/ace/test_runner/atoms/lazy_loader.rb +62 -0
  13. data/lib/ace/test_runner/atoms/line_number_resolver.rb +86 -0
  14. data/lib/ace/test_runner/atoms/report_directory_resolver.rb +48 -0
  15. data/lib/ace/test_runner/atoms/report_path_resolver.rb +67 -0
  16. data/lib/ace/test_runner/atoms/result_parser.rb +254 -0
  17. data/lib/ace/test_runner/atoms/test_detector.rb +114 -0
  18. data/lib/ace/test_runner/atoms/test_folder_detector.rb +53 -0
  19. data/lib/ace/test_runner/atoms/test_type_detector.rb +83 -0
  20. data/lib/ace/test_runner/atoms/timestamp_generator.rb +103 -0
  21. data/lib/ace/test_runner/cli/commands/test.rb +326 -0
  22. data/lib/ace/test_runner/cli.rb +16 -0
  23. data/lib/ace/test_runner/formatters/base_formatter.rb +102 -0
  24. data/lib/ace/test_runner/formatters/json_formatter.rb +90 -0
  25. data/lib/ace/test_runner/formatters/markdown_formatter.rb +91 -0
  26. data/lib/ace/test_runner/formatters/progress_file_formatter.rb +164 -0
  27. data/lib/ace/test_runner/formatters/progress_formatter.rb +328 -0
  28. data/lib/ace/test_runner/models/test_configuration.rb +165 -0
  29. data/lib/ace/test_runner/models/test_failure.rb +95 -0
  30. data/lib/ace/test_runner/models/test_group.rb +105 -0
  31. data/lib/ace/test_runner/models/test_report.rb +145 -0
  32. data/lib/ace/test_runner/models/test_result.rb +86 -0
  33. data/lib/ace/test_runner/molecules/cli_argument_parser.rb +263 -0
  34. data/lib/ace/test_runner/molecules/config_loader.rb +162 -0
  35. data/lib/ace/test_runner/molecules/deprecation_fixer.rb +204 -0
  36. data/lib/ace/test_runner/molecules/failed_package_reporter.rb +100 -0
  37. data/lib/ace/test_runner/molecules/failure_analyzer.rb +249 -0
  38. data/lib/ace/test_runner/molecules/in_process_runner.rb +249 -0
  39. data/lib/ace/test_runner/molecules/package_resolver.rb +106 -0
  40. data/lib/ace/test_runner/molecules/pattern_resolver.rb +146 -0
  41. data/lib/ace/test_runner/molecules/rake_integration.rb +218 -0
  42. data/lib/ace/test_runner/molecules/report_storage.rb +303 -0
  43. data/lib/ace/test_runner/molecules/smart_test_executor.rb +107 -0
  44. data/lib/ace/test_runner/molecules/test_executor.rb +162 -0
  45. data/lib/ace/test_runner/organisms/agent_reporter.rb +384 -0
  46. data/lib/ace/test_runner/organisms/report_generator.rb +151 -0
  47. data/lib/ace/test_runner/organisms/sequential_group_executor.rb +185 -0
  48. data/lib/ace/test_runner/organisms/test_orchestrator.rb +648 -0
  49. data/lib/ace/test_runner/rake_task.rb +90 -0
  50. data/lib/ace/test_runner/suite/display_helpers.rb +117 -0
  51. data/lib/ace/test_runner/suite/display_manager.rb +204 -0
  52. data/lib/ace/test_runner/suite/duration_estimator.rb +50 -0
  53. data/lib/ace/test_runner/suite/orchestrator.rb +120 -0
  54. data/lib/ace/test_runner/suite/process_monitor.rb +268 -0
  55. data/lib/ace/test_runner/suite/result_aggregator.rb +176 -0
  56. data/lib/ace/test_runner/suite/simple_display_manager.rb +122 -0
  57. data/lib/ace/test_runner/suite.rb +22 -0
  58. data/lib/ace/test_runner/version.rb +7 -0
  59. data/lib/ace/test_runner.rb +69 -0
  60. metadata +246 -0
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module TestRunner
5
+ module Atoms
6
+ # Parses test output into structured data
7
+ class ResultParser
8
+ # Patterns for parsing minitest output
9
+ PATTERNS = {
10
+ summary: /(\d+) (?:tests?|runs?), (\d+) assertions?, (\d+) failures?, (\d+) errors?, (\d+) skips?/,
11
+ failure: /^\s+\d+\) (Failure|Error):\n(.+?)(?=^\s+\d+\) |^Finished in|\z)/m,
12
+ # Pattern for inline verbose failures - match test name, FAIL marker, and content until next test or EOF
13
+ # Matches: " test_name FAIL (0.02s)\n error details\n /path/file.rb:123..."
14
+ inline_failure: /^\s+(test_[\w_]+).*?FAIL.*?\([\d.]+s\)\n(.*?)(?=^\s+test_[\w_]+.*?(?:PASS|FAIL|ERROR|SKIP)|^Finished in|\z)/m,
15
+ # Pattern for inline errors - same structure as failures but with ERROR marker
16
+ inline_error: /^\s+(test_[\w_]+).*?ERROR.*?\([\d.]+s\)\n(.*?)(?=^\s+test_[\w_]+.*?(?:PASS|FAIL|ERROR|SKIP)|^Finished in|\z)/m,
17
+ location: /\[(.*?):(\d+)\]/,
18
+ duration: /Finished in ([\d.]+)s/,
19
+ deprecation: /DEPRECATION WARNING: (.+)/,
20
+ # Pattern to capture individual test times from verbose output
21
+ # Matches Minitest::Reporters DefaultReporter format:
22
+ # " test_name PASS (0.00s)"
23
+ test_time: /^\s+(test_[\w_]+).*?\s+(PASS|FAIL|ERROR|SKIP)\s+\(([\d.]+)s\)/,
24
+ # Pattern for standard Minitest verbose format:
25
+ # 1. "ClassName#test_name = 0.00 s = ."
26
+ # 2. "ClassName#test_name 0.00 = ."
27
+ test_time_standard: /^(\S+)#(test_[\w_]+)\s+(?:=\s+)?([\d.]+)\s*s?\s*=\s*([.FEWS])/
28
+ }.freeze
29
+
30
+ def parse_output(output)
31
+ # Clean ANSI color codes once for all parsing methods
32
+ clean_output = output.gsub(/\e\[[0-9;]*m/, "")
33
+
34
+ summary = parse_summary(clean_output)
35
+ failures = parse_failures(clean_output)
36
+ duration = parse_duration(clean_output)
37
+ deprecations = parse_deprecations(clean_output)
38
+ test_times = parse_test_times(clean_output)
39
+
40
+ {
41
+ raw_output: output,
42
+ summary: summary,
43
+ failures: failures,
44
+ duration: duration,
45
+ deprecations: deprecations,
46
+ test_times: test_times
47
+ }
48
+ end
49
+
50
+ def parse_summary(clean_output)
51
+ # clean_output already has ANSI codes removed
52
+ match = clean_output.match(PATTERNS[:summary])
53
+
54
+ return default_summary unless match
55
+
56
+ {
57
+ runs: match[1].to_i,
58
+ assertions: match[2].to_i,
59
+ failures: match[3].to_i,
60
+ errors: match[4].to_i,
61
+ skips: match[5].to_i,
62
+ passed: match[1].to_i - match[3].to_i - match[4].to_i - match[5].to_i
63
+ }
64
+ end
65
+
66
+ def parse_failures(clean_output)
67
+ # clean_output already has ANSI codes removed
68
+ failures = []
69
+
70
+ # First try to parse standard format failures
71
+ clean_output.scan(PATTERNS[:failure]) do |type, content|
72
+ failure = parse_single_failure(type, content)
73
+ failures << failure if failure
74
+ end
75
+
76
+ # If no failures found, try inline verbose format
77
+ # But ONLY if the output contains FAIL or ERROR to avoid expensive processing on success
78
+ if failures.empty? && (clean_output.include?(" FAIL ") || clean_output.include?(" ERROR "))
79
+ # Split by test headers and process each block
80
+ test_blocks = clean_output.split(/(?=^\s+test_[\w_]+)/).select { |s| s.match?(/^\s+test_/) }
81
+
82
+ test_blocks.each do |block|
83
+ # Check if this block contains a FAIL or ERROR
84
+ if block =~ /^\s+(test_[\w_]+).*?(ERROR|FAIL).*?\(([\d.]+)s\)\n(.*)/m
85
+ test_name = $1
86
+ type = ($2 == "ERROR") ? :error : :failure
87
+ content = $4
88
+
89
+ failure = parse_inline_failure(test_name, content, type)
90
+ failures << failure if failure
91
+ end
92
+ end
93
+ end
94
+
95
+ failures
96
+ end
97
+
98
+ def parse_duration(clean_output)
99
+ # clean_output already has ANSI codes removed
100
+ match = clean_output.match(PATTERNS[:duration])
101
+ match ? match[1].to_f : 0.0
102
+ end
103
+
104
+ def parse_deprecations(clean_output)
105
+ deprecations = []
106
+
107
+ clean_output.scan(PATTERNS[:deprecation]) do |message|
108
+ deprecations << message.first
109
+ end
110
+
111
+ deprecations.uniq
112
+ end
113
+
114
+ def parse_test_times(clean_output)
115
+ # clean_output already has ANSI codes removed
116
+ test_times = []
117
+
118
+ # Build location index first to avoid O(n²) complexity
119
+ location_index = {}
120
+ clean_output.scan(/(test_[\w_]+).*?\[(.*?):(\d+)\]/) do |name, file, line|
121
+ location_index[name] ||= "#{file}:#{line}"
122
+ end
123
+
124
+ # Try Minitest::Reporters DefaultReporter format first (most common)
125
+ # Format: " test_name PASS (0.00s)"
126
+ clean_output.scan(PATTERNS[:test_time]) do |test_name, status, time|
127
+ test_times << {
128
+ name: test_name,
129
+ status: status,
130
+ duration: time.to_f,
131
+ location: location_index[test_name]
132
+ }
133
+ end
134
+
135
+ # If no matches, try standard Minitest verbose format
136
+ # Format: "ClassName#test_name = 0.00 s = ." or "ClassName#test_name 0.00 = ."
137
+ if test_times.empty?
138
+ clean_output.scan(PATTERNS[:test_time_standard]) do |class_name, test_name, time, status_char|
139
+ status = case status_char
140
+ when "." then "PASS"
141
+ when "F" then "FAIL"
142
+ when "E" then "ERROR"
143
+ when "S" then "SKIP"
144
+ else "UNKNOWN"
145
+ end
146
+ test_times << {
147
+ name: test_name,
148
+ class_name: class_name,
149
+ status: status,
150
+ duration: time.to_f,
151
+ location: location_index[test_name]
152
+ }
153
+ end
154
+ end
155
+
156
+ test_times.sort_by { |t| -t[:duration] }
157
+ end
158
+
159
+ private
160
+
161
+ def default_summary
162
+ {
163
+ runs: 0,
164
+ assertions: 0,
165
+ failures: 0,
166
+ errors: 0,
167
+ skips: 0,
168
+ passed: 0
169
+ }
170
+ end
171
+
172
+ def parse_single_failure(type, content)
173
+ lines = content.strip.lines
174
+ return nil if lines.empty?
175
+
176
+ # First line is test name
177
+ test_name = lines.first.strip
178
+
179
+ # Find location
180
+ location_match = content.match(PATTERNS[:location])
181
+ location = if location_match
182
+ {
183
+ file: location_match[1],
184
+ line: location_match[2].to_i
185
+ }
186
+ end
187
+
188
+ # Extract message (everything after test name and before location/backtrace)
189
+ message_lines = []
190
+ lines[1..-1].each do |line|
191
+ break if line.match?(PATTERNS[:location]) || line.strip.start_with?("/")
192
+ message_lines << line
193
+ end
194
+
195
+ {
196
+ type: type.downcase.to_sym,
197
+ test_name: test_name,
198
+ message: message_lines.join.strip,
199
+ location: location,
200
+ full_content: content.strip
201
+ }
202
+ end
203
+
204
+ def parse_inline_failure(test_name, content, type = :failure)
205
+ # Parse inline verbose format failures
206
+ # Example:
207
+ # test_handles_special_characters FAIL (0.00s)
208
+ # Expected: "path\\to\\file"
209
+ # Actual: nil
210
+ # /Users/mc/Ps/ace/ace-core/test/atoms/env_parser_test.rb:50:in 'EnvParserTest#test_handles_special_characters'
211
+
212
+ lines = content.strip.lines
213
+
214
+ # Extract location from any line that looks like a file path (first occurrence)
215
+ location = nil
216
+ location_line_idx = nil
217
+ lines.each_with_index do |line, idx|
218
+ # Match absolute paths with .rb extension
219
+ if line =~ %r{^\s*(/[^:]+\.rb):(\d+)}
220
+ location = {
221
+ file: $1.strip,
222
+ line: $2.to_i
223
+ }
224
+ location_line_idx = idx
225
+ break
226
+ end
227
+ end
228
+
229
+ # Extract message (all lines before location, trimmed)
230
+ # Skip empty lines and indentation
231
+ message_lines = []
232
+ lines[0...location_line_idx].each do |line|
233
+ stripped = line.strip
234
+ next if stripped.empty?
235
+ message_lines << stripped
236
+ end
237
+
238
+ # If no message extracted, use first non-empty line
239
+ if message_lines.empty? && lines.any?
240
+ message_lines << lines.first.strip
241
+ end
242
+
243
+ {
244
+ type: type,
245
+ test_name: test_name,
246
+ message: message_lines.join("\n"),
247
+ location: location,
248
+ full_content: "#{test_name}\n#{content}".strip
249
+ }
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module TestRunner
5
+ module Atoms
6
+ # Detects and finds test files based on patterns
7
+ class TestDetector
8
+ DEFAULT_PATTERNS = [
9
+ "test/**/*_test.rb",
10
+ "spec/**/*_spec.rb",
11
+ "test/**/test_*.rb"
12
+ ].freeze
13
+
14
+ def initialize(patterns: nil, root_dir: ".")
15
+ @patterns = patterns || DEFAULT_PATTERNS
16
+ @root_dir = root_dir
17
+ end
18
+
19
+ def find_test_files
20
+ files = []
21
+
22
+ # Handle both hash (new format) and array (old format) patterns
23
+ patterns_to_search = if @patterns.is_a?(Hash)
24
+ @patterns.values
25
+ else
26
+ @patterns
27
+ end
28
+
29
+ patterns_to_search.each do |pattern|
30
+ full_pattern = File.join(@root_dir, pattern)
31
+ matched_files = Dir.glob(full_pattern).select { |f| File.file?(f) }
32
+ files.concat(matched_files)
33
+ end
34
+
35
+ # Filter out helper files that aren't actual test files
36
+ files = files.reject { |f| f.end_with?("/test_helper.rb", "/spec_helper.rb") }
37
+
38
+ files.uniq.sort
39
+ end
40
+
41
+ def filter_by_pattern(files, pattern)
42
+ return files unless pattern
43
+
44
+ regex = Regexp.new(pattern, Regexp::IGNORECASE)
45
+ files.select do |file|
46
+ file.match?(regex) || File.basename(file).match?(regex)
47
+ end
48
+ end
49
+
50
+ def test_file?(path)
51
+ return false unless File.exist?(path) && File.file?(path)
52
+
53
+ # Handle both hash (new format) and array (old format) patterns
54
+ patterns_to_check = if @patterns.is_a?(Hash)
55
+ @patterns.values
56
+ else
57
+ @patterns
58
+ end
59
+
60
+ patterns_to_check.any? do |pattern|
61
+ File.fnmatch?(pattern, path) ||
62
+ File.fnmatch?(pattern, path.sub(@root_dir + "/", ""))
63
+ end
64
+ end
65
+
66
+ def classify_file(file_path)
67
+ case file_path
68
+ when /test\/unit\/atoms\//
69
+ :atoms
70
+ when /test\/unit\/molecules\//
71
+ :molecules
72
+ when /test\/unit\/organisms\//
73
+ :organisms
74
+ when /test\/unit\/models\//
75
+ :models
76
+ when /test\/integration\//
77
+ :integration
78
+ when /test\/system\//
79
+ :system
80
+ when /test\/unit\//
81
+ :unit
82
+ when /spec\/unit\/atoms\//
83
+ :atoms
84
+ when /spec\/unit\/molecules\//
85
+ :molecules
86
+ when /spec\/unit\/organisms\//
87
+ :organisms
88
+ when /spec\/unit\/models\//
89
+ :models
90
+ when /spec\/integration\//
91
+ :integration
92
+ when /spec\/system\//
93
+ :system
94
+ when /spec\/unit\//
95
+ :unit
96
+ else
97
+ :other
98
+ end
99
+ end
100
+
101
+ def group_test_files(files)
102
+ grouped = Hash.new { |h, k| h[k] = [] }
103
+
104
+ files.each do |file|
105
+ group = classify_file(file)
106
+ grouped[group] << file
107
+ end
108
+
109
+ grouped
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module TestRunner
5
+ module Atoms
6
+ # Detects test folder location from test file paths and calculates report directory
7
+ module TestFolderDetector
8
+ module_function
9
+
10
+ # Given test file paths, find the test folder and calculate report directory
11
+ # Returns the parent directory of the test folder + "/test-reports"
12
+ #
13
+ # Example:
14
+ # files = ["ace-taskflow/test/commands/tasks_command_test.rb"]
15
+ # detect_report_dir(files) # => "ace-taskflow/test-reports"
16
+ def detect_report_dir(test_files)
17
+ return nil if test_files.nil? || test_files.empty?
18
+
19
+ # Get the first test file and find its test folder
20
+ first_file = test_files.first
21
+ test_folder = find_test_folder(first_file)
22
+
23
+ return nil unless test_folder
24
+
25
+ # Get parent directory and append test-reports
26
+ parent_dir = File.dirname(test_folder)
27
+ File.join(parent_dir, "test-reports")
28
+ end
29
+
30
+ # Find the test folder in a given file path
31
+ # Looks for "/test/" in the path and returns the path up to and including "test"
32
+ #
33
+ # Example:
34
+ # find_test_folder("ace-taskflow/test/commands/tasks_command_test.rb")
35
+ # # => "ace-taskflow/test"
36
+ def find_test_folder(file_path)
37
+ # Remove line number suffix if present (file:line format)
38
+ file_path = file_path.sub(/:\d+$/, "")
39
+
40
+ # Split path into parts
41
+ parts = file_path.split("/")
42
+
43
+ # Find index of "test" directory
44
+ test_index = parts.index("test")
45
+ return nil unless test_index
46
+
47
+ # Return path up to and including test directory
48
+ parts[0..test_index].join("/")
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module TestRunner
5
+ module Atoms
6
+ # Detects whether tests need subprocess isolation based on their content and type
7
+ class TestTypeDetector
8
+ # Patterns that indicate a test needs subprocess isolation
9
+ SUBPROCESS_PATTERNS = [
10
+ /CommandExecutor/, # Tests that execute shell commands
11
+ /Open3\./, # Direct subprocess usage
12
+ /Process\.(spawn|fork|kill)/, # Process manipulation
13
+ /Signal\./, # Signal handling
14
+ /system\(/, # System command execution
15
+ /`.*`/, # Backtick command execution
16
+ /\$\(.*\)/, # Command substitution
17
+ /run_in_subprocess/, # Explicit subprocess test helpers
18
+ /run_in_clean_env/ # Environment isolation helpers
19
+ ].freeze
20
+
21
+ # Test directories that typically need isolation
22
+ ISOLATION_DIRS = %w[
23
+ integration
24
+ system
25
+ e2e
26
+ end_to_end
27
+ ].freeze
28
+
29
+ # Test directories that typically don't need isolation
30
+ UNIT_TEST_DIRS = %w[
31
+ atoms
32
+ molecules
33
+ organisms
34
+ models
35
+ unit
36
+ ].freeze
37
+
38
+ def needs_subprocess?(file_path)
39
+ # Check if explicitly marked as needing isolation
40
+ return true if isolation_directory?(file_path)
41
+
42
+ # Check if it's clearly a unit test that doesn't need isolation
43
+ return false if unit_test_directory?(file_path)
44
+
45
+ # Check file content for patterns that require subprocess
46
+ check_file_content(file_path)
47
+ end
48
+
49
+ def test_type(file_path)
50
+ if isolation_directory?(file_path)
51
+ :integration
52
+ elsif unit_test_directory?(file_path)
53
+ :unit
54
+ elsif check_file_content(file_path)
55
+ :subprocess_required
56
+ else
57
+ :unit
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def isolation_directory?(file_path)
64
+ ISOLATION_DIRS.any? { |dir| file_path.include?("/#{dir}/") }
65
+ end
66
+
67
+ def unit_test_directory?(file_path)
68
+ UNIT_TEST_DIRS.any? { |dir| file_path.include?("/#{dir}/") }
69
+ end
70
+
71
+ def check_file_content(file_path)
72
+ return false unless File.exist?(file_path)
73
+
74
+ content = File.read(file_path)
75
+ SUBPROCESS_PATTERNS.any? { |pattern| content.match?(pattern) }
76
+ rescue
77
+ # If we can't read the file, assume it doesn't need isolation
78
+ false
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/b36ts"
4
+
5
+ module Ace
6
+ module TestRunner
7
+ module Atoms
8
+ # Generates Base36 compact IDs for test reports
9
+ #
10
+ # Uses ace-b36ts to generate 6-character compact IDs (e.g., "i50jj3")
11
+ # for test report directories and files. Reports are temporary, so no
12
+ # backward compatibility with legacy timestamp format is needed.
13
+ #
14
+ # @example Generate a compact ID
15
+ # generator = TimestampGenerator.new
16
+ # generator.generate # => "i50jj3"
17
+ #
18
+ # @example Generate ISO timestamp for human-readable output
19
+ # generator = TimestampGenerator.new
20
+ # generator.iso_timestamp # => "2025-01-06T12:30:00"
21
+ #
22
+ class TimestampGenerator
23
+ ISO_FORMAT = "%Y-%m-%dT%H:%M:%S"
24
+
25
+ # Generate a Base36 compact ID for the given time
26
+ #
27
+ # @param time [Time] The time to encode (default: Time.now)
28
+ # @return [String] 6-character Base36 compact ID
29
+ def generate(time = Time.now)
30
+ Ace::B36ts.encode(time)
31
+ end
32
+
33
+ # Generate an ISO timestamp for human-readable output
34
+ #
35
+ # @param time [Time] The time to format (default: Time.now)
36
+ # @return [String] ISO formatted timestamp
37
+ def iso_timestamp(time = Time.now)
38
+ time.strftime(ISO_FORMAT)
39
+ end
40
+
41
+ # Generate a directory name (alias for generate)
42
+ #
43
+ # @param time [Time] The time to encode (default: Time.now)
44
+ # @return [String] 6-character Base36 compact ID
45
+ def directory_name(time = Time.now)
46
+ generate(time)
47
+ end
48
+
49
+ # Generate a filename timestamp with optional extension
50
+ #
51
+ # @param time [Time] The time to encode (default: Time.now)
52
+ # @param extension [String, nil] Optional file extension
53
+ # @return [String] Filename with optional extension
54
+ def filename_timestamp(time = Time.now, extension = nil)
55
+ base = generate(time)
56
+ extension ? "#{base}#{extension}" : base
57
+ end
58
+
59
+ # Parse a Base36 compact ID string to Time
60
+ #
61
+ # @param id_str [String] The Base36 ID string to parse
62
+ # @return [Time, nil] Parsed time or nil if invalid
63
+ def parse(id_str)
64
+ Ace::B36ts.decode(id_str)
65
+ rescue ArgumentError
66
+ nil
67
+ end
68
+
69
+ # Calculate elapsed time between two times
70
+ #
71
+ # @param start_time [Time] Start time
72
+ # @param end_time [Time] End time (default: Time.now)
73
+ # @return [String] Human-readable duration
74
+ def elapsed_time(start_time, end_time = Time.now)
75
+ duration = end_time - start_time
76
+ format_duration(duration)
77
+ end
78
+
79
+ # Detect the format of an ID string
80
+ #
81
+ # @param value [String] The ID string to analyze
82
+ # @return [Symbol, nil] :"2sec" for valid Base36 IDs, :timestamp for legacy format, or nil
83
+ def self.detect_format(value)
84
+ Ace::B36ts.detect_format(value)
85
+ end
86
+
87
+ private
88
+
89
+ def format_duration(seconds)
90
+ if seconds < 1
91
+ "#{(seconds * 1000).round(2)}ms"
92
+ elsif seconds < 60
93
+ "#{seconds.round(2)}s"
94
+ else
95
+ minutes = (seconds / 60).floor
96
+ remaining_seconds = (seconds % 60).round
97
+ "#{minutes}m #{remaining_seconds}s"
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end