probatio_diabolica 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c53898870646f0712544d5bc8998715622cffb4981711737757c26d3f8e41ad7
4
- data.tar.gz: e3a828ea56faac76e54443c1292235a849bbfd0aca1ff9745c94b3f53190650e
3
+ metadata.gz: 368faf074dc354becfef975422fc3199a1e83293a30bad935ef17696a9b879c9
4
+ data.tar.gz: 66b84421f306926f425cdc6d9e5cc4a1ff3b8ed1cd4f6df13e86bd7313796c5d
5
5
  SHA512:
6
- metadata.gz: 38aa1da254d62abeed89a013e82a1e87b63970c4faedf0fb858d140e3a83a8c61a2fc16fff6f073e0cafd81260f762a7fa0ab294d7a879f20c27c204d9549d50
7
- data.tar.gz: 5e108178e7da9d4ec7605959f96c6b782754a068bcec05faedac09bf93c709fc559cf1c4fb6e0f71aaa6110f396ec2a2f549092ab1f17581efd7d8a92a5dd8d9
6
+ metadata.gz: f9cce79af416cc2b0e216820dd3f07f2d0ba46552db2e2598d9ab2ab83f1492757044717a383256db7b6d14168a3da4d11a0cf89949f6ec1eebe4bf55f366f2c
7
+ data.tar.gz: 03e7a8a23b02b473e8bac35c4b4021e729b4e435408bdc69a6143162eebdad8172ec5bb07b83d4471b87464c5f42b1346a112d0e1d7f3af061f916838ea13ea8
data/README.md CHANGED
@@ -56,6 +56,28 @@ gem build probatio_diabolica.gemspec
56
56
  gem install ./probatio_diabolica-*.gem
57
57
  ```
58
58
 
59
+ ## Release workflow
60
+
61
+ Use the release helper to bump version, refresh `Gemfile.lock`, create commit/tag, and push:
62
+
63
+ ```bash
64
+ # explicit version
65
+ bin/release --version 0.2.0
66
+
67
+ # or env-style
68
+ VERSION=0.2.0 bin/release
69
+
70
+ # semantic bump from current version
71
+ bin/release --bump patch
72
+ ```
73
+
74
+ Useful options:
75
+
76
+ - `--dry-run` preview all actions without modifying files/git
77
+ - `--no-push` create commit/tag locally only
78
+ - `--skip-tests` skip `bundle exec ruby bin/prd spec --mode synthetic`
79
+ - `--allow-dirty` bypass clean-working-tree guard
80
+
59
81
  After installation, you can run:
60
82
 
61
83
  ```bash
@@ -85,39 +107,63 @@ Without valid configuration, tests using `satisfy(...)` will fail.
85
107
 
86
108
  ## Running tests
87
109
 
88
- Command:
110
+ CLI command:
89
111
 
90
112
  ```bash
91
113
  prd <file_or_directory> [options]
92
114
  ```
93
115
 
94
- From source checkout (without gem install), use:
116
+ From source checkout (without gem install), this is always valid:
95
117
 
96
118
  ```bash
97
- bundle exec prd <file_or_directory> [options]
119
+ bundle exec ruby bin/prd <file_or_directory> [options]
98
120
  ```
99
121
 
100
- Supported options:
122
+ Options:
101
123
 
102
- - `-o, --out DIR` writes output to `DIR/report.qd` (otherwise stdout)
103
124
  - `-c, --config FILE` Ruby config file to require
104
- - `-t, --type TYPE` formatter type (`simple` by default; supports `simple`, `html`, `json`, `pdf`)
105
- - `-m, --mode MODE` output verbosity mode (`verbose` by default; supports `verbose`, `synthetic`)
125
+ - `-t, --type TYPE` formatter type(s), default: `simple`
126
+ - supported: `simple`, `html`, `json`, `pdf`
127
+ - can be repeated (`-t html -t json`) or comma-separated (`-t html,json,pdf`)
128
+ - `-o, --out PATH` output base path (directory or file-like base name)
129
+ - `-m, --mode MODE` output mode, default: `verbose`
130
+ - supported: `verbose`, `synthetic`
131
+
132
+ Output rules (`--out`):
133
+
134
+ - No `--out`:
135
+ - one formatter (`simple`, `html`, or `json`): output goes to `stdout`
136
+ - `pdf`: fails (`PDF formatter requires --out`)
137
+ - multiple formatters: fails (`Multiple formatter types require --out`)
138
+ - With `--out PATH`:
139
+ - if `PATH` exists as a directory, or ends with `/`, reports are written as `PATH/report.<ext>`
140
+ - otherwise, `PATH` is treated as a base name and reports are written as `PATH.<ext>`
141
+ - if `PATH` ends with one known extension (`.txt`, `.html`, `.json`, `.pdf`), that extension is stripped before generating outputs
142
+
143
+ Formatter/file extension mapping:
144
+
145
+ - `simple` -> `.txt`
146
+ - `html` -> `.html`
147
+ - `json` -> `.json`
148
+ - `pdf` -> `.pdf`
106
149
 
107
150
  Examples:
108
151
 
