GCC Code Coverage Report


Directory: include/tts/
File: include/tts/sinks/json.hpp
Date: 2026-10-14 16:28:14
Exec Total Coverage
Lines: 60 72 83.3%
Functions: 8 9 88.9%
Branches: 21 39 53.8%

Line Branch Exec Source
1 //======================================================================================================================
2 //! @file
3 /*
4 TTS - Tiny Test System
5 Copyright : TTS Contributors & Maintainers
6 SPDX-License-Identifier: BSL-1.0
7 */
8 //======================================================================================================================
9 #pragma once
10
11 #include <tts/tools/output.hpp>
12
13 TTS_DISABLE_WARNING_PUSH
14 TTS_DISABLE_WARNING_CRT_SECURE
15
16 namespace tts::_
17 {
18 // Test names and failure messages are arbitrary user text - this is what keeps them valid JSON.
19 4 inline ::tts::text json_escape(::tts::text const& t)
20 {
21 4 ::tts::text out;
22
2/2
✓ Branch 2 taken 101 times.
✓ Branch 3 taken 4 times.
105 for(char c: t)
23 {
24
1/6
✗ Branch 0 not taken.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 101 times.
101 switch(c)
25 {
26 ✗ case '"': out += R"(\")"; break;
27 ✗ case '\\': out += R"(\\)"; break;
28 ✗ case '\n': out += "\\n"; break;
29 ✗ case '\r': out += "\\r"; break;
30 ✗ case '\t': out += "\\t"; break;
31 101 default:
32
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 101 times.
101 if(static_cast<unsigned char>(c) < 0x20)
33 ✗ out += ::tts::text {"\\u%04x", static_cast<unsigned>(static_cast<unsigned char>(c))};
34
1/1
✓ Branch 2 taken 101 times.
101 else out += ::tts::text {"%c", c};
35 101 break;
36 }
37 }
38 4 return out;
39 ✗ }
40 }
41
42 namespace tts
43 {
44 //====================================================================================================================
45 /**
46 @ingroup tools-sinks
47 @public
48 @brief output_sink rendering the run as a single structured JSON document.
49
50 Built from @ref output_sink's structured hooks rather than by parsing the human-readable text
51 @ref tts::stdout_sink prints, so it stays correct regardless of `-v`/`-q`. Every @ref TTS_CASE
52 becomes one entry in a `"tests"` array (name, status, duration in nanoseconds, and any
53 failing/fatal assertions gathered while it ran), summarized by a `"summary"` object counting
54 passed/failed/invalid cases - counted from `test_finished()` directly, not from the suite's
55 raw assertion totals, so `tests.length` always matches `summary.total`. Rendered by `dump()`,
56 or automatically via `finish()`, to the target given at construction
57 (`tts::output_handler::default_sink()` by default). It does not (currently) reflect
58 @ref TTS_CASE_TPL's per-type breakdown or @ref TTS_WHEN / @ref TTS_AND_THEN sub-scenarios,
59 since those don't have their own hook - only the enclosing @ref TTS_CASE does.
60
61 @groupheader{Example}
62 @code
63 tts::json_sink json;
64 tts::output().sink(json);
65
66 // ... run the test suite ...
67
68 json.dump(); // stream the JSON report to stdout
69 @endcode
70 **/
71 //====================================================================================================================
72 struct json_sink : output_sink
73 {
74 100 explicit json_sink(output_sink& target = output_handler::default_sink())
75 100 : target_(&target)
76 {
77 100 }
78
79 10 void write(text const&) override
80 {
81 // Intentionally empty: the JSON report is built entirely from the structured hooks below.
82 10 }
83
84 1 void assertion_failed(text const& location, text const& message, bool fatal) override
85 {
86 // location is "[file:line]" - strip the brackets, split so line is a real integer.
87 1 char const* loc = location.data();
88 1 std::size_t len = strlen(loc); // NOSONAR - loc always comes from a well-formed tts::text
89 1 auto stripped = text {"%.*s", static_cast<int>(len - 2), loc + 1};
90
91 1 char const* colon = strrchr(stripped.data(), ':'); // NOSONAR - stripped always has one
92 text file =
93
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 colon ? text {"%.*s", static_cast<int>(colon - stripped.data()), stripped.data()} : stripped;
94 1 int line = 0;
95
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if(colon) sscanf(colon + 1, "%d", &line); // NOSONAR - colon+1 is always digits here
96
97
1/3
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 4 not taken.
1 if(!current_failures_.is_empty()) current_failures_ += ",";
98 1 current_failures_ += text {R"({"location":{"file":"%s","line":%d},"message":"%s",)"
99 R"("fatal":%s})",
100
1/1
✓ Branch 1 taken 1 times.
1 _::json_escape(file).data(),
101 line,
102 1 _::json_escape(message).data(),
103
3/4
✓ Branch 2 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✓ Branch 9 taken 1 times.
1 fatal ? "true" : "false"};
104 1 }
105
106 void
107 2 test_finished(text const& name, bool passed, bool invalid, nanoseconds duration_ns) override
108 {
109 2 char const* status = "failed";
110
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
2 if(invalid)
111 {
112 ✗ status = "invalid";
113 ✗ ++invalid_count_;
114 }
115
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 else if(passed)
116 {
117 1 status = "passed";
118 1 ++passed_count_;
119 }
120 1 else ++failed_count_;
121 2 total_duration_ns_ += duration_ns;
122
123
2/2
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 1 times.
2 if(!body_.is_empty()) body_ += ",";
124 2 body_ += text {R"({"name":"%s","status":"%s","duration_ns":%llu,"failures":[%s]})",
125
1/1
✓ Branch 1 taken 2 times.
4 _::json_escape(name).data(),
126 status,
127 duration_ns,
128
1/1
✓ Branch 4 taken 2 times.
2 current_failures_.data()};
129
130 2 current_failures_ = text {};
131 2 }
132
133 /// Renders everything gathered so far as a single JSON document.
134 1 text render() const
135 {
136 1 counter total = passed_count_ + failed_count_ + invalid_count_;
137 return text {R"({"tests":[%s],"summary":{"total":%llu,"passed":%llu,"failed":%llu,)"
138 R"("invalid":%llu,"duration_ns":%llu}})",
139 body_.data(),
140 total,
141 1 passed_count_,
142 1 failed_count_,
143 1 invalid_count_,
144 1 total_duration_ns_};
145 }
146
147 /// Forwards the JSON-rendered report to target, then clears the gathered results.
148 1 void dump(output_sink& target)
149 {
150
2/2
✓ Branch 1 taken 1 times.
✓ Branch 4 taken 1 times.
1 target.write(render());
151 1 clear();
152 1 }
153
154 /// Streams the JSON-rendered report to `stdout`, then clears the gathered results.
155 void dump()
156 {
157 stdout_sink target;
158 dump(target);
159 }
160
161 /// Discards everything gathered so far.
162 1 void clear()
163 {
164 1 body_ = text {};
165 1 current_failures_ = text {};
166 1 passed_count_ = 0;
167 1 failed_count_ = 0;
168 1 invalid_count_ = 0;
169 1 total_duration_ns_ = 0;
170 1 }
171
172 /// Dumps the JSON-rendered report to the target given at construction.
173 ✗ void finish() override
174 {
175 ✗ dump(*target_);
176 ✗ }
177
178 private:
179 output_sink* target_;
180 text body_;
181 text current_failures_;
182 counter passed_count_ = 0;
183 counter failed_count_ = 0;
184 counter invalid_count_ = 0;
185 nanoseconds total_duration_ns_ = 0;
186 };
187 }
188
189 TTS_DISABLE_WARNING_POP
190