Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorLeitwolf112026-09-24 23:05:47 +0300
committerLeitwolf112026-09-24 23:05:47 +0300
commitd79b294903e4d0f4b24fdf1cc50be4b0fec7f745 (patch)
tree64f0c7c3dfbc08bfb70d9a37df5cccae3d52b526 /src
parent9ad804cedf4cc4a85ef89fe097f97a3b44654360 (diff)
downloadcatch3-0.2.0.tar.gz
Add snapshots and JUnit result serialization0.2.0
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt16
-rw-r--r--src/Progmasoft/BUILD.bazel12
-rw-r--r--src/Progmasoft/Catch3.hpp3
-rw-r--r--src/Progmasoft/Catch3/Assertions.hpp10
-rw-r--r--src/Progmasoft/Catch3/Results.hpp57
-rw-r--r--src/Progmasoft/Catch3/Snapshot.hpp55
-rw-r--r--src/Progmasoft/Catch3/XmlWriter.hpp20
-rw-r--r--src/Progmasoft/Results.cpp73
-rw-r--r--src/Progmasoft/Snapshot.cpp265
-rw-r--r--src/Progmasoft/XmlWriter.cpp274
-rw-r--r--src/Progmasoft/meson.build15
11 files changed, 793 insertions, 7 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index cb8c339d..5eec18e9 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -360,18 +360,22 @@ if(CATCH_ENABLE_REPRODUCIBLE_BUILD)
endif()
add_library(Catch2::Catch2 ALIAS Catch2)
-# Progmasoft's C++20 additions are a separate target so the Catch2
-# compatibility engine can keep its existing, broader C++14 baseline.
-add_library(ProgmasoftCatch3 INTERFACE)
+# Progmasoft's compiled C++20 utilities are separate from Catch2's
+# compatibility engine, which retains its existing C++14 baseline.
+add_library(ProgmasoftCatch3 STATIC
+ "${CATCH_DIR}/src/Progmasoft/Results.cpp"
+ "${CATCH_DIR}/src/Progmasoft/Snapshot.cpp"
+ "${CATCH_DIR}/src/Progmasoft/XmlWriter.cpp"
+)
add_library(Progmasoft::Catch3 ALIAS ProgmasoftCatch3)
set_target_properties(ProgmasoftCatch3 PROPERTIES EXPORT_NAME Catch3)
-target_link_libraries(ProgmasoftCatch3 INTERFACE Catch2::Catch2)
+target_link_libraries(ProgmasoftCatch3 PUBLIC Catch2::Catch2)
target_include_directories(ProgmasoftCatch3
- INTERFACE
+ PUBLIC
$<BUILD_INTERFACE:${CATCH_DIR}/src>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
)
-target_compile_features(ProgmasoftCatch3 INTERFACE cxx_std_20)
+target_compile_features(ProgmasoftCatch3 PUBLIC cxx_std_20)
if(ANDROID)
target_link_libraries(Catch2 PRIVATE log)
diff --git a/src/Progmasoft/BUILD.bazel b/src/Progmasoft/BUILD.bazel
index 9927aafa..642bc158 100644
--- a/src/Progmasoft/BUILD.bazel
+++ b/src/Progmasoft/BUILD.bazel
@@ -6,6 +6,11 @@ package(default_visibility = ["//visibility:public"])
cc_library(
name = "catch3",
+ srcs = [
+ "Results.cpp",
+ "Snapshot.cpp",
+ "XmlWriter.cpp",
+ ],
hdrs = [
"Catch3.hpp",
"Catch3/Assertions.hpp",
@@ -14,7 +19,14 @@ cc_library(
"Catch3/Generators/Integer.hpp",
"Catch3/Property.hpp",
"Catch3/Random.hpp",
+ "Catch3/Results.hpp",
+ "Catch3/Snapshot.hpp",
+ "Catch3/XmlWriter.hpp",
],
+ copts = select({
+ "@platforms//os:windows": ["/std:c++20"],
+ "//conditions:default": ["-std=c++20"],
+ }),
strip_include_prefix = "/src",
deps = ["//:catch2"],
)
diff --git a/src/Progmasoft/Catch3.hpp b/src/Progmasoft/Catch3.hpp
index 1ae73e53..d2d34e74 100644
--- a/src/Progmasoft/Catch3.hpp
+++ b/src/Progmasoft/Catch3.hpp
@@ -11,3 +11,6 @@
#include <Progmasoft/Catch3/Generators/Integer.hpp>
#include <Progmasoft/Catch3/Property.hpp>
#include <Progmasoft/Catch3/Random.hpp>
+#include <Progmasoft/Catch3/Results.hpp>
+#include <Progmasoft/Catch3/Snapshot.hpp>
+#include <Progmasoft/Catch3/XmlWriter.hpp>
diff --git a/src/Progmasoft/Catch3/Assertions.hpp b/src/Progmasoft/Catch3/Assertions.hpp
index 19ac13ef..668c086d 100644
--- a/src/Progmasoft/Catch3/Assertions.hpp
+++ b/src/Progmasoft/Catch3/Assertions.hpp
@@ -6,6 +6,7 @@
#include <catch2/catch_test_macros.hpp>
#include <Progmasoft/Catch3/Property.hpp>
+#include <Progmasoft/Catch3/Snapshot.hpp>
/// Runs a property and reports its replay seed and minimized counterexample.
#define CATCH3_CHECK_PROPERTY( ... ) \
@@ -15,3 +16,12 @@
INFO( catch3PropertyResult.Describe() ); \
CHECK( catch3PropertyResult.Succeeded() ); \
} while ( false )
+
+/// Compares a value with a named snapshot and reports the first difference.
+#define CATCH3_CHECK_SNAPSHOT( name, actual, options ) \
+ do { \
+ const auto catch3SnapshotResult = \
+ ::Progmasoft::Catch3::CompareSnapshot( name, actual, options ); \
+ INFO( catch3SnapshotResult.Message ); \
+ CHECK( catch3SnapshotResult.Succeeded() ); \
+ } while ( false )
diff --git a/src/Progmasoft/Catch3/Results.hpp b/src/Progmasoft/Catch3/Results.hpp
new file mode 100644
index 00000000..b0b69b55
--- /dev/null
+++ b/src/Progmasoft/Catch3/Results.hpp
@@ -0,0 +1,57 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#pragma once
+
+#include <cstddef>
+#include <string>
+#include <vector>
+
+namespace Progmasoft::Catch3 {
+
+ enum class TestCaseStatus : unsigned char {
+ kPassed,
+ kFailed,
+ kErrored,
+ kSkipped,
+ };
+
+ /// Framework-neutral result for one completed test case.
+ struct TestCaseResult final {
+ std::string Name;
+ std::string ClassName;
+ std::string SourceFile;
+ TestCaseStatus Status = TestCaseStatus::kPassed;
+ double DurationSeconds = 0.0;
+ std::string Message;
+ std::string Details;
+ std::string StandardOutput;
+ std::string StandardError;
+ };
+
+ struct TestSuiteResult final {
+ std::string Name;
+ std::vector<TestCaseResult> TestCases;
+ };
+
+ struct TestRunResult final {
+ std::string Name;
+ std::vector<TestSuiteResult> TestSuites;
+ };
+
+ struct TestRunSummary final {
+ std::size_t Tests = 0;
+ std::size_t Failures = 0;
+ std::size_t Errors = 0;
+ std::size_t Skipped = 0;
+ double DurationSeconds = 0.0;
+
+ [[nodiscard]] std::size_t Passed() const noexcept;
+ };
+
+ /// Validates durations and statuses while aggregating nested test results.
+ [[nodiscard]] TestRunSummary
+ SummarizeTestSuite( const TestSuiteResult& result );
+ [[nodiscard]] TestRunSummary
+ SummarizeTestRun( const TestRunResult& result );
+
+} // namespace Progmasoft::Catch3
diff --git a/src/Progmasoft/Catch3/Snapshot.hpp b/src/Progmasoft/Catch3/Snapshot.hpp
new file mode 100644
index 00000000..09863c1c
--- /dev/null
+++ b/src/Progmasoft/Catch3/Snapshot.hpp
@@ -0,0 +1,55 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#pragma once
+
+#include <cstddef>
+#include <filesystem>
+#include <string>
+#include <string_view>
+
+namespace Progmasoft::Catch3 {
+
+ /// Updates are deliberately explicit so normal test runs never rewrite
+ /// files.
+ enum class SnapshotUpdateMode : unsigned char {
+ kNever,
+ kCreateMissing,
+ kAlways,
+ };
+
+ enum class SnapshotStatus : unsigned char {
+ kMatched,
+ kCreated,
+ kUpdated,
+ kMismatched,
+ kInvalidName,
+ kInvalidOptions,
+ kIoError,
+ };
+
+ struct SnapshotOptions final {
+ std::filesystem::path Directory;
+ SnapshotUpdateMode UpdateMode = SnapshotUpdateMode::kNever;
+ bool NormalizeLineEndings = true;
+ };
+
+ /// Structured comparison result; DifferenceLine/Column are one-based.
+ struct SnapshotResult final {
+ SnapshotStatus Status = SnapshotStatus::kInvalidOptions;
+ std::filesystem::path File;
+ std::string Expected;
+ std::string Actual;
+ std::string Message;
+ std::size_t DifferenceLine = 0;
+ std::size_t DifferenceColumn = 0;
+
+ [[nodiscard]] bool Succeeded() const noexcept;
+ };
+
+ /// Compare against `<Directory>/<name>.snap`; names cannot contain paths.
+ [[nodiscard]] SnapshotResult
+ CompareSnapshot( std::string_view name,
+ std::string_view actual,
+ const SnapshotOptions& options );
+
+} // namespace Progmasoft::Catch3
diff --git a/src/Progmasoft/Catch3/XmlWriter.hpp b/src/Progmasoft/Catch3/XmlWriter.hpp
new file mode 100644
index 00000000..3216b5ae
--- /dev/null
+++ b/src/Progmasoft/Catch3/XmlWriter.hpp
@@ -0,0 +1,20 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#pragma once
+
+#include <iosfwd>
+#include <string>
+
+namespace Progmasoft::Catch3 {
+
+ struct TestRunResult;
+
+ /// Emits deterministic JUnit-style XML without timestamps or global state.
+ class JUnitXmlWriter final {
+ public:
+ static void Write( const TestRunResult& result, std::ostream& output );
+ [[nodiscard]] static std::string
+ ToString( const TestRunResult& result );
+ };
+
+} // namespace Progmasoft::Catch3
diff --git a/src/Progmasoft/Results.cpp b/src/Progmasoft/Results.cpp
new file mode 100644
index 00000000..69a819a7
--- /dev/null
+++ b/src/Progmasoft/Results.cpp
@@ -0,0 +1,73 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#include <Progmasoft/Catch3/Results.hpp>
+
+#include <cmath>
+#include <limits>
+#include <stdexcept>
+
+namespace Progmasoft::Catch3 {
+
+ namespace {
+
+ void AccumulateCases( TestRunSummary& summary,
+ const std::vector<TestCaseResult>& testCases ) {
+ for ( const TestCaseResult& testCase : testCases ) {
+ if ( !std::isfinite( testCase.DurationSeconds ) ||
+ testCase.DurationSeconds < 0.0 ) {
+ throw std::invalid_argument(
+ "test duration must be finite and non-negative" );
+ }
+ if ( summary.Tests ==
+ std::numeric_limits<std::size_t>::max() ) {
+ throw std::overflow_error( "test count overflow" );
+ }
+
+ ++summary.Tests;
+ summary.DurationSeconds += testCase.DurationSeconds;
+ if ( !std::isfinite( summary.DurationSeconds ) ) {
+ throw std::overflow_error( "test duration total overflow" );
+ }
+
+ switch ( testCase.Status ) {
+ case TestCaseStatus::kPassed:
+ break;
+ case TestCaseStatus::kFailed:
+ ++summary.Failures;
+ break;
+ case TestCaseStatus::kErrored:
+ ++summary.Errors;
+ break;
+ case TestCaseStatus::kSkipped:
+ ++summary.Skipped;
+ break;
+ default:
+ throw std::invalid_argument( "unknown test case status" );
+ }
+ }
+ }
+
+ } // namespace
+
+ std::size_t TestRunSummary::Passed() const noexcept {
+ if ( Failures > Tests || Errors > Tests - Failures ) { return 0; }
+ const std::size_t failuresAndErrors = Failures + Errors;
+ if ( Skipped > Tests - failuresAndErrors ) { return 0; }
+ return Tests - failuresAndErrors - Skipped;
+ }
+
+ TestRunSummary SummarizeTestSuite( const TestSuiteResult& result ) {
+ TestRunSummary summary;
+ AccumulateCases( summary, result.TestCases );
+ return summary;
+ }
+
+ TestRunSummary SummarizeTestRun( const TestRunResult& result ) {
+ TestRunSummary summary;
+ for ( const TestSuiteResult& suite : result.TestSuites ) {
+ AccumulateCases( summary, suite.TestCases );
+ }
+ return summary;
+ }
+
+} // namespace Progmasoft::Catch3
diff --git a/src/Progmasoft/Snapshot.cpp b/src/Progmasoft/Snapshot.cpp
new file mode 100644
index 00000000..7278eee3
--- /dev/null
+++ b/src/Progmasoft/Snapshot.cpp
@@ -0,0 +1,265 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#include <Progmasoft/Catch3/Snapshot.hpp>
+
+#include <atomic>
+#include <chrono>
+#include <cstdint>
+#include <fstream>
+#include <iterator>
+#include <system_error>
+
+#if defined( _WIN32 )
+# if !defined( NOMINMAX )
+# define NOMINMAX
+# endif
+# include <Windows.h>
+#endif
+#include <utility>
+
+namespace Progmasoft::Catch3 {
+
+ namespace {
+
+ bool IsSafeSnapshotName( std::string_view name ) noexcept {
+ if ( name.empty() || name == "." || name == ".." ) { return false; }
+ for ( const unsigned char character : name ) {
+ const bool isLetter =
+ ( character >= 'a' && character <= 'z' ) ||
+ ( character >= 'A' && character <= 'Z' );
+ const bool isDigit = character >= '0' && character <= '9';
+ if ( !isLetter && !isDigit && character != '_' &&
+ character != '-' && character != '.' ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ std::string NormalizeSnapshotText( std::string_view text,
+ bool normalizeLineEndings ) {
+ if ( !normalizeLineEndings ) { return std::string( text ); }
+
+ std::string normalized;
+ normalized.reserve( text.size() );
+ for ( std::size_t index = 0; index < text.size(); ++index ) {
+ if ( text[index] == '\r' ) {
+ if ( index + 1 < text.size() && text[index + 1] == '\n' ) {
+ ++index;
+ }
+ normalized.push_back( '\n' );
+ } else {
+ normalized.push_back( text[index] );
+ }
+ }
+ return normalized;
+ }
+
+ void LocateDifference( SnapshotResult& result ) noexcept {
+ const std::size_t commonLength =
+ result.Expected.size() < result.Actual.size()
+ ? result.Expected.size()
+ : result.Actual.size();
+ std::size_t index = 0;
+ while ( index < commonLength &&
+ result.Expected[index] == result.Actual[index] ) {
+ ++index;
+ }
+
+ result.DifferenceLine = 1;
+ result.DifferenceColumn = 1;
+ for ( std::size_t offset = 0; offset < index; ++offset ) {
+ if ( result.Actual[offset] == '\n' ) {
+ ++result.DifferenceLine;
+ result.DifferenceColumn = 1;
+ } else {
+ ++result.DifferenceColumn;
+ }
+ }
+ }
+
+ bool WriteAtomically( const std::filesystem::path& destination,
+ std::string_view contents,
+ bool replaceExisting,
+ std::error_code& error ) {
+ // Write beside the destination so replacement stays on one volume
+ // and readers never observe a partially written snapshot.
+ static std::atomic<std::uint64_t> nextTemporaryFile{ 0 };
+ const auto clockValue =
+ std::chrono::steady_clock::now().time_since_epoch().count();
+ const auto sequence =
+ nextTemporaryFile.fetch_add( 1, std::memory_order_relaxed );
+ std::filesystem::path temporary = destination;
+ temporary += ".tmp." + std::to_string( clockValue ) + "." +
+ std::to_string( sequence );
+
+ {
+ std::ofstream output( temporary,
+ std::ios::binary | std::ios::trunc );
+ if ( !output ) {
+ error = std::make_error_code( std::errc::io_error );
+ return false;
+ }
+ output.write( contents.data(),
+ static_cast<std::streamsize>( contents.size() ) );
+ output.flush();
+ if ( !output ) {
+ error = std::make_error_code( std::errc::io_error );
+ output.close();
+ std::error_code ignored;
+ std::filesystem::remove( temporary, ignored );
+ return false;
+ }
+ }
+
+ if ( replaceExisting ) {
+#if defined( _WIN32 )
+ if ( MoveFileExW( temporary.c_str(),
+ destination.c_str(),
+ MOVEFILE_REPLACE_EXISTING |
+ MOVEFILE_WRITE_THROUGH ) == 0 ) {
+ error = std::error_code( static_cast<int>( GetLastError() ),
+ std::system_category() );
+ }
+#else
+ std::filesystem::rename( temporary, destination, error );
+#endif
+ } else {
+ // A hard-link create is atomic and refuses a concurrent file.
+ std::filesystem::create_hard_link(
+ temporary, destination, error );
+ std::error_code ignored;
+ std::filesystem::remove( temporary, ignored );
+ }
+ if ( error ) {
+ std::error_code ignored;
+ std::filesystem::remove( temporary, ignored );
+ return false;
+ }
+ return true;
+ }
+
+ } // namespace
+
+ bool SnapshotResult::Succeeded() const noexcept {
+ return Status == SnapshotStatus::kMatched ||
+ Status == SnapshotStatus::kCreated ||
+ Status == SnapshotStatus::kUpdated;
+ }
+
+ SnapshotResult CompareSnapshot( std::string_view name,
+ std::string_view actual,
+ const SnapshotOptions& options ) {
+ SnapshotResult result;
+ if ( !IsSafeSnapshotName( name ) ) {
+ result.Status = SnapshotStatus::kInvalidName;
+ result.Message =
+ "snapshot name must be a non-empty ASCII filename component";
+ return result;
+ }
+ if ( options.Directory.empty() ||
+ ( options.UpdateMode != SnapshotUpdateMode::kNever &&
+ options.UpdateMode != SnapshotUpdateMode::kCreateMissing &&
+ options.UpdateMode != SnapshotUpdateMode::kAlways ) ) {
+ result.Status = SnapshotStatus::kInvalidOptions;
+ result.Message = "snapshot directory or update mode is invalid";
+ return result;
+ }
+
+ result.File = options.Directory / ( std::string( name ) + ".snap" );
+ result.Actual =
+ NormalizeSnapshotText( actual, options.NormalizeLineEndings );
+
+ std::error_code error;
+ const std::filesystem::file_status fileStatus =
+ std::filesystem::symlink_status( result.File, error );
+ if ( error && error != std::errc::no_such_file_or_directory ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message =
+ "could not inspect snapshot file: " + error.message();
+ return result;
+ }
+
+ const bool exists = !error && fileStatus.type() !=
+ std::filesystem::file_type::not_found;
+ if ( exists && std::filesystem::is_symlink( fileStatus ) ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message = "snapshot files must not be symbolic links";
+ return result;
+ }
+ if ( exists && !std::filesystem::is_regular_file( fileStatus ) ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message = "snapshot path is not a regular file";
+ return result;
+ }
+
+ if ( !exists ) {
+ if ( options.UpdateMode == SnapshotUpdateMode::kNever ) {
+ result.Status = SnapshotStatus::kMismatched;
+ result.Message = "snapshot file does not exist";
+ LocateDifference( result );
+ return result;
+ }
+
+ std::filesystem::create_directories( options.Directory, error );
+ if ( error || !WriteAtomically(
+ result.File, result.Actual, false, error ) ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message =
+ "could not create snapshot file: " + error.message();
+ return result;
+ }
+ result.Status = SnapshotStatus::kCreated;
+ result.Message = "snapshot created";
+ return result;
+ }
+
+ std::ifstream input( result.File, std::ios::binary );
+ if ( !input ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message = "could not open snapshot file for reading";
+ return result;
+ }
+ const std::string fileContents{ std::istreambuf_iterator<char>( input ),
+ std::istreambuf_iterator<char>() };
+ if ( input.bad() ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message = "could not read snapshot file";
+ return result;
+ }
+ input.close();
+ if ( input.fail() ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message = "could not close snapshot file after reading";
+ return result;
+ }
+ result.Expected =
+ NormalizeSnapshotText( fileContents, options.NormalizeLineEndings );
+
+ if ( result.Expected == result.Actual ) {
+ result.Status = SnapshotStatus::kMatched;
+ result.Message = "snapshot matched";
+ return result;
+ }
+
+ LocateDifference( result );
+ if ( options.UpdateMode == SnapshotUpdateMode::kAlways ) {
+ if ( !WriteAtomically( result.File, result.Actual, true, error ) ) {
+ result.Status = SnapshotStatus::kIoError;
+ result.Message =
+ "could not update snapshot file: " + error.message();
+ return result;
+ }
+ result.Status = SnapshotStatus::kUpdated;
+ result.Message = "snapshot updated";
+ return result;
+ }
+
+ result.Status = SnapshotStatus::kMismatched;
+ result.Message = "snapshot differs at line " +
+ std::to_string( result.DifferenceLine ) + ", column " +
+ std::to_string( result.DifferenceColumn );
+ return result;
+ }
+
+} // namespace Progmasoft::Catch3
diff --git a/src/Progmasoft/XmlWriter.cpp b/src/Progmasoft/XmlWriter.cpp
new file mode 100644
index 00000000..b3a5f9b1
--- /dev/null
+++ b/src/Progmasoft/XmlWriter.cpp
@@ -0,0 +1,274 @@
+// Copyright (c) 2026 Progmasoft.
+// SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1
+#include <Progmasoft/Catch3/Results.hpp>
+#include <Progmasoft/Catch3/XmlWriter.hpp>
+
+#include <cmath>
+#include <iomanip>
+#include <locale>
+#include <ostream>
+#include <sstream>
+#include <stdexcept>
+#include <string_view>
+
+namespace Progmasoft::Catch3 {
+
+ namespace {
+
+ struct DecodedCharacter final {
+ char32_t Value = 0;
+ std::size_t Length = 0;
+ };
+
+ DecodedCharacter DecodeUtf8( std::string_view text,
+ std::size_t offset ) noexcept {
+ const auto first = static_cast<unsigned char>( text[offset] );
+ if ( first < 0x80 ) { return { first, 1 }; }
+
+ std::size_t length = 0;
+ char32_t value = 0;
+ char32_t minimum = 0;
+ if ( first >= 0xC2 && first <= 0xDF ) {
+ length = 2;
+ value = first & 0x1F;
+ minimum = 0x80;
+ } else if ( first >= 0xE0 && first <= 0xEF ) {
+ length = 3;
+ value = first & 0x0F;
+ minimum = 0x800;
+ } else if ( first >= 0xF0 && first <= 0xF4 ) {
+ length = 4;
+ value = first & 0x07;
+ minimum = 0x10000;
+ } else {
+ return { 0xFFFD, 0 };
+ }
+
+ if ( offset + length > text.size() ) { return { 0xFFFD, 0 }; }
+ for ( std::size_t index = 1; index < length; ++index ) {
+ const auto continuation =
+ static_cast<unsigned char>( text[offset + index] );
+ if ( ( continuation & 0xC0 ) != 0x80 ) { return { 0xFFFD, 0 }; }
+ value = static_cast<char32_t>( ( value << 6 ) |
+ ( continuation & 0x3F ) );
+ }
+ if ( value < minimum || value > 0x10FFFF ||
+ ( value >= 0xD800 && value <= 0xDFFF ) ) {
+ return { 0xFFFD, 0 };
+ }
+ return { value, length };
+ }
+
+ bool IsXmlCharacter( char32_t value ) noexcept {
+ return value == 0x09 || value == 0x0A || value == 0x0D ||
+ ( value >= 0x20 && value <= 0xD7FF ) ||
+ ( value >= 0xE000 && value <= 0xFFFD ) ||
+ ( value >= 0x10000 && value <= 0x10FFFF );
+ }
+
+ void WriteEscaped( std::string_view value,
+ std::ostream& output,
+ bool attribute ) {
+ for ( std::size_t index = 0; index < value.size(); ) {
+ const DecodedCharacter character = DecodeUtf8( value, index );
+ if ( character.Length == 0 ||
+ !IsXmlCharacter( character.Value ) ) {
+ // Preserve parseability without silently dropping input.
+ output << "&#xFFFD;";
+ ++index;
+ continue;
+ }
+
+ if ( character.Value < 0x80 ) {
+ switch ( static_cast<char>( character.Value ) ) {
+ case '&':
+ output << "&amp;";
+ break;
+ case '<':
+ output << "&lt;";
+ break;
+ case '>':
+ output << "&gt;";
+ break;
+ case '"':
+ if ( attribute ) {
+ output << "&quot;";
+ } else {
+ output.put( '"' );
+ }
+ break;
+ case '\'':
+ if ( attribute ) {
+ output << "&apos;";
+ } else {
+ output.put( '\'' );
+ }
+ break;
+ default:
+ output.put( static_cast<char>( character.Value ) );
+ }
+ } else {
+ output.write(
+ value.data() + index,
+ static_cast<std::streamsize>( character.Length ) );
+ }
+ index += character.Length;
+ }
+ }
+
+ void WriteAttribute( std::ostream& output,
+ std::string_view name,
+ std::string_view value ) {
+ output.put( ' ' );
+ output.write( name.data(),
+ static_cast<std::streamsize>( name.size() ) );
+ output << "=\"";
+ WriteEscaped( value, output, true );
+ output.put( '"' );
+ }
+
+ std::string FormatSeconds( double seconds ) {
+ std::ostringstream formatted;
+ formatted.imbue( std::locale::classic() );
+ formatted << std::fixed << std::setprecision( 6 ) << seconds;
+ return formatted.str();
+ }
+
+ void WriteIndent( std::ostream& output, unsigned int level ) {
+ for ( unsigned int index = 0; index < level; ++index ) {
+ output << " ";
+ }
+ }
+
+ void WriteTextElement( std::ostream& output,
+ unsigned int indent,
+ std::string_view name,
+ std::string_view value ) {
+ WriteIndent( output, indent );
+ output.put( '<' );
+ output.write( name.data(),
+ static_cast<std::streamsize>( name.size() ) );
+ output.put( '>' );
+ WriteEscaped( value, output, false );
+ output << "</";
+ output.write( name.data(),
+ static_cast<std::streamsize>( name.size() ) );
+ output << ">\n";
+ }
+
+ TestRunSummary SummarizeSuite( const TestSuiteResult& suite ) {
+ return SummarizeTestSuite( suite );
+ }
+
+ void WriteTestCase( const TestCaseResult& testCase,
+ std::string_view suiteName,
+ std::ostream& output ) {
+ const bool hasBody = testCase.Status != TestCaseStatus::kPassed ||
+ !testCase.StandardOutput.empty() ||
+ !testCase.StandardError.empty();
+ WriteIndent( output, 2 );
+ output << "<testcase";
+ WriteAttribute( output,
+ "classname",
+ testCase.ClassName.empty() ? suiteName
+ : testCase.ClassName );
+ WriteAttribute( output, "name", testCase.Name );
+ WriteAttribute(
+ output, "time", FormatSeconds( testCase.DurationSeconds ) );
+ if ( !testCase.SourceFile.empty() ) {
+ WriteAttribute( output, "file", testCase.SourceFile );
+ }
+ if ( !hasBody ) {
+ output << "/>\n";
+ return;
+ }
+
+ output << ">\n";
+ if ( testCase.Status == TestCaseStatus::kFailed ||
+ testCase.Status == TestCaseStatus::kErrored ) {
+ const bool isError =
+ testCase.Status == TestCaseStatus::kErrored;
+ WriteIndent( output, 3 );
+ output << ( isError ? "<error" : "<failure" );
+ WriteAttribute( output, "message", testCase.Message );
+ WriteAttribute( output,
+ "type",
+ isError ? "TestError" : "AssertionFailure" );
+ output.put( '>' );
+ WriteEscaped( testCase.Details.empty() ? testCase.Message
+ : testCase.Details,
+ output,
+ false );
+ output << ( isError ? "</error>\n" : "</failure>\n" );
+ } else if ( testCase.Status == TestCaseStatus::kSkipped ) {
+ WriteIndent( output, 3 );
+ output << "<skipped";
+ WriteAttribute( output, "message", testCase.Message );
+ output << "/>\n";
+ }
+ if ( !testCase.StandardOutput.empty() ) {
+ WriteTextElement(
+ output, 3, "system-out", testCase.StandardOutput );
+ }
+ if ( !testCase.StandardError.empty() ) {
+ WriteTextElement(
+ output, 3, "system-err", testCase.StandardError );
+ }
+ WriteIndent( output, 2 );
+ output << "</testcase>\n";
+ }
+
+ } // namespace
+
+ void JUnitXmlWriter::Write( const TestRunResult& result,
+ std::ostream& output ) {
+ const TestRunSummary runSummary = SummarizeTestRun( result );
+ output << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<testsuites";
+ WriteAttribute( output, "name", result.Name );
+ WriteAttribute( output, "tests", std::to_string( runSummary.Tests ) );
+ WriteAttribute(
+ output, "failures", std::to_string( runSummary.Failures ) );
+ WriteAttribute( output, "errors", std::to_string( runSummary.Errors ) );
+ WriteAttribute(
+ output, "skipped", std::to_string( runSummary.Skipped ) );
+ WriteAttribute(
+ output, "time", FormatSeconds( runSummary.DurationSeconds ) );
+ output << ">\n";
+
+ for ( const TestSuiteResult& suite : result.TestSuites ) {
+ const TestRunSummary suit