109
152
  ```bash
110
153
  # single file
111
- bundle exec prd examples/basics_spec.rb
154
+ bundle exec ruby bin/prd examples/basics_spec.rb
112
155
 
113
156
  # all *_spec.rb files in a directory
114
- bundle exec prd examples
157
+ bundle exec ruby bin/prd examples
115
158
 
116
- # HTML report
117
- bundle exec prd examples/image_spec.rb -t html -o ./tmp
159
+ # HTML report in an existing directory (creates ./tmp/report.html)
160
+ bundle exec ruby bin/prd examples/image_spec.rb -t html -o ./tmp/
161
+
162
+ # multiple reports from one run with shared base name
163
+ bundle exec ruby bin/prd examples/basics_spec.rb -t html,json,pdf -o ./tmp/my_report
118
164
 
119
165
  # compact synthetic output on console
120
- bundle exec prd examples/basics_spec.rb --mode synthetic
166
+ bundle exec ruby bin/prd examples/basics_spec.rb --mode synthetic
121
167
  ```
122
168
 
123
169
  ## Available DSL
@@ -210,7 +256,13 @@ The runtime keeps a model stack (`it` can temporarily override the parent `conte
210
256
 
211
257
  - `PrD::Formatters::SimpleFormatter` (text console output)
212
258
  - `PrD::Formatters::HtmlFormatter` (simple HTML output)
213
- - `PrD::Formatters::JsonFormatter` exists in code but is not currently exposed by `bin/prd`
259
+ - `PrD::Formatters::JsonFormatter` (structured JSON output)
260
+ - `PrD::Formatters::PdfFormatter` (PDF report output)
261
+
262
+ In CLI usage:
263
+
264
+ - selecting one formatter writes one output stream/file
265
+ - selecting multiple formatters runs tests once and writes one file per formatter
214
266
 
215
267
  ### Subject rendering policy (best effort)
216
268
 
@@ -245,5 +297,5 @@ The goal is to preserve readability and report size while surfacing the richest
245
297
  ## Current limitations
246
298
 
247
299
  - Work in progress, API may change.
248
- - Strong dependency on an LLM provider for `satisfy`.
249
- - `-o` output is always written to a file named `report.qd`.
300
+ - `satisfy(...)` requires a configured LLM provider and network access.
301
+ - PDF and multi-format output require `--out`.
data/bin/prd CHANGED
@@ -6,15 +6,93 @@ rescue LoadError
6
6
  require_relative '../lib/pr_d'
7
7
  end
8
8
  require 'optparse'
9
+ require 'fileutils'
9
10
 
10
- options = {}
11
+ FORMATTERS = {
12
+ 'simple' => { klass: PrD::Formatters::SimpleFormatter, extension: '.txt', binary: false },
13
+ 'html' => { klass: PrD::Formatters::HtmlFormatter, extension: '.html', binary: false },
14
+ 'json' => { klass: PrD::Formatters::JsonFormatter, extension: '.json', binary: false },
15
+ 'pdf' => { klass: PrD::Formatters::PdfFormatter, extension: '.pdf', binary: true }
16
+ }.freeze
17
+
18
+ SUPPORTED_FORMATTERS = FORMATTERS.keys.freeze
19
+ SUPPORTED_MODES = %w[verbose synthetic].freeze
20
+ DEFAULT_REPORT_BASENAME = 'report'.freeze
21
+
22
+ class CompositeFormatter
23
+ def initialize(formatters)
24
+ @formatters = formatters
25
+ end
26
+
27
+ def method_missing(name, *args, &block)
28
+ handled = false
29
+ @formatters.each do |formatter|
30
+ next unless formatter.respond_to?(name)
31
+
32
+ formatter.public_send(name, *args, &block)
33
+ handled = true
34
+ end
35
+
36
+ return if handled
37
+
38
+ super
39
+ end
40
+
41
+ def respond_to_missing?(name, include_private = false)
42
+ @formatters.any? { |formatter| formatter.respond_to?(name, include_private) } || super
43
+ end
44
+ end
45
+
46
+ def parse_formatter_types(raw_values)
47
+ types = Array(raw_values)
48
+ .flat_map { |value| value.to_s.split(',') }
49
+ .map(&:strip)
50
+ .reject(&:empty?)
51
+ types = ['simple'] if types.empty?
52
+
53
+ unknown_types = types - SUPPORTED_FORMATTERS
54
+ unless unknown_types.empty?
55
+ supported = SUPPORTED_FORMATTERS.join(', ')
56
+ raise "Unsupported formatter type: #{unknown_types.first}. Supported: #{supported}"
57
+ end
58
+
59
+ types.uniq
60
+ end
61
+
62
+ def normalize_mode(raw_mode)
63
+ mode = raw_mode || 'verbose'
64
+ supported = SUPPORTED_MODES.join(', ')
65
+ raise "Unsupported mode: #{mode}. Supported: #{supported}" unless SUPPORTED_MODES.include?(mode)
66
+
67
+ mode
68
+ end
69
+
70
+ def directory_like_path?(path)
71
+ path.end_with?(File::SEPARATOR) || Dir.exist?(path)
72
+ end
73
+
74
+ def output_base_path(out_path)
75
+ return nil if out_path.nil?
76
+
77
+ if directory_like_path?(out_path)
78
+ File.join(out_path, DEFAULT_REPORT_BASENAME)
79
+ else
80
+ known_extensions = FORMATTERS.values.map { |config| config[:extension].downcase }
81
+ ext = File.extname(out_path).downcase
82
+ return out_path[...-ext.length] if known_extensions.include?(ext)
83
+
84
+ out_path
85
+ end
86
+ end
87
+
88
+ options = { formatters: [] }
11
89
  OptionParser
12
90
  .new do |opts|
13
91
  opts.banner = 'Usage: prd [options] FILE_OR_DIR'
14
- opts.on('-o', '--out DIR', 'Specify the output directory') { |dir| options[:out] = dir }
92
+ opts.on('-o', '--out PATH', 'Specify output base path (directory or path/name)') { |path| options[:out] = path }
15
93
  opts.on('-c', '--config FILE', 'Specify the config file') { |file| options[:config] = file }
16
- opts.on('-t', '--type TYPE', 'Specify the formatter type (simple, html, json, pdf)') do |type|
17
- options[:formatter] = type
94
+ opts.on('-t', '--type TYPE', 'Specify formatter type(s): simple, html, json, pdf (repeat or comma-separated)') do |type|
95
+ options[:formatters] << type
18
96
  end
19
97
  opts.on('-m', '--mode MODE', 'Specify output mode (verbose, synthetic)') do |mode|
20
98
  options[:mode] = mode
@@ -36,47 +114,50 @@ tests =
36
114
  [File.read(input_path)]
37
115
  end
38
116
 
39
- io =
40
- if options[:out].nil?
41
- raise 'PDF formatter requires --out to write a binary report file.' if options[:formatter] == 'pdf'
42
- STDOUT
43
- else
44
- Dir.mkdir(options[:out]) unless Dir.exist?(options[:out])
45
- report_filename = case options[:formatter]
46
- when 'html'
47
- 'report.html'
48
- when 'pdf'
49
- 'report.pdf'
50
- else
51
- 'report.qd'
52
- end
53
- mode = options[:formatter] == 'pdf' ? 'wb' : 'w'
54
- File.open(File.join(options[:out], report_filename), mode)
55
- end
117
+ formatter_types = parse_formatter_types(options[:formatters])
118
+ mode = normalize_mode(options[:mode])
119
+ out_base = output_base_path(options[:out])
56
120
 
57
- formatter_class = case options[:formatter]
58
- when nil, 'simple'
59
- PrD::Formatters::SimpleFormatter
60
- when 'html'
61
- PrD::Formatters::HtmlFormatter
62
- when 'json'
63
- PrD::Formatters::JsonFormatter
64
- when 'pdf'
65
- PrD::Formatters::PdfFormatter
66
- else
67
- raise "Unsupported formatter type: #{options[:formatter]}. Supported: simple, html, json, pdf"
68
- end
69
-
70
- mode = options[:mode] || 'verbose'
71
- supported_modes = %w[verbose synthetic]
72
- raise "Unsupported mode: #{mode}. Supported: verbose, synthetic" unless supported_modes.include?(mode)
121
+ if formatter_types.include?('pdf') && out_base.nil?
122
+ raise 'PDF formatter requires --out to write a binary report file.'
123
+ end
124
+
125
+ if formatter_types.length > 1 && out_base.nil?
126
+ raise 'Multiple formatter types require --out to specify where files will be written.'
127
+ end
73
128
 
74
129
  serializers = {
75
130
  # Ferrum::Network::Exchange => (->(exchange) { exchange.url }),
76
131
  # PDF::Reader => (->(pdf) { pdf.pages.map(&:text).join("\n") })
77
132
  }
78
133
 
79
- formatter = formatter_class.new(io:, serializers:, mode: mode.to_sym)
80
- runtime = PrD::Runtime.new(formatter:, output_dir: options[:out], config_file: options[:config])
134
+ destinations =
135
+ if out_base.nil?
136
+ [{ io: STDOUT, formatter: FORMATTERS.fetch(formatter_types.first)[:klass].new(io: STDOUT, serializers:, mode: mode.to_sym) }]
137
+ else
138
+ output_dir = File.dirname(out_base)
139
+ FileUtils.mkdir_p(output_dir)
140
+ formatter_types.map do |type|
141
+ config = FORMATTERS.fetch(type)
142
+ output_path = "#{out_base}#{config[:extension]}"
143
+ io = File.open(output_path, config[:binary] ? 'wb' : 'w')
144
+ { io:, formatter: config[:klass].new(io:, serializers:, mode: mode.to_sym) }
145
+ end
146
+ end
147
+
148
+ formatter =
149
+ if destinations.length == 1
150
+ destinations.first[:formatter]
151
+ else
152
+ CompositeFormatter.new(destinations.map { |destination| destination[:formatter] })
153
+ end
154
+
155
+ runtime_output_dir = out_base.nil? ? nil : File.dirname(out_base)
156
+ runtime = PrD::Runtime.new(formatter:, output_dir: runtime_output_dir, config_file: options[:config])
81
157
  runtime.run(tests)
82
- io.close if io != STDOUT
158
+ exit_status = runtime.success? ? 0 : 1
159
+ destinations.each do |destination|
160
+ io = destination[:io]
161
+ io.close if io != STDOUT
162
+ end
163
+ exit(exit_status)
@@ -6,14 +6,148 @@ module PrD
6
6
  class HtmlFormatter < Formatter
7
7
  def initialize(io: $stdout, serializers: {}, mode: :verbose)
8
8
  super(io: io, serializers: serializers, mode: mode)
9
- @io << <<~HTML
9
+ @content = +''
10
+ @index_entries = []
11
+ @anchor_counters = Hash.new(0)
12
+ end
13
+
14
+ def context(message)
15
+ anchor_id = next_anchor_id('ctx')
16
+ add_index_entry(type: :context, label: message, level: @level, anchor_id:)
17
+ @content << "<h2 class=\"context\" id=\"#{anchor_id}\">#{escape(message)}</h2>"
18
+ end
19
+
20
+ def success_result(message)
21
+ if synthetic?
22
+ @content << "<div class='status success'>PASS</div>"
23
+ return
24
+ end
25
+ @content << "<div class='status success'>✓ #{escape(message)}</div>"
26
+ end
27
+
28
+ def failure_result(message)
29
+ if synthetic?
30
+ @content << "<div class='status failure'>FAIL</div>"
31
+ return
32
+ end
33
+ @content << "<div class='status failure'>✗ #{escape(message)}</div>"
34
+ end
35
+
36
+ def it(description = nil, &block)
37
+ @current_test_title = description.to_s
38
+ anchor_id = next_anchor_id('test')
39
+ add_index_entry(type: :test, label: description.to_s, level: @level, anchor_id:)
40
+ @content << "<article class=\"test-card\" id=\"#{anchor_id}\">"
41
+ @content << "<h3 class=\"test-title\">#{escape(description)}</h3>"
42
+ end
43
+
44
+ def end_it(description = nil, &block)
45
+ @content << '</article>'
46
+ end
47
+
48
+ def justification(justification)
49
+ return if synthetic?
50
+ @content << "<p class=\"line\"><strong>Justification:</strong> #{escape(justification)}</p>"
51
+ end
52
+
53
+ def let(value)
54
+ end
55
+
56
+ def subject(subject)
57
+ return if synthetic?
58
+ @content << '<div class="subject-block">'
59
+ @content << "<p class=\"line\"><strong>Subject:</strong> #{escape(serialize(subject).to_s)}</p>"
60
+ if image_file?(subject)
61
+ @content << "<img src=\"#{image_data_uri(subject.path)}\" alt=\"Subject image\" class=\"subject-image\" />"
62
+ elsif pdf_file?(subject)
63
+ @content << "<embed src=\"#{pdf_data_uri(subject.path)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
64
+ elsif pdf_reader?(subject)
65
+ @content << "<embed src=\"#{pdf_reader_data_uri(subject)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
66
+ end
67
+ @content << '</div>'
68
+ end
69
+
70
+ def pending(description = nil)
71
+ pending_label = description || 'Pending test'
72
+ anchor_id = next_anchor_id('pending')
73
+ add_index_entry(type: :pending, label: pending_label, level: @level, anchor_id:)
74
+
75
+ @content << "<article class=\"test-card\" id=\"#{anchor_id}\">"
76
+ @content << "<h3 class=\"test-title\">#{escape(pending_label)}</h3>"
77
+ if synthetic?
78
+ @content << "<div class='status pending'>PENDING</div>"
79
+ else
80
+ @content << "<div class='status pending'>⚠ #{escape(pending_label)}</div>"
81
+ @content << '<p class="line muted">This test is pending and has not been executed.</p>'
82
+ end
83
+ @content << '</article>'
84
+ end
85
+
86
+ def expect(expectation)
87
+ return if synthetic?
88
+ @content << "<p class=\"line\"><strong>Expect:</strong> #{escape(serialize(expectation).to_s)}</p>"
89
+ end
90
+
91
+ def to
92
+ return if synthetic?
93
+ @content << '<p class="line"><strong>To:</strong></p>'
94
+ end
95
+
96
+ def not_to
97
+ return if synthetic?
98
+ @content << '<p class="line"><strong>Not to:</strong></p>'
99
+ end
100
+
101
+ def matcher(matcher, sources: nil)
102
+ return if synthetic?
103
+ case matcher
104
+ when Matchers::EqMatcher
105
+ @content << "<p class=\"line\"><strong>Matcher:</strong> Be equal to #{escape(serialize(matcher.expected).to_s)}</p>"
106
+ when Matchers::BeMatcher
107
+ @content << "<p class=\"line\"><strong>Matcher:</strong> Be the same object as #{escape(serialize(matcher.expected).to_s)}</p>"
108
+ when Matchers::IncludesMatcher
109
+ @content << "<p class=\"line\"><strong>Matcher:</strong> Include #{escape(serialize(matcher.expected).to_s)}</p>"
110
+ when Matchers::HaveMatcher
111
+ @content << "<p class=\"line\"><strong>Matcher:</strong> Have #{escape(serialize(matcher.expected).to_s)}</p>"
112
+ when Matchers::LlmMatcher
113
+ @content << "<p class=\"line\"><strong>Matcher:</strong> Satisfy condition #{escape(serialize(matcher.expected).to_s)}</p>"
114
+ when Matchers::AllMatcher
115
+ if sources
116
+ code_line = matcher.expected.source_location.last.to_i
117
+ code = sources.lines[code_line - 1]
118
+ @content << "<p class=\"line\"><strong>Matcher:</strong> All match condition #{escape(code.strip)}</p>"
119
+ else
120
+ @content << '<p class="line"><strong>Matcher:</strong> All match the given condition</p>'
121
+ end
122
+ else
123
+ @content << "<p class=\"line\"><strong>Matcher:</strong> #{escape(matcher.class.to_s)}</p>"
124
+ end
125
+ end
126
+
127
+ def result(passed_count, failed_count)
128
+ summary_class = failed_count > 0 ? 'failure' : 'success'
129
+ @content << "<p class='result #{summary_class}'><strong>#{passed_count} passed, #{failed_count} failed</strong></p>"
130
+ end
131
+
132
+ def flush
133
+ @io << document_opening
134
+ @io << render_index
135
+ @io << @content
136
+ @io << '</main></body></html>'
137
+ super
138
+ end
139
+
140
+ private
141
+
142
+ def document_opening
143
+ <<~HTML
10
144
  <html>
11
145
  <head>
12
146
  <meta charset="utf-8" />
13
147
  <meta name="viewport" content="width=device-width, initial-scale=1" />
14
148
  <link rel="preconnect" href="https://fonts.googleapis.com">
15
149
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16
- <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Source+Sans+3:wght@400;500;600&display=swap" rel="stylesheet">
150
+ <link href="https://fonts.googleapis.com/css2?family=Saira:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
17
151
  <style>
18
152
  :root {
19
153
  --bg: #f3f4f6;
@@ -48,8 +182,42 @@ module PrD
48
182
  border-radius: 18px;
49
183
  }
50
184
 
185
+ .report-index {
186
+ background: var(--paper);
187
+ border: 1px solid var(--line);
188
+ border-radius: 14px;
189
+ padding: 0.9rem 1rem;
190
+ margin-bottom: 1rem;
191
+ }
192
+
193
+ .index-title {
194
+ margin: 0 0 0.6rem;
195
+ font-family: "Saira", Georgia, serif;
196
+ font-size: 1.15rem;
197
+ }
198
+
199
+ .index-list {
200
+ margin: 0;
201
+ padding: 0;
202
+ list-style: none;
203
+ }
204
+
205
+ .index-item {
206
+ margin: 0.2rem 0;
207
+ padding-left: calc(var(--index-level, 0) * 1rem);
208
+ }
209
+
210
+ .index-item a {
211
+ color: var(--accent);
212
+ text-decoration: none;
213
+ }
214
+
215
+ .index-item a:hover {
216
+ text-decoration: underline;
217
+ }
218
+
51
219
  .context {
52
- font-family: "Fraunces", Georgia, serif;
220
+ font-family: "Saira", Georgia, serif;
53
221
  font-size: clamp(1.4rem, 2.8vw, 2rem);
54
222
  margin: 1.3rem 0 1rem;
55
223
  padding-bottom: 0.45rem;
@@ -67,7 +235,7 @@ module PrD
67
235
 
68
236
  .test-title {
69
237
  margin: 0 0 0.75rem;
70
- font-family: "Fraunces", Georgia, serif;
238
+ font-family: "Saira", Georgia, serif;
71
239
  font-size: 1.2rem;
72
240
  }
73
241
 
@@ -153,126 +321,54 @@ module PrD
153
321
  HTML
154
322
  end
155
323
 
156
- def context(message)
157
- return if synthetic?
158
- @io << "<h2 class=\"context\">#{escape(message)}</h2>"
159
- end
324
+ def render_index
325
+ return '' if @index_entries.empty?
160
326
 
161
- def success_result(message)
162
- if synthetic?
163
- @io << "<div class='status success'>PASS</div>"
164
- return
165
- end
166
- @io << "<div class='status success'>✓ #{escape(message)}</div>"
167
- end
327
+ index_items = @index_entries.map do |entry|
328
+ "<li class=\"index-item\" style=\"--index-level: #{entry[:level]};\"><a href=\"##{entry[:anchor_id]}\">#{escape(index_label(entry))}</a></li>"
329
+ end.join
168
330
 
169
- def failure_result(message)
170
- if synthetic?
171
- @io << "<div class='status failure'>FAIL</div>"
172
- return
173
- end
174
- @io << "<div class='status failure'>✗ #{escape(message)}</div>"
175
- end
176
-
177
- def it(description = nil, &block)
178
- @current_test_title = description.to_s
179
- @io << '<article class="test-card">'
180
- @io << "<h3 class=\"test-title\">#{escape(description)}</h3>"
181
- end
182
-
183
- def end_it(description = nil, &block)
184
- @io << '</article>'
185
- end
186
-
187
- def justification(justification)
188
- return if synthetic?
189
- @io << "<p class=\"line\"><strong>Justification:</strong> #{escape(justification)}</p>"
190
- end
191
-
192
- def let(value)
193
- end
194
-
195
- def subject(subject)
196
- return if synthetic?
197
- @io << '<div class="subject-block">'
198
- @io << "<p class=\"line\"><strong>Subject:</strong> #{escape(serialize(subject).to_s)}</p>"
199
- if image_file?(subject)
200
- @io << "<img src=\"#{image_data_uri(subject.path)}\" alt=\"Subject image\" class=\"subject-image\" />"
201
- elsif pdf_file?(subject)
202
- @io << "<embed src=\"#{pdf_data_uri(subject.path)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
203
- elsif pdf_reader?(subject)
204
- @io << "<embed src=\"#{pdf_reader_data_uri(subject)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
205
- end
206
- @io << '</div>'
207
- end
208
-
209
- def pending(description = nil)
210
- if synthetic?
211
- @io << '<article class="test-card">'
212
- @io << "<h3 class=\"test-title\">#{escape(description || 'Pending test')}</h3>"
213
- @io << "<div class='status pending'>PENDING</div>"
214
- @io << '</article>'
215
- return
216
- end
217
- @io << "<div class='status pending'>⚠ #{escape(description || 'Pending test')}</div>"
218
- @io << '<p class="line muted">This test is pending and has not been executed.</p>'
219
- end
220
-
221
- def expect(expectation)
222
- return if synthetic?
223
- @io << "<p class=\"line\"><strong>Expect:</strong> #{escape(serialize(expectation).to_s)}</p>"
331
+ <<~HTML
332
+ <nav class="report-index" aria-label="Report index">
333
+ <h2 class="index-title">Index</h2>
334
+ <ul class="index-list">
335
+ #{index_items}
336
+ </ul>
337
+ </nav>
338
+ HTML
224
339
  end
225
340
 
226
- def to
227
- return if synthetic?
228
- @io << '<p class="line"><strong>To:</strong></p>'
229
- end
341
+ def index_label(entry)
342
+ prefix =
343
+ case entry[:type]
344
+ when :context then 'Context'
345
+ when :pending then 'Pending'
346
+ else 'Test'
347
+ end
230
348
 
231
- def not_to
232
- return if synthetic?
233
- @io << '<p class="line"><strong>Not to:</strong></p>'
349
+ "#{prefix}: #{entry[:label]}"
234
350
  end
235
351
 
236
- def matcher(matcher, sources: nil)
237
- return if synthetic?
238
- case matcher
239
- when Matchers::EqMatcher
240
- @io << "<p class=\"line\"><strong>Matcher:</strong> Be equal to #{escape(serialize(matcher.expected).to_s)}</p>"
241
- when Matchers::BeMatcher
242
- @io << "<p class=\"line\"><strong>Matcher:</strong> Be the same object as #{escape(serialize(matcher.expected).to_s)}</p>"
243
- when Matchers::IncludesMatcher
244
- @io << "<p class=\"line\"><strong>Matcher:</strong> Include #{escape(serialize(matcher.expected).to_s)}</p>"
245
- when Matchers::HaveMatcher
246
- @io << "<p class=\"line\"><strong>Matcher:</strong> Have #{escape(serialize(matcher.expected).to_s)}</p>"
247
- when Matchers::LlmMatcher
248
- @io << "<p class=\"line\"><strong>Matcher:</strong> Satisfy condition #{escape(serialize(matcher.expected).to_s)}</p>"
249
- when Matchers::AllMatcher
250
- if sources
251
- code_line = matcher.expected.source_location.last.to_i
252
- code = sources.lines[code_line - 1]
253
- @io << "<p class=\"line\"><strong>Matcher:</strong> All match condition #{escape(code.strip)}</p>"
254
- else
255
- @io << '<p class="line"><strong>Matcher:</strong> All match the given condition</p>'
256
- end
257
- else
258
- @io << "<p class=\"line\"><strong>Matcher:</strong> #{escape(matcher.class.to_s)}</p>"
259
- end
352
+ def add_index_entry(type:, label:, level:, anchor_id:)
353
+ @index_entries << {
354
+ type:,
355
+ label: normalize_text(label),
356
+ level: [level, 0].max,
357
+ anchor_id:
358
+ }
260
359
  end
261
360
 
262
- def result(passed_count, failed_count)
263
- summary_class = failed_count > 0 ? 'failure' : 'success'
264
- @io << "<p class='result #{summary_class}'><strong>#{passed_count} passed, #{failed_count} failed</strong></p>"
361
+ def next_anchor_id(prefix)
362
+ @anchor_counters[prefix] += 1
363
+ "#{prefix}-#{@anchor_counters[prefix]}"
265
364
  end
266
365
 
267
- def flush
268
- @io << '</main></body></html>'
269
- super
366
+ def escape(message)
367
+ CGI.escape_html(normalize_text(message))
270
368
  end
271
369
 
272
- private
273
-
274
- def escape(message)
275
- CGI.escape_html(message.to_s)
370
+ def normalize_text(value)
371
+ value.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
276
372
  end
277
373
 
278
374
  def image_file?(value)
@@ -52,6 +52,11 @@ module PrD
52
52
  add_event(type: 'justification', message: justification)
53
53
  end
54
54
 
55
+ def let(value)
56
+ return if synthetic?
57
+ add_event(type: 'let', value: serialize(value))
58
+ end
59
+
55
60
  def pending(description = nil)
56
61
  if synthetic?
57
62
  add_event(type: 'test_result', title: description || 'Pending test', status: 'PENDING')
@@ -104,7 +109,8 @@ module PrD
104
109
  private
105
110
 
106
111
  def add_event(type:, **payload)
107
- @events << payload.merge(type:, level: @level)
112
+ event = payload.merge(type:, level: @level)
113
+ @events << normalize_for_json(event)
108
114
  end
109
115
 
110
116
  def serialize(value)
@@ -189,6 +195,27 @@ module PrD
189
195
  'application/octet-stream'
190
196
  end
191
197
  end
198
+
199
+ def normalize_for_json(value)
200
+ case value
201
+ when String
202
+ value.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
203
+ when Array
204
+ value.map { |item| normalize_for_json(item) }
205
+ when Hash
206
+ value.each_with_object({}) do |(key, item), normalized|
207
+ normalized[normalize_hash_key(key)] = normalize_for_json(item)
208
+ end
209
+ else
210
+ value
211
+ end
212
+ end
213
+
214
+ def normalize_hash_key(key)
215
+ return key.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?') if key.is_a?(String)
216
+
217
+ key
218
+ end
192
219
  end
193
220
  end
194
221
  end
@@ -18,6 +18,8 @@ module PrD
18
18
  super(io: io, serializers: serializers, mode: mode)
19
19
  @events = []
20
20
  @summary = { passed: 0, failed: 0 }
21
+ @index_entries = []
22
+ @anchor_counters = Hash.new(0)
21
23
  end
22
24
 
23
25
  def title(message)
@@ -26,8 +28,13 @@ module PrD
26
28
  end
27
29
 
28
30
  def context(message)
29
- return if synthetic?
30
- add_event(:context, message:, level: @level)
31
+ anchor_id = next_anchor_id('ctx')
32
+ add_index_entry(type: :context, label: message, level: @level, anchor_id:)
33
+ if synthetic?
34
+ add_event(:anchor_marker, message: '', level: @level, anchor_id:)
35
+ return
36
+ end
37
+ add_event(:context, message:, level: @level, anchor_id:)
31
38
  end
32
39
 
33
40
  def success_result(message)
@@ -48,7 +55,9 @@ module PrD
48
55
 
49
56
  def it(description = nil, &block)
50
57
  @current_test_title = description.to_s
51
- add_event(:it, message: description.to_s, level: @level)
58
+ anchor_id = next_anchor_id('test')
59
+ add_index_entry(type: :test, label: description.to_s, level: @level, anchor_id:)
60
+ add_event(:it, message: description.to_s, level: @level, anchor_id:)
52
61
  end
53
62
 
54
63
  def end_it(description = nil, &block)
@@ -74,7 +83,10 @@ module PrD
74
83
  end
75
84
 
76
85
  def pending(description = nil)
77
- add_event(:pending, message: (description || 'Pending test'), level: @level)
86
+ pending_label = description || 'Pending test'
87
+ anchor_id = next_anchor_id('pending')
88
+ add_index_entry(type: :pending, label: pending_label, level: @level, anchor_id:)
89
+ add_event(:pending, message: pending_label, level: @level, anchor_id:)
78
90
  return if synthetic?
79
91
  add_event(:detail, message: 'This test is pending and has not been executed.', level: @level + 1)
80
92
  end
@@ -122,6 +134,7 @@ module PrD
122
134
  def flush
123
135
  document = Prawn::Document.new(page_size: 'A4', margin: 42)
124
136
  render_header(document)
137
+ render_index(document)
125
138
  render_events(document)
126
139
  render_summary(document)
127
140
 
@@ -132,8 +145,22 @@ module PrD
132
145
 
133
146
  private
134
147
 
135
- def add_event(type, message:, level:)
136
- @events << { type:, message: safe_pdf_text(message.to_s), level: [level, 0].max }
148
+ def add_event(type, message:, level:, anchor_id: nil)
149
+ @events << {
150
+ type:,
151
+ message: safe_pdf_text(message.to_s),
152
+ level: [level, 0].max,
153
+ anchor_id:
154
+ }
155
+ end
156
+
157
+ def add_index_entry(type:, label:, level:, anchor_id:)
158
+ @index_entries << {
159
+ type:,
160
+ label: safe_pdf_text(label.to_s),
161
+ level: [level, 0].max,
162
+ anchor_id:
163
+ }
137
164
  end
138
165
 
139
166
  def safe_pdf_text(text)
@@ -153,8 +180,40 @@ module PrD
153
180
  document.move_down 10
154
181
  end
155
182
 
183
+ def render_index(document)
184
+ return if @index_entries.empty?
185
+
186
+ document.fill_color COLORS[:title]
187
+ document.text 'Index', size: 14, style: :bold
188
+ document.move_down 4
189
+
190
+ @index_entries.each do |entry|
191
+ document.indent(entry[:level] * 12) do
192
+ document.formatted_text(
193
+ [
194
+ {
195
+ text: "- #{index_label(entry)}",
196
+ anchor: entry[:anchor_id],
197
+ styles: [:underline],
198
+ color: COLORS[:context]
199
+ }
200
+ ],
201
+ size: 10
202
+ )
203
+ end
204
+ document.move_down 1
205
+ end
206
+
207
+ document.move_down 8
208
+ document.stroke_color COLORS[:border]
209
+ document.stroke_horizontal_rule
210
+ document.stroke_color '000000'
211
+ document.move_down 8
212
+ end
213
+
156
214
  def render_events(document)
157
215
  @events.each do |event|
216
+ register_anchor(document, event)
158
217
  case event[:type]
159
218
  when :title, :context
160
219
  styled_line(document, event[:message], level: event[:level], size: 14, style: :bold, color: COLORS[:context], spacing: 6)
@@ -172,6 +231,8 @@ module PrD
172
231
  styled_line(document, event[:message], level: event[:level], size: 10, color: COLORS[:text])
173
232
  when :subject_image
174
233
  render_image(document, event[:message], level: event[:level])
234
+ when :anchor_marker
235
+ next
175
236
  when :result
176
237
  document.move_down 8
177
238
  styled_line(document, event[:message], level: event[:level], size: 11, style: :bold, color: COLORS[:title])
@@ -179,6 +240,13 @@ module PrD
179
240
  end
180
241
  end
181
242
 
243
+ def register_anchor(document, event)
244
+ anchor_id = event[:anchor_id]
245
+ return if anchor_id.nil?
246
+
247
+ document.add_dest(anchor_id, document.dest_xyz(0, document.cursor))
248
+ end
249
+
182
250
  def render_summary(document)
183
251
  document.move_down 10
184
252
  document.stroke_color COLORS[:border]
@@ -216,6 +284,22 @@ module PrD
216
284
  document.move_down 2
217
285
  end
218
286
 
287
+ def index_label(entry)
288
+ prefix =
289
+ case entry[:type]
290
+ when :context then 'Context'
291
+ when :pending then 'Pending'
292
+ else 'Test'
293
+ end
294
+
295
+ "#{prefix}: #{entry[:label]}"
296
+ end
297
+
298
+ def next_anchor_id(prefix)
299
+ @anchor_counters[prefix] += 1
300
+ "#{prefix}-#{@anchor_counters[prefix]}"
301
+ end
302
+
219
303
  def image_file?(value)
220
304
  value.is_a?(File) && value.path.match?(/\.(png|jpe?g)\z/i)
221
305
  end
data/lib/pr_d/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PrD
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/pr_d.rb CHANGED
@@ -69,6 +69,8 @@ module PrD
69
69
  end
70
70
  end
71
71
 
72
+ attr_reader :passed_count, :failed_count
73
+
72
74
  def describe(description, model: nil, &block)
73
75
  context(description, model: model, &block)
74
76
  @formatter.result(@passed_count, @failed_count)
@@ -185,6 +187,10 @@ module PrD
185
187
  @formatter.flush
186
188
  end
187
189
 
190
+ def success?
191
+ @failed_count.zero?
192
+ end
193
+
188
194
  private
189
195
 
190
196
  def formatted_time
@@ -10,13 +10,13 @@ Gem::Specification.new do |spec|
10
10
 
11
11
  spec.summary = "A Ruby DSL testing framework with classic and LLM-powered matchers."
12
12
  spec.description = "Probatio Diabolica runs custom *_spec.rb files with a DSL inspired by RSpec and supports text/image/PDF reporting."
13
- spec.homepage = "https://github.com/syadem/probatio_diabolica"
13
+ spec.homepage = "https://github.com/mathieulaporte/probatio_diabolica"
14
14
  spec.license = "MIT"
15
15
  spec.required_ruby_version = ">= 3.1"
16
16
 
17
17
  spec.metadata = {
18
- "source_code_uri" => "https://github.com/syadem/probatio_diabolica",
19
- "changelog_uri" => "https://github.com/syadem/probatio_diabolica/releases"
18
+ "source_code_uri" => "https://github.com/mathieulaporte/probatio_diabolica",
19
+ "changelog_uri" => "https://github.com/mathieulaporte/probatio_diabolica/releases"
20
20
  }
21
21
 
22
22
  spec.files = Dir[
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: probatio_diabolica
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Laporte Mathieu
@@ -111,12 +111,12 @@ files:
111
111
  - lib/pr_d/version.rb
112
112
  - lib/probatio_diabolica.rb
113
113
  - probatio_diabolica.gemspec
114
- homepage: https://github.com/syadem/probatio_diabolica
114
+ homepage: https://github.com/mathieulaporte/probatio_diabolica
115
115
  licenses:
116
116
  - MIT
117
117
  metadata:
118
- source_code_uri: https://github.com/syadem/probatio_diabolica
119
- changelog_uri: https://github.com/syadem/probatio_diabolica/releases
118
+ source_code_uri: https://github.com/mathieulaporte/probatio_diabolica
119
+ changelog_uri: https://github.com/mathieulaporte/probatio_diabolica/releases
120
120
  rdoc_options: []
121
121
  require_paths:
122
122
  - lib