From 0039cd7e7ca7838086ae506b0709e9356b0bca2c Mon Sep 17 00:00:00 2001 From: ljubisap Date: Thu, 3 Sep 2026 06:57:21 +0300 Subject: [PATCH] feat: define coverage map schema v1 and fragment contract --- docs/coverage-map-model.md | 63 +++++ docs/coverage-map-schema.md | 167 +++++++++++ docs/jgrapht-schema-v1-validation.md | 78 ++++++ smart-test-picker-cli/build.gradle | 2 +- .../cli/SmartTestPickerCli.java | 2 +- smart-test-picker-common/build.gradle | 2 +- .../coverage/CoverageMapContract.java | 11 + .../coverage/ExecutionPlanContract.java | 11 + .../coverage/model/CollectionExpectation.java | 16 ++ .../coverage/model/CollectionStatus.java | 5 + .../coverage/model/CollectionSummary.java | 19 ++ .../coverage/model/Completeness.java | 52 ++++ .../coverage/model/CoverageFragment.java | 22 ++ .../coverage/model/CoverageMap.java | 22 ++ .../model/CoverageMapLifecycleState.java | 5 + .../coverage/model/CoverageMapRevision.java | 11 + .../coverage/model/GeneratorProvenance.java | 5 + .../coverage/model/MapStatistics.java | 6 + .../model/MethodCoverageReference.java | 13 + .../coverage/model/SetupScope.java | 19 ++ .../coverage/model/SetupScopeType.java | 5 + .../coverage/model/ShardId.java | 9 + .../coverage/model/TestContainer.java | 12 + .../coverage/model/TestCoverage.java | 24 ++ .../coverage/model/TestIdentity.java | 33 +++ .../coverage/model/TestInventory.java | 11 + .../coverage/model/TestOutcome.java | 6 + .../coverage/model/UnmappedReason.java | 5 + .../coverage/model/UnmappedTest.java | 11 + .../serialization/CoverageFragmentCodec.java | 86 ++++++ .../serialization/CoverageMapCodec.java | 239 ++++++++++++++++ .../CoverageMapValidationException.java | 16 ++ .../validation/CoverageMapValidator.java | 69 +++++ .../validation/ValidationCategory.java | 11 + .../coverage/validation/ValidationCode.java | 23 ++ .../coverage/validation/ValidationError.java | 5 + .../coverage/validation/ValidationResult.java | 13 + .../engine/CoverageMapEngine.java | 3 +- .../smarttestpicker/mapper/CoverageMap.java | 8 +- .../mapper/CoverageMapReader.java | 6 +- .../mapper/CoverageMapperJaxb.java | 8 +- .../coverage/CoverageMapContractTest.java | 249 +++++++++++++++++ .../JGraphTCoverageMapV1IntegrationTest.java | 264 ++++++++++++++++++ smart-test-picker-maven/build.gradle | 6 +- 44 files changed, 1634 insertions(+), 19 deletions(-) create mode 100644 docs/coverage-map-model.md create mode 100644 docs/coverage-map-schema.md create mode 100644 docs/jgrapht-schema-v1-validation.md create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/CoverageMapContract.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/ExecutionPlanContract.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CollectionExpectation.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CollectionStatus.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CollectionSummary.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/Completeness.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageFragment.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageMap.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageMapLifecycleState.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageMapRevision.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/GeneratorProvenance.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/MapStatistics.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/MethodCoverageReference.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/SetupScope.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/SetupScopeType.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/ShardId.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestContainer.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestCoverage.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestIdentity.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestInventory.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestOutcome.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/UnmappedReason.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/UnmappedTest.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/serialization/CoverageFragmentCodec.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/serialization/CoverageMapCodec.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/CoverageMapValidationException.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/CoverageMapValidator.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationCategory.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationCode.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationError.java create mode 100644 smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationResult.java create mode 100644 smart-test-picker-common/src/test/java/com/sap/oss/smarttestpicker/coverage/CoverageMapContractTest.java create mode 100644 smart-test-picker-common/src/test/java/com/sap/oss/smarttestpicker/coverage/JGraphTCoverageMapV1IntegrationTest.java diff --git a/docs/coverage-map-model.md b/docs/coverage-map-model.md new file mode 100644 index 00000000..c5fe5017 --- /dev/null +++ b/docs/coverage-map-model.md @@ -0,0 +1,63 @@ +# Coverage map semantic model + +The semantic model lives under `com.sap.oss.smarttestpicker.coverage.model`; JSON construction and +parsing live separately under `coverage.serialization`. Model records defensively copy maps, lists, +and sets, expose no setters, and use value types for revisions, test identities, containers, and +shards. This separation lets selection and future builders depend on meaning rather than dictionary +indexes or JSON layout. + +The main types are `CoverageMap`, `CoverageMapRevision`, `TestIdentity`, `TestCoverage`, +`TestContainer`, `SetupScope`, `UnmappedTest`, `TestInventory`, `CollectionExpectation`, +`CollectionSummary`, `Completeness`, `TestOutcome`, +`GeneratorProvenance`, `MapStatistics`, `CoverageFragment`, and `MethodCoverageReference`. + +## Setup is first-class + +Static initialization, `@BeforeAll`, inherited setup, nested setup, and cached framework contexts do +not necessarily belong to one test class. `SetupScope` therefore maps covered classes to a set of +affected containers. This lets backlog 5b conservatively select every logical test in every affected +container without schema changes. + +## Failed and unmapped tests + +Test outcome and coverage collection status are independent. A failed test with a valid coverage +collection remains a mapped test with `outcome = FAIL`; its coverage is not discarded. A test is an +`UnmappedTest` only when no valid mapping exists, including a collection failure. Backlog 5b owns +the future selection policy for these states. + +`COLLECTED_EMPTY` is different: the test and collector completed successfully and observed no +production coverage. `COLLECTION_FAILED` belongs in `unmapped`, never in a successful test mapping. + +## Independent inventory and completeness + +`Completeness.from` requires both an independent `CollectionExpectation` and a collector-side +`CollectionSummary`. The expectation combines `TestInventory` with expected shard identities; +neither expected tests nor expected shards can be inferred from collector output. Inventory discovery belongs to 5c/5d. Completeness uses identity +set differences and separately records missing, unexpected and duplicate tests and missing or +duplicate shards. Equal counts with different identities are incomplete. + +Dynamic sharding has a global expected test set and an expected shard set; there is deliberately no +shard-to-test assignment in the model. + +## Lifecycle + +`CoverageMapLifecycleState` represents `FRAGMENT`, `CANDIDATE`, and `PUBLISHED`. Only a validated +`PUBLISHED` map may be selector input. This task defines the distinction; atomic publication, +retention, and storage belong to backlog 5d. + +## Deliberate boundaries + +This contract does not implement fragment merging or a map builder (5c/5d), runtime coverage and +test discovery (5c), setup-scope or unmapped-test selection policy (5b), storage/publication (5d), +Jenkins orchestration (5d), or arbitrary base/head revision handling (5e). It also does not alter +Gradle or Maven adapters. + +## Version impact + +Schema v1 is not compatible with legacy maps. Legacy test keys use `SimpleClass#method_7hexHash`, +from which the FQN cannot be reconstructed, so maps must be regenerated. The semantic selector API +need not change in Task 5a and the existing CLI output schema remains unchanged; future v1 reader +adoption will be an explicit integration step. Because Core and CLI artifact content changes, the +next release must not reuse `0.1.0`; the recommended next artifact version is `0.2.0`. + +The RAD 1 evaluation repository is frozen at commit `a53e5c7` and is not modified or regenerated. diff --git a/docs/coverage-map-schema.md b/docs/coverage-map-schema.md new file mode 100644 index 00000000..5555c721 --- /dev/null +++ b/docs/coverage-map-schema.md @@ -0,0 +1,167 @@ +# Coverage map schema v1 + +Schema v1 extends STP's existing indexed JSON format. It is the first frozen coverage-map +contract; legacy maps without `schemaVersion` remain readable only through the legacy reader +and cannot be converted because their hashed simple-name test identities do not contain the +original fully-qualified class name. + +`CoverageMapContract.SCHEMA_VERSION` in STP Core is the single authority for map and fragment +validation. Compatibility requires exact equality. A higher or lower integer is rejected; +integer ordering does not imply compatibility. `ExecutionPlanContract.VERSION` similarly owns +execution-plan version 1 without changing the execution-plan wire format. + +## Indexed physical form + +The first implementation writes class and method coverage in one artifact: + +```json +{ + "schemaVersion": 1, + "revision": "abc123", + "generatedAt": "2026-08-30T10:00:00Z", + "generator": { + "stpVersion": "0.2.0", + "coverageRuntimeVersion": "0.1.0", + "jacocoVersion": "0.8.15", + "jdkVersion": "17" + }, + "classIndex": ["com.foo.ConfigService", "com.foo.OrderService"], + "methodIndex": ["com.foo.OrderService#placeOrder"], + "containerIndex": ["com.foo.BarTest", "com.foo.FooTest"], + "testIndex": ["com.foo.BarTest#slow", "com.foo.FooTest#a"], + "tests": [ + {"test": 1, "classes": [1], "methods": [0], "outcome": "FAIL", "collectionStatus": "COLLECTED_WITH_COVERAGE"} + ], + "unmapped": [ + {"test": 0, "reason": "TIMEOUT"} + ], + "setupScopes": [ + {"id": "scope-1", "type": "CONTAINER", "coveredClasses": [0], "affectedContainers": [1]} + ], + "completeness": { + "expectedTests": [0, 1], + "reportedTests": [0, 1], + "expectedShards": ["01"], + "completedShards": ["01"], + "missingTests": [], + "unexpectedTests": [], + "missingShards": [], + "duplicateShards": [], + "duplicateTests": [] + }, + "statistics": { + "expectedTests": 2, + "mappedTests": 1, + "unmappedTests": 1, + "setupScopes": 1, + "classEdges": 1, + "methodEdges": 1 + }, + "lifecycleState": "PUBLISHED", + "checksum": "sha256:<64-lowercase-hex-digits>" +} +``` + +Every interning table and every reference collection is sorted. Test identities are interned +because their FQNs otherwise recur in mappings, unmapped entries, and both completeness sets. +`classMetrics` is intentionally excluded: it is reporting data and is not read by selection. + +## Test identity + +The logical identity is `fully.qualified.BinaryClass#declaredMethod`. Nested classes use JVM +binary naming, for example `com.foo.OuterTest$Inner#nestedTest`, matching the names accepted by +build-tool filters. All invocations of one declared method collapse into one identity: + +* `@ParameterizedTest` and `@RepeatedTest` invocations are combined; +* `@TestTemplate` invocations are combined under the template method; +* `@TestFactory` and its dynamic tests are combined under the factory method. + +Whether a runtime can always recover the declaring factory method when a dynamic test lacks a +usable `MethodSource` remains an open 5c question. Schema v1 does not introduce invocation IDs. + +## Setup scopes + +A setup scope independently relates covered production classes to one or more affected test +containers. This represents nested, inherited, framework, and shared-context setup without +assigning coverage to whichever test happened to initialize it first. Supported enum values are +`CONTAINER`, `NESTED_CONTAINER`, `INHERITED_SETUP`, `SHARED_CONTEXT`, and `FRAMEWORK_SETUP`. +Only `CONTAINER` is currently expected to be producible. The other values are schema placeholders, +not claims of collector capability. Covered classes and containers reference shared tables. + +## Test outcome, unmapped tests, and collection status + +`TestOutcome` (`PASS` or `FAIL`) is independent of `CollectionStatus`. Both passing and failing tests +may have `COLLECTED_WITH_COVERAGE` or `COLLECTED_EMPTY`; either is a mapped, reported test for +completeness. An unmapped test has no `tests` entry because no valid coverage mapping exists. A +collection failure remains `UnmappedReason.COLLECTION_FAILED` and is never synthesized as successful +empty coverage. Other unmapped reasons diagnose why a valid mapping is unavailable; `FAILED` does +not imply that every failed test is unmapped. Selection semantics remain a 5b concern. + +## Completeness and shards + +Completeness compares global expected and reported identity sets, never counts. Reported means +mapped plus unmapped. `CollectionExpectation` independently owns expected shard identities; +`CollectionSummary` contains only completed shard reports and other collected facts. Sharding is +dynamic and there is no per-shard expected-test assignment. Missing, +unexpected and duplicate identities and missing or duplicate shards prevent publication. + +Generator versions and JDK/JaCoCo values are diagnostic and do not reject a map. Statistics are +also descriptive; completeness is never inferred from their counts. + +A `CANDIDATE` validation fixture may use JSON `null` for `completeness` when its source has no +independent inventory. Such a map can be codec-round-tripped, but validation still reports +`LIFECYCLE_NOT_PUBLISHED`, and it is not selector input. An absent inventory must never be replaced +with `expectedTests = collectedTests`. + +## Determinism and integrity + +The checksum is SHA-256 over compact canonical JSON with the `checksum` member omitted. The final +wire document adds that checksum after hashing. All identities, dictionaries, references, scope +members, shards, and entries are sorted before serialization. + +> Coverage map SHA-256 provides integrity and corruption detection. It is not an authenticity or trust mechanism. + +Trust in a map must come from its publication/distribution boundary; the checksum only identifies +and verifies the exact bytes inside that boundary. + +## Reserved separable method coverage + +Schema v1 reserves an optional checksum-covered descriptor: + +```json +"methodCoverage": { + "artifact": "coverage-methods.json.gz", + "sha256": "sha256:<64-lowercase-hex-digits>" +} +``` + +The first implementation does not use it and writes methods inline. A later implementation may +publish an immutable side artifact for lazy loading without changing the base schema. Resolution, +publication, and fallback behavior belong to 5b/5d. A declared but missing or corrupt side artifact +is not equivalent to a map that deliberately contains no method descriptor. + +## Provisional fragment contract + +```json +{ + "schemaVersion": 1, + "revision": "abc123", + "shardId": "07", + "tests": { + "com.foo.FooTest#a": { + "classes": ["com.foo.OrderService"], + "methods": ["com.foo.OrderService#placeOrder"], + "outcome": "PASS", + "collectionStatus": "COLLECTED_WITH_COVERAGE" + } + }, + "unmapped": [{"test": "com.foo.BarTest#b", "reason": "COLLECTION_FAILED"}], + "setupScopes": [], + "collection": {"completed": false} +} +``` + +Fragments from different revisions are not combinable. A fragment declares its shard but never +the expected shard set. `completed: false` differs from an absent fragment. This contract is +**PROVISIONAL** until 5c confirms how shard IDs enter the test JVM and whether the runtime can +reliably distinguish `COLLECTED_EMPTY` from `COLLECTION_FAILED`. diff --git a/docs/jgrapht-schema-v1-validation.md b/docs/jgrapht-schema-v1-validation.md new file mode 100644 index 00000000..dc9037f2 --- /dev/null +++ b/docs/jgrapht-schema-v1-validation.md @@ -0,0 +1,78 @@ +# JGraphT coverage-map schema-v1 validation + +## Dataset and conversion + +The validation reads, but does not modify or regenerate, +`../smart-test-picker-evaluation/jgrapht/results/test-coverage-map.json.gz`. The artifact was added at +evaluation-repository commit `df8f3785075773c96ce5e8baaa6e10d271b4b32d` and identifies JGraphT +revision `093b0c5ea006ba5b1d8b7a0212676bf8850cac6b`. Its physical format is gzip-compressed legacy JSON +with `classMetrics`, `metadata`, and 2,308 `testMappings`; mappings contain both class and method +coverage. + +The validation helper removes the legacy seven-hex-character suffix and reconstructs the FQN from +JGraphT test sources. If a simple class name is ambiguous, an archived JUnit identity in the +evaluation PIT XML must disambiguate it. It never guesses an FQN. This converted 2,298 mappings +(99.57%); ten mappings were excluded and reported because their `ReversedDoublyLinkedListViewTest` +source class was unavailable. The two `IncomingOutgoingEdgesTest` mappings are safely distinguished +where archived JUnit evidence exists; one without such evidence remains excluded. + +The semantic fixture is a `CANDIDATE`, has `completeness = null`, and uses an empty setup-scope list. +The null means no independent expected inventory is available. The empty scope list means setup +information is unavailable, not that JGraphT has no setup coverage. The fixture is consequently not +valid selector input and cannot be mistaken for a published map. + +## Results + +The complete legacy artifact has 565 unique covered classes, 3,151 unique covered methods, 60,556 +test-to-class edges, and 213,049 test-to-method edges. After removing the ten unresolved mappings, +the converted source subset and the schema-v1 round trip both have exactly: + +| Measure | Converted source | Round trip | +| --- | ---: | ---: | +| Tests | 2,298 | 2,298 | +| Unique classes | 561 | 561 | +| Unique methods | 3,099 | 3,099 | +| Test-to-class edges | 60,477 | 60,477 | +| Test-to-method edges | 212,656 | 212,656 | + +All reconstructed `TestIdentity` values and semantic edge sets compare equal. Every test, class, and +method index resolves in range; dictionaries are sorted and contain no semantic duplicates. Nested +binary names are handled by preserving the `$` suffix after resolving the outer test class. + +One representative run produced 1,920,176 raw bytes and 111,800 gzip bytes. Dictionary JSON members +occupied 412,691 bytes and the indexed `tests` member occupied 1,501,089 bytes (member sizes include +their JSON field framing and are diagnostic, not an additive file decomposition). The result is +broadly consistent with the earlier indexed/gzip expectation: dictionary indexing plus gzip reduces +this high-edge-density payload to about 6% of its raw schema-v1 size. The checksum was +`sha256:5561b8545399aff95e33dec02b05d9fb819191674019a9183e93adfe33168a2e` for that deterministic +fixture. Two serializations and serialize-deserialize-serialize were byte-identical. + +Serializing the same semantic map with method sets removed measured the method-coverage contribution +at 1,246,745 raw bytes and 70,265 gzip bytes. This comparison is diagnostic and is not a proposal to +change the inline schema. + +Representative in-process timings, averaged where practical, were approximately 217 ms semantic +construction, 79 ms serialization, 101 ms deserialization, and 241 ms semantic/dictionary +validation. These are diagnostics rather than thresholds. Heap delta/peak was not measured because +the Gradle test JVM does not provide a useful isolated peak without adding profiling infrastructure. + +Corrupting the real-data-sized serialized document is rejected as `CHECKSUM_MISMATCH`. Changing its +schema version to 2 is rejected before v1 parsing as `INCOMPATIBLE_SCHEMA/HIGHER_SCHEMA_VERSION`. + +## Running the validation + +`JGraphTCoverageMapV1IntegrationTest` uses the sibling evaluation and JGraphT trees when present and +is skipped otherwise, so the normal test suite remains portable. Alternate read-only locations can +be supplied with JVM properties `stp.jgraphtCoverageMap` and `stp.jgraphtSourceRoot`. It neither +executes JGraphT tests nor invokes a coverage collector. + +## Limitations + +- No new coverage was collected, and the full JGraphT test suite was not run. +- Setup scopes were not validated. +- Completeness was not validated against an independent inventory. +- Shards and fragments were not validated. +- Selector semantics were not changed or validated. +- The result does not prove 45k-test scale. +- It does prove schema-v1 codec/model handling and exact converted-subset preservation on a real + multi-thousand-test, high-edge-density dataset. diff --git a/smart-test-picker-cli/build.gradle b/smart-test-picker-cli/build.gradle index 90cc7012..c515838e 100644 --- a/smart-test-picker-cli/build.gradle +++ b/smart-test-picker-cli/build.gradle @@ -7,7 +7,7 @@ plugins { } group = 'com.sap.oss.smart-test-picker' -version = '0.1.0' +version = '0.2.0' dependencies { implementation project(':smart-test-picker-common') diff --git a/smart-test-picker-cli/src/main/java/com/sap/oss/smarttestpicker/cli/SmartTestPickerCli.java b/smart-test-picker-cli/src/main/java/com/sap/oss/smarttestpicker/cli/SmartTestPickerCli.java index 990e51f7..ca0667f0 100644 --- a/smart-test-picker-cli/src/main/java/com/sap/oss/smarttestpicker/cli/SmartTestPickerCli.java +++ b/smart-test-picker-cli/src/main/java/com/sap/oss/smarttestpicker/cli/SmartTestPickerCli.java @@ -30,7 +30,7 @@ name = "smart-test-picker", description = "Regression test selection via per-test runtime coverage", mixinStandardHelpOptions = true, - version = "0.1.0", + version = "0.2.0", subcommands = { ExecToXmlCommand.class, GenerateMapCommand.class, diff --git a/smart-test-picker-common/build.gradle b/smart-test-picker-common/build.gradle index 32bc530d..b7bc18be 100644 --- a/smart-test-picker-common/build.gradle +++ b/smart-test-picker-common/build.gradle @@ -6,7 +6,7 @@ plugins { } group = 'com.sap.oss.smart-test-picker' -version = '0.1.0' +version = '0.2.0' dependencies { implementation 'com.google.code.gson:gson:2.10.1' diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/CoverageMapContract.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/CoverageMapContract.java new file mode 100644 index 00000000..84d3550f --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/CoverageMapContract.java @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage; + +/** Authoritative wire-contract versions owned by STP Core. */ +public final class CoverageMapContract +{ + public static final int SCHEMA_VERSION = 1; + + private CoverageMapContract() {} +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/ExecutionPlanContract.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/ExecutionPlanContract.java new file mode 100644 index 00000000..2a50d259 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/ExecutionPlanContract.java @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage; + +/** Authoritative execution-plan wire version owned by STP Core. */ +public final class ExecutionPlanContract +{ + public static final int VERSION = 1; + + private ExecutionPlanContract() {} +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CollectionExpectation.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CollectionExpectation.java new file mode 100644 index 00000000..d51128b0 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CollectionExpectation.java @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +import java.util.Set; +import java.util.TreeSet; + +/** Independent expected test inventory and shard identities for one collection. */ +public record CollectionExpectation(TestInventory testInventory, Set expectedShards) +{ + public CollectionExpectation + { + if (testInventory == null) throw new IllegalArgumentException("test inventory is required"); + expectedShards = Set.copyOf(new TreeSet<>(expectedShards)); + } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CollectionStatus.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CollectionStatus.java new file mode 100644 index 00000000..c0056d72 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CollectionStatus.java @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +public enum CollectionStatus { COLLECTED_WITH_COVERAGE, COLLECTED_EMPTY, COLLECTION_FAILED } diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CollectionSummary.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CollectionSummary.java new file mode 100644 index 00000000..0cd011a5 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CollectionSummary.java @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +/** Collector-side facts; it deliberately contains no expected state. */ +public record CollectionSummary(Set reportedTests, List completedShardReports, + Set duplicateTests) +{ + public CollectionSummary + { + reportedTests = Set.copyOf(new TreeSet<>(reportedTests)); + completedShardReports = List.copyOf(completedShardReports); + duplicateTests = Set.copyOf(new TreeSet<>(duplicateTests)); + } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/Completeness.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/Completeness.java new file mode 100644 index 00000000..7bff7a65 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/Completeness.java @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +import java.util.HashSet; +import java.util.Set; +import java.util.TreeSet; + +public record Completeness(Set expectedTests, Set reportedTests, + Set expectedShards, Set completedShards, Set missingTests, + Set unexpectedTests, Set missingShards, Set duplicateShards, + Set duplicateTests) +{ + public static Completeness from(CollectionExpectation expectation, CollectionSummary collection) + { + TestInventory inventory = expectation.testInventory(); + Set missingTests = difference(inventory.expectedTests(), collection.reportedTests()); + Set unexpectedTests = difference(collection.reportedTests(), inventory.expectedTests()); + Set completed = new TreeSet<>(collection.completedShardReports()); + Set duplicateShards = duplicates(collection.completedShardReports()); + return new Completeness(inventory.expectedTests(), collection.reportedTests(), expectation.expectedShards(), + completed, missingTests, unexpectedTests, difference(expectation.expectedShards(), completed), + duplicateShards, collection.duplicateTests()); + } + + public Completeness + { + expectedTests = immutable(expectedTests); reportedTests = immutable(reportedTests); + expectedShards = immutable(expectedShards); completedShards = immutable(completedShards); + missingTests = immutable(missingTests); unexpectedTests = immutable(unexpectedTests); + missingShards = immutable(missingShards); duplicateShards = immutable(duplicateShards); + duplicateTests = immutable(duplicateTests); + } + + public boolean isComplete() + { + return missingTests.isEmpty() && unexpectedTests.isEmpty() && missingShards.isEmpty() + && duplicateShards.isEmpty() && duplicateTests.isEmpty(); + } + + private static > Set difference(Set left, Set right) + { + TreeSet result = new TreeSet<>(left); result.removeAll(right); return result; + } + private static > Set immutable(Set values) { return Set.copyOf(new TreeSet<>(values)); } + private static > Set duplicates(Iterable values) + { + Set seen = new HashSet<>(); TreeSet duplicate = new TreeSet<>(); + for (T value : values) if (!seen.add(value)) duplicate.add(value); + return duplicate; + } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageFragment.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageFragment.java new file mode 100644 index 00000000..6e7a2d7b --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageFragment.java @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** PROVISIONAL collector contract pending producibility confirmation in backlog 5c. */ +public record CoverageFragment(int schemaVersion, CoverageMapRevision revision, ShardId shardId, + Map tests, List unmapped, + List setupScopes, boolean collectionCompleted) +{ + public CoverageFragment + { + tests = Map.copyOf(new TreeMap<>(tests)); + unmapped = unmapped.stream().sorted((a, b) -> a.test().compareTo(b.test())).toList(); + setupScopes = setupScopes.stream().sorted((a, b) -> a.id().compareTo(b.id())).toList(); + } + + public boolean hasSameRevision(CoverageFragment other) { return revision.equals(other.revision); } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageMap.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageMap.java new file mode 100644 index 00000000..fb7ca9f4 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageMap.java @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** Immutable semantic coverage-map model, independent of its JSON representation. */ +public record CoverageMap(int schemaVersion, CoverageMapRevision revision, Instant generatedAt, + GeneratorProvenance generator, Map tests, List unmapped, + List setupScopes, Completeness completeness, MapStatistics statistics, + CoverageMapLifecycleState lifecycleState, MethodCoverageReference methodCoverageReference) +{ + public CoverageMap + { + tests = Map.copyOf(new TreeMap<>(tests)); + unmapped = unmapped.stream().sorted((a, b) -> a.test().compareTo(b.test())).toList(); + setupScopes = setupScopes.stream().sorted((a, b) -> a.id().compareTo(b.id())).toList(); + } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageMapLifecycleState.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageMapLifecycleState.java new file mode 100644 index 00000000..9cb7cc1b --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageMapLifecycleState.java @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +public enum CoverageMapLifecycleState { FRAGMENT, CANDIDATE, PUBLISHED } diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageMapRevision.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageMapRevision.java new file mode 100644 index 00000000..2ac444c2 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/CoverageMapRevision.java @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +public record CoverageMapRevision(String value) +{ + public CoverageMapRevision + { + if (value == null || value.isBlank()) throw new IllegalArgumentException("revision must not be blank"); + } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/GeneratorProvenance.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/GeneratorProvenance.java new file mode 100644 index 00000000..b7d84e68 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/GeneratorProvenance.java @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +public record GeneratorProvenance(String stpVersion, String coverageRuntimeVersion, String jacocoVersion, String jdkVersion) {} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/MapStatistics.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/MapStatistics.java new file mode 100644 index 00000000..33deff7e --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/MapStatistics.java @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +public record MapStatistics(int expectedTests, int mappedTests, int unmappedTests, int setupScopes, + long classEdges, long methodEdges) {} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/MethodCoverageReference.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/MethodCoverageReference.java new file mode 100644 index 00000000..8a392ad8 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/MethodCoverageReference.java @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +/** Reserved schema-v1 affordance; resolving this artifact is not implemented in Task 5a. */ +public record MethodCoverageReference(String artifact, String sha256) +{ + public MethodCoverageReference + { + if (artifact == null || artifact.isBlank() || sha256 == null || !sha256.matches("sha256:[0-9a-f]{64}")) + throw new IllegalArgumentException("valid method artifact and SHA-256 are required"); + } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/SetupScope.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/SetupScope.java new file mode 100644 index 00000000..5bd81894 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/SetupScope.java @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +import java.util.Set; +import java.util.TreeSet; + +public record SetupScope(String id, SetupScopeType type, Set coveredClasses, + Set affectedContainers) +{ + public SetupScope + { + if (id == null || id.isBlank()) throw new IllegalArgumentException("scope id must not be blank"); + if (type == null) throw new IllegalArgumentException("scope type must not be null"); + coveredClasses = Set.copyOf(new TreeSet<>(coveredClasses)); + affectedContainers = Set.copyOf(new TreeSet<>(affectedContainers)); + if (affectedContainers.isEmpty()) throw new IllegalArgumentException("setup scope must affect at least one container"); + } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/SetupScopeType.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/SetupScopeType.java new file mode 100644 index 00000000..8440b675 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/SetupScopeType.java @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +public enum SetupScopeType { CONTAINER, NESTED_CONTAINER, INHERITED_SETUP, SHARED_CONTEXT, FRAMEWORK_SETUP } diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/ShardId.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/ShardId.java new file mode 100644 index 00000000..9755a7ca --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/ShardId.java @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +public record ShardId(String value) implements Comparable +{ + public ShardId { if (value == null || value.isBlank()) throw new IllegalArgumentException("shard id must not be blank"); } + @Override public int compareTo(ShardId other) { return value.compareTo(other.value); } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestContainer.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestContainer.java new file mode 100644 index 00000000..6c7b4fc9 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestContainer.java @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +public record TestContainer(String binaryName) implements Comparable +{ + public TestContainer + { + if (binaryName == null || binaryName.isBlank()) throw new IllegalArgumentException("container name must not be blank"); + } + @Override public int compareTo(TestContainer other) { return binaryName.compareTo(other.binaryName); } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestCoverage.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestCoverage.java new file mode 100644 index 00000000..5bd385b8 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestCoverage.java @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +import java.util.Set; +import java.util.TreeSet; + +public record TestCoverage(Set coveredClasses, Set coveredMethods, TestOutcome outcome, + CollectionStatus collectionStatus) +{ + public TestCoverage + { + coveredClasses = Set.copyOf(new TreeSet<>(coveredClasses)); + coveredMethods = Set.copyOf(new TreeSet<>(coveredMethods)); + if (outcome == null || collectionStatus == null) + throw new IllegalArgumentException("test outcome and collection status are required"); + if (collectionStatus == CollectionStatus.COLLECTION_FAILED) + throw new IllegalArgumentException("COLLECTION_FAILED belongs in unmapped, not tests"); + if (collectionStatus == CollectionStatus.COLLECTED_EMPTY && (!coveredClasses.isEmpty() || !coveredMethods.isEmpty())) + throw new IllegalArgumentException("COLLECTED_EMPTY cannot contain coverage"); + if (collectionStatus == CollectionStatus.COLLECTED_WITH_COVERAGE && coveredClasses.isEmpty()) + throw new IllegalArgumentException("COLLECTED_WITH_COVERAGE requires class coverage"); + } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestIdentity.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestIdentity.java new file mode 100644 index 00000000..3cabcce8 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestIdentity.java @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** Logical test identity: fully-qualified JVM binary class name plus declared method name. */ +public record TestIdentity(String className, String methodName) implements Comparable +{ + private static final Pattern CLASS_NAME = Pattern.compile("[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*(?:[.$][\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*)+"); + private static final Pattern METHOD_NAME = Pattern.compile("[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*"); + + public TestIdentity + { + if (className == null || !CLASS_NAME.matcher(className).matches()) + throw new IllegalArgumentException("Malformed test class name: " + className); + if (methodName == null || !METHOD_NAME.matcher(methodName).matches()) + throw new IllegalArgumentException("Malformed test method name: " + methodName); + } + + public static TestIdentity parse(String value) + { + Objects.requireNonNull(value, "value"); + int separator = value.indexOf('#'); + if (separator <= 0 || separator != value.lastIndexOf('#') || separator == value.length() - 1) + throw new IllegalArgumentException("Malformed test identity: " + value); + return new TestIdentity(value.substring(0, separator), value.substring(separator + 1)); + } + + @Override public String toString() { return className + "#" + methodName; } + @Override public int compareTo(TestIdentity other) { return toString().compareTo(other.toString()); } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestInventory.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestInventory.java new file mode 100644 index 00000000..4a4ac6ab --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestInventory.java @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +import java.util.Set; +import java.util.TreeSet; + +public record TestInventory(CoverageMapRevision revision, Set expectedTests) +{ + public TestInventory { expectedTests = Set.copyOf(new TreeSet<>(expectedTests)); } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestOutcome.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestOutcome.java new file mode 100644 index 00000000..7d298b9b --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/TestOutcome.java @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +/** Test execution outcome, independent of whether coverage collection succeeded. */ +public enum TestOutcome { PASS, FAIL } diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/UnmappedReason.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/UnmappedReason.java new file mode 100644 index 00000000..a291bca0 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/UnmappedReason.java @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +public enum UnmappedReason { FAILED, SKIPPED, TIMEOUT, COLLECTION_FAILED } diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/UnmappedTest.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/UnmappedTest.java new file mode 100644 index 00000000..5f12c140 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/model/UnmappedTest.java @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.model; + +public record UnmappedTest(TestIdentity test, UnmappedReason reason) +{ + public UnmappedTest + { + if (test == null || reason == null) throw new IllegalArgumentException("unmapped test and reason are required"); + } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/serialization/CoverageFragmentCodec.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/serialization/CoverageFragmentCodec.java new file mode 100644 index 00000000..827fb1dd --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/serialization/CoverageFragmentCodec.java @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.serialization; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sap.oss.smarttestpicker.coverage.CoverageMapContract; +import com.sap.oss.smarttestpicker.coverage.model.CollectionStatus; +import com.sap.oss.smarttestpicker.coverage.model.CoverageFragment; +import com.sap.oss.smarttestpicker.coverage.model.CoverageMapRevision; +import com.sap.oss.smarttestpicker.coverage.model.SetupScope; +import com.sap.oss.smarttestpicker.coverage.model.SetupScopeType; +import com.sap.oss.smarttestpicker.coverage.model.ShardId; +import com.sap.oss.smarttestpicker.coverage.model.TestContainer; +import com.sap.oss.smarttestpicker.coverage.model.TestCoverage; +import com.sap.oss.smarttestpicker.coverage.model.TestIdentity; +import com.sap.oss.smarttestpicker.coverage.model.TestOutcome; +import com.sap.oss.smarttestpicker.coverage.model.UnmappedReason; +import com.sap.oss.smarttestpicker.coverage.model.UnmappedTest; +import com.sap.oss.smarttestpicker.coverage.validation.CoverageMapValidationException; +import com.sap.oss.smarttestpicker.coverage.validation.CoverageMapValidator; +import com.sap.oss.smarttestpicker.coverage.validation.ValidationCategory; +import com.sap.oss.smarttestpicker.coverage.validation.ValidationCode; + +/** PROVISIONAL schema-v1 fragment codec. Setup scopes use the same semantic shape as maps. */ +public final class CoverageFragmentCodec +{ + private static final Gson GSON = new Gson(); + + public byte[] serialize(CoverageFragment fragment) + { + JsonObject root = new JsonObject(); root.addProperty("schemaVersion", fragment.schemaVersion()); + root.addProperty("revision", fragment.revision().value()); root.addProperty("shardId", fragment.shardId().value()); + JsonObject tests = new JsonObject(); + new TreeMap<>(fragment.tests()).forEach((identity, coverage) -> { + JsonObject value = new JsonObject(); value.add("classes", GSON.toJsonTree(coverage.coveredClasses().stream().sorted().toList())); + value.add("methods", GSON.toJsonTree(coverage.coveredMethods().stream().sorted().toList())); + value.addProperty("outcome", coverage.outcome().name()); + value.addProperty("collectionStatus", coverage.collectionStatus().name()); tests.add(identity.toString(), value); + }); root.add("tests", tests); + JsonArray unmapped = new JsonArray(); fragment.unmapped().stream().sorted((a, b) -> a.test().compareTo(b.test())).forEach(entry -> { JsonObject value = new JsonObject(); value.addProperty("test", entry.test().toString()); value.addProperty("reason", entry.reason().name()); unmapped.add(value); }); root.add("unmapped", unmapped); + JsonArray scopes = new JsonArray(); + fragment.setupScopes().stream().sorted((a, b) -> a.id().compareTo(b.id())).forEach(scope -> { + JsonObject value = new JsonObject(); value.addProperty("id", scope.id()); value.addProperty("type", scope.type().name()); + value.add("coveredClasses", GSON.toJsonTree(scope.coveredClasses().stream().sorted().toList())); + value.add("affectedContainers", GSON.toJsonTree(scope.affectedContainers().stream().map(TestContainer::binaryName).sorted().toList())); scopes.add(value); + }); root.add("setupScopes", scopes); + JsonObject collection = new JsonObject(); collection.addProperty("completed", fragment.collectionCompleted()); root.add("collection", collection); + return GSON.toJson(root).getBytes(StandardCharsets.UTF_8); + } + + public CoverageFragment deserialize(byte[] bytes) + { + JsonObject root = JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)).getAsJsonObject(); int version = root.get("schemaVersion").getAsInt(); + if (version != CoverageMapContract.SCHEMA_VERSION) throw new CoverageMapValidationException(CoverageMapValidator.error(ValidationCategory.INCOMPATIBLE_SCHEMA, version > CoverageMapContract.SCHEMA_VERSION ? ValidationCode.HIGHER_SCHEMA_VERSION : ValidationCode.SCHEMA_VERSION_MISMATCH, "Unsupported fragment schema version: " + version)); + Map tests = new LinkedHashMap<>(); + for (Map.Entry entry : root.getAsJsonObject("tests").entrySet()) + { + JsonObject value = entry.getValue().getAsJsonObject(); + tests.put(TestIdentity.parse(entry.getKey()), new TestCoverage(strings(value, "classes"), strings(value, "methods"), + TestOutcome.valueOf(value.get("outcome").getAsString()), + CollectionStatus.valueOf(value.get("collectionStatus").getAsString()))); + } + List unmapped = new ArrayList<>(); for (JsonElement element : root.getAsJsonArray("unmapped")) { JsonObject value = element.getAsJsonObject(); unmapped.add(new UnmappedTest(TestIdentity.parse(value.get("test").getAsString()), UnmappedReason.valueOf(value.get("reason").getAsString()))); } + List scopes = new ArrayList<>(); + for (JsonElement element : root.getAsJsonArray("setupScopes")) + { + JsonObject value = element.getAsJsonObject(); java.util.Set containers = new java.util.TreeSet<>(); + strings(value, "affectedContainers").forEach(name -> containers.add(new TestContainer(name))); + scopes.add(new SetupScope(value.get("id").getAsString(), SetupScopeType.valueOf(value.get("type").getAsString()), strings(value, "coveredClasses"), containers)); + } + return new CoverageFragment(version, new CoverageMapRevision(root.get("revision").getAsString()), new ShardId(root.get("shardId").getAsString()), tests, unmapped, scopes, root.getAsJsonObject("collection").get("completed").getAsBoolean()); + } + + private static java.util.Set strings(JsonObject object, String name) { java.util.TreeSet result = new java.util.TreeSet<>(); object.getAsJsonArray(name).forEach(value -> result.add(value.getAsString())); return result; } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/serialization/CoverageMapCodec.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/serialization/CoverageMapCodec.java new file mode 100644 index 00000000..6816b210 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/serialization/CoverageMapCodec.java @@ -0,0 +1,239 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.serialization; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sap.oss.smarttestpicker.coverage.CoverageMapContract; +import com.sap.oss.smarttestpicker.coverage.model.CollectionStatus; +import com.sap.oss.smarttestpicker.coverage.model.Completeness; +import com.sap.oss.smarttestpicker.coverage.model.CoverageMap; +import com.sap.oss.smarttestpicker.coverage.model.CoverageMapLifecycleState; +import com.sap.oss.smarttestpicker.coverage.model.CoverageMapRevision; +import com.sap.oss.smarttestpicker.coverage.model.GeneratorProvenance; +import com.sap.oss.smarttestpicker.coverage.model.MapStatistics; +import com.sap.oss.smarttestpicker.coverage.model.MethodCoverageReference; +import com.sap.oss.smarttestpicker.coverage.model.SetupScope; +import com.sap.oss.smarttestpicker.coverage.model.SetupScopeType; +import com.sap.oss.smarttestpicker.coverage.model.ShardId; +import com.sap.oss.smarttestpicker.coverage.model.TestContainer; +import com.sap.oss.smarttestpicker.coverage.model.TestCoverage; +import com.sap.oss.smarttestpicker.coverage.model.TestIdentity; +import com.sap.oss.smarttestpicker.coverage.model.TestOutcome; +import com.sap.oss.smarttestpicker.coverage.model.UnmappedReason; +import com.sap.oss.smarttestpicker.coverage.model.UnmappedTest; +import com.sap.oss.smarttestpicker.coverage.validation.CoverageMapValidationException; +import com.sap.oss.smarttestpicker.coverage.validation.CoverageMapValidator; +import com.sap.oss.smarttestpicker.coverage.validation.ValidationCategory; +import com.sap.oss.smarttestpicker.coverage.validation.ValidationCode; +import com.sap.oss.smarttestpicker.coverage.validation.ValidationResult; + +/** Deterministic schema-v1 indexed serializer/deserializer. */ +public final class CoverageMapCodec +{ + private static final Gson GSON = new Gson(); + + public byte[] serialize(CoverageMap map) + { + JsonObject canonical = toJson(map); + String checksum = checksum(bytes(canonical)); + canonical.addProperty("checksum", checksum); + return bytes(canonical); + } + + public CoverageMap deserialize(byte[] bytes) + { + JsonObject root; + try { root = JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)).getAsJsonObject(); } + catch (RuntimeException e) { throw invalid(ValidationCategory.CORRUPT_MAP, ValidationCode.CHECKSUM_MISMATCH, "Malformed coverage-map JSON"); } + + int version = requiredInt(root, "schemaVersion"); + if (version != CoverageMapContract.SCHEMA_VERSION) + throw invalid(ValidationCategory.INCOMPATIBLE_SCHEMA, + version > CoverageMapContract.SCHEMA_VERSION ? ValidationCode.HIGHER_SCHEMA_VERSION : ValidationCode.SCHEMA_VERSION_MISMATCH, + "Unsupported coverage-map schema version: " + version); + + String suppliedChecksum = requiredString(root, "checksum"); + JsonObject withoutChecksum = root.deepCopy(); withoutChecksum.remove("checksum"); + String actualChecksum = checksum(bytes(withoutChecksum)); + if (!actualChecksum.equals(suppliedChecksum)) + throw invalid(ValidationCategory.CORRUPT_MAP, ValidationCode.CHECKSUM_MISMATCH, + "Coverage-map checksum mismatch"); + + try + { + CoverageMap map = fromJson(root); + ValidationResult validation = CoverageMapValidator.validate(map); + validation.errors().stream() + .filter(error -> error.code() != ValidationCode.LIFECYCLE_NOT_PUBLISHED) + .findFirst().ifPresent(error -> { throw new CoverageMapValidationException(error); }); + return map; + } + catch (CoverageMapValidationException e) { throw e; } + catch (IllegalArgumentException | IndexOutOfBoundsException e) + { + throw invalid(ValidationCategory.INVALID_STRUCTURE, ValidationCode.MALFORMED_TEST_IDENTITY, e.getMessage()); + } + } + + public ValidationResult validate(CoverageMap map) { return CoverageMapValidator.validate(map); } + + private JsonObject toJson(CoverageMap map) + { + TreeSet classes = new TreeSet<>(); TreeSet methods = new TreeSet<>(); + TreeSet containers = new TreeSet<>(); TreeSet identities = new TreeSet<>(); + identities.addAll(map.tests().keySet()); + map.unmapped().forEach(entry -> identities.add(entry.test())); + if (map.completeness() != null) { identities.addAll(map.completeness().expectedTests()); identities.addAll(map.completeness().reportedTests()); } + map.tests().values().forEach(coverage -> { classes.addAll(coverage.coveredClasses()); methods.addAll(coverage.coveredMethods()); }); + map.setupScopes().forEach(scope -> { classes.addAll(scope.coveredClasses()); scope.affectedContainers().forEach(c -> containers.add(c.binaryName())); }); + + List classTable = List.copyOf(classes), methodTable = List.copyOf(methods), containerTable = List.copyOf(containers); + List testTable = List.copyOf(identities); + Map classIds = ids(classTable), methodIds = ids(methodTable), containerIds = ids(containerTable); + Map testIds = new LinkedHashMap<>(); for (int i = 0; i < testTable.size(); i++) testIds.put(testTable.get(i), i); + + JsonObject root = new JsonObject(); + root.addProperty("schemaVersion", map.schemaVersion()); root.addProperty("revision", map.revision().value()); + root.addProperty("generatedAt", map.generatedAt().toString()); root.add("generator", GSON.toJsonTree(map.generator())); + root.add("classIndex", strings(classTable)); root.add("methodIndex", strings(methodTable)); + root.add("containerIndex", strings(containerTable)); root.add("testIndex", strings(testTable.stream().map(TestIdentity::toString).toList())); + + JsonArray tests = new JsonArray(); + map.tests().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> { + JsonObject value = new JsonObject(); value.addProperty("test", testIds.get(entry.getKey())); + value.add("classes", indexes(entry.getValue().coveredClasses(), classIds)); + value.add("methods", indexes(entry.getValue().coveredMethods(), methodIds)); + value.addProperty("outcome", entry.getValue().outcome().name()); + value.addProperty("collectionStatus", entry.getValue().collectionStatus().name()); tests.add(value); + }); root.add("tests", tests); + + JsonArray unmapped = new JsonArray(); + map.unmapped().stream().sorted((a, b) -> a.test().compareTo(b.test())).forEach(entry -> { + JsonObject value = new JsonObject(); value.addProperty("test", testIds.get(entry.test())); value.addProperty("reason", entry.reason().name()); unmapped.add(value); + }); root.add("unmapped", unmapped); + + JsonArray scopes = new JsonArray(); + map.setupScopes().stream().sorted((a, b) -> a.id().compareTo(b.id())).forEach(scope -> { + JsonObject value = new JsonObject(); value.addProperty("id", scope.id()); value.addProperty("type", scope.type().name()); + value.add("coveredClasses", indexes(scope.coveredClasses(), classIds)); + value.add("affectedContainers", indexes(scope.affectedContainers().stream().map(TestContainer::binaryName).collect(java.util.stream.Collectors.toSet()), containerIds)); + scopes.add(value); + }); root.add("setupScopes", scopes); + + if (map.completeness() == null) root.add("completeness", com.google.gson.JsonNull.INSTANCE); + else root.add("completeness", completenessToJson(map.completeness(), testIds)); + root.add("statistics", GSON.toJsonTree(map.statistics())); root.addProperty("lifecycleState", map.lifecycleState().name()); + if (map.methodCoverageReference() != null) root.add("methodCoverage", GSON.toJsonTree(map.methodCoverageReference())); + return root; + } + + private CoverageMap fromJson(JsonObject root) + { + List classes = stringList(root, "classIndex"), methods = stringList(root, "methodIndex"), containers = stringList(root, "containerIndex"); + List identities = stringList(root, "testIndex").stream().map(TestIdentity::parse).toList(); + Map tests = new LinkedHashMap<>(); + for (JsonElement element : root.getAsJsonArray("tests")) + { + JsonObject value = element.getAsJsonObject(); TestIdentity identity = identities.get(requiredInt(value, "test")); + if (tests.containsKey(identity)) throw invalid(ValidationCategory.INVALID_STRUCTURE, ValidationCode.DUPLICATE_TEST_IDENTITY, "Duplicate test: " + identity); + Set coveredClasses = resolve(value.getAsJsonArray("classes"), classes); + Set coveredMethods = resolve(value.getAsJsonArray("methods"), methods); + tests.put(identity, new TestCoverage(coveredClasses, coveredMethods, + TestOutcome.valueOf(requiredString(value, "outcome")), + CollectionStatus.valueOf(requiredString(value, "collectionStatus")))); + } + List unmapped = new ArrayList<>(); + for (JsonElement element : root.getAsJsonArray("unmapped")) + { + JsonObject value = element.getAsJsonObject(); + if (!value.has("reason")) throw invalid(ValidationCategory.INVALID_STRUCTURE, ValidationCode.UNMAPPED_REASON_MISSING, "Unmapped reason is required"); + unmapped.add(new UnmappedTest(identities.get(requiredInt(value, "test")), UnmappedReason.valueOf(requiredString(value, "reason")))); + } + List scopes = new ArrayList<>(); Set scopeIds = new HashSet<>(); + for (JsonElement element : root.getAsJsonArray("setupScopes")) + { + JsonObject value = element.getAsJsonObject(); String id = requiredString(value, "id"); + if (!scopeIds.add(id)) throw invalid(ValidationCategory.INVALID_STRUCTURE, ValidationCode.DUPLICATE_SETUP_SCOPE_ID, "Duplicate setup scope: " + id); + Set affected = new TreeSet<>(); for (String container : resolve(value.getAsJsonArray("affectedContainers"), containers)) affected.add(new TestContainer(container)); + if (affected.isEmpty()) throw invalid(ValidationCategory.INVALID_STRUCTURE, ValidationCode.EMPTY_AFFECTED_CONTAINERS, "Setup scope has no affected containers"); + scopes.add(new SetupScope(id, SetupScopeType.valueOf(requiredString(value, "type")), resolve(value.getAsJsonArray("coveredClasses"), classes), affected)); + } + Completeness completeness = root.has("completeness") && !root.get("completeness").isJsonNull() + ? completenessFromJson(root.getAsJsonObject("completeness"), identities) : null; + GeneratorProvenance generator = GSON.fromJson(root.get("generator"), GeneratorProvenance.class); + MapStatistics statistics = GSON.fromJson(root.get("statistics"), MapStatistics.class); + MethodCoverageReference reference = root.has("methodCoverage") ? GSON.fromJson(root.get("methodCoverage"), MethodCoverageReference.class) : null; + return new CoverageMap(requiredInt(root, "schemaVersion"), new CoverageMapRevision(requiredString(root, "revision")), + Instant.parse(requiredString(root, "generatedAt")), generator, tests, unmapped, scopes, completeness, statistics, + CoverageMapLifecycleState.valueOf(requiredString(root, "lifecycleState")), reference); + } + + private JsonObject completenessToJson(Completeness value, Map testIds) + { + JsonObject json = new JsonObject(); json.add("expectedTests", testIndexes(value.expectedTests(), testIds)); json.add("reportedTests", testIndexes(value.reportedTests(), testIds)); + json.add("expectedShards", strings(value.expectedShards().stream().map(ShardId::value).sorted().toList())); + json.add("completedShards", strings(value.completedShards().stream().map(ShardId::value).sorted().toList())); + json.add("missingTests", testIndexes(value.missingTests(), testIds)); json.add("unexpectedTests", testIndexes(value.unexpectedTests(), testIds)); + json.add("missingShards", strings(value.missingShards().stream().map(ShardId::value).sorted().toList())); + json.add("duplicateShards", strings(value.duplicateShards().stream().map(ShardId::value).sorted().toList())); + json.add("duplicateTests", testIndexes(value.duplicateTests(), testIds)); return json; + } + + private Completeness completenessFromJson(JsonObject json, List identities) + { + return new Completeness(testSet(json, "expectedTests", identities), testSet(json, "reportedTests", identities), + shards(json, "expectedShards"), shards(json, "completedShards"), testSet(json, "missingTests", identities), + testSet(json, "unexpectedTests", identities), shards(json, "missingShards"), shards(json, "duplicateShards"), + testSet(json, "duplicateTests", identities)); + } + + private static Map ids(List values) + { + Map result = new LinkedHashMap<>(); + for (int i = 0; i < values.size(); i++) + { + result.put(values.get(i), i); + } + return result; + } + private static JsonArray strings(List values) { JsonArray result = new JsonArray(); values.forEach(result::add); return result; } + private static JsonArray indexes(Set values, Map ids) { JsonArray result = new JsonArray(); values.stream().sorted().forEach(value -> result.add(ids.get(value))); return result; } + private static JsonArray testIndexes(Set values, Map ids) { JsonArray result = new JsonArray(); values.stream().sorted().forEach(value -> result.add(ids.get(value))); return result; } + private static List stringList(JsonObject object, String name) { List result = new ArrayList<>(); object.getAsJsonArray(name).forEach(value -> result.add(value.getAsString())); return result; } + private static Set resolve(JsonArray indexes, List table) { TreeSet result = new TreeSet<>(); indexes.forEach(index -> result.add(table.get(index.getAsInt()))); return result; } + private static Set testSet(JsonObject object, String name, List table) { TreeSet result = new TreeSet<>(); object.getAsJsonArray(name).forEach(index -> result.add(table.get(index.getAsInt()))); return result; } + private static Set shards(JsonObject object, String name) { TreeSet result = new TreeSet<>(); object.getAsJsonArray(name).forEach(value -> result.add(new ShardId(value.getAsString()))); return result; } + private static int requiredInt(JsonObject object, String name) { return object.get(name).getAsInt(); } + private static String requiredString(JsonObject object, String name) { if (!object.has(name) || object.get(name).isJsonNull()) throw new IllegalArgumentException(name + " is required"); return object.get(name).getAsString(); } + private static byte[] bytes(JsonObject json) { return GSON.toJson(json).getBytes(StandardCharsets.UTF_8); } + private static String checksum(byte[] bytes) + { + try + { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes); + StringBuilder result = new StringBuilder("sha256:"); + for (byte value : digest) + { + result.append(String.format("%02x", value)); + } + return result.toString(); + } + catch (Exception e) { throw new IllegalStateException(e); } + } + private static CoverageMapValidationException invalid(ValidationCategory category, ValidationCode code, String message) { return new CoverageMapValidationException(CoverageMapValidator.error(category, code, message)); } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/CoverageMapValidationException.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/CoverageMapValidationException.java new file mode 100644 index 00000000..165bcc18 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/CoverageMapValidationException.java @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.validation; + +public class CoverageMapValidationException extends IllegalArgumentException +{ + private final ValidationError error; + + public CoverageMapValidationException(ValidationError error) + { + super(error.message()); + this.error = error; + } + + public ValidationError getError() { return error; } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/CoverageMapValidator.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/CoverageMapValidator.java new file mode 100644 index 00000000..7113ef56 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/CoverageMapValidator.java @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.validation; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; + +import com.sap.oss.smarttestpicker.coverage.CoverageMapContract; +import com.sap.oss.smarttestpicker.coverage.model.CoverageMap; +import com.sap.oss.smarttestpicker.coverage.model.CoverageMapLifecycleState; +import com.sap.oss.smarttestpicker.coverage.model.ShardId; +import com.sap.oss.smarttestpicker.coverage.model.TestIdentity; + +public final class CoverageMapValidator +{ + private CoverageMapValidator() {} + + public static ValidationResult validate(CoverageMap map) + { + ArrayList errors = new ArrayList<>(); + if (map.schemaVersion() != CoverageMapContract.SCHEMA_VERSION) + errors.add(error(ValidationCategory.INCOMPATIBLE_SCHEMA, + map.schemaVersion() > CoverageMapContract.SCHEMA_VERSION ? ValidationCode.HIGHER_SCHEMA_VERSION : ValidationCode.SCHEMA_VERSION_MISMATCH, + "Unsupported coverage-map schema version: " + map.schemaVersion())); + if (map.revision() == null) + errors.add(error(ValidationCategory.INVALID_STRUCTURE, ValidationCode.MISSING_REVISION, "revision is required")); + + Set unmapped = new HashSet<>(); + map.unmapped().forEach(entry -> unmapped.add(entry.test())); + Set overlap = new HashSet<>(map.tests().keySet()); overlap.retainAll(unmapped); + if (!overlap.isEmpty()) + errors.add(error(ValidationCategory.INVALID_STRUCTURE, ValidationCode.TEST_MAPPED_AND_UNMAPPED, + "Tests cannot be both mapped and unmapped: " + overlap)); + + Set scopeIds = new HashSet<>(); + map.setupScopes().forEach(scope -> { + if (!scopeIds.add(scope.id())) errors.add(error(ValidationCategory.INVALID_STRUCTURE, + ValidationCode.DUPLICATE_SETUP_SCOPE_ID, "Duplicate setup scope: " + scope.id())); + }); + + if (map.completeness() != null) + { + if (!map.completeness().missingTests().isEmpty()) errors.add(error(ValidationCategory.INCOMPLETE_MAP, + ValidationCode.MISSING_TEST, "Missing tests: " + map.completeness().missingTests())); + if (!map.completeness().unexpectedTests().isEmpty()) errors.add(error(ValidationCategory.INCOMPLETE_MAP, + ValidationCode.UNEXPECTED_TEST, "Unexpected tests: " + map.completeness().unexpectedTests())); + if (!map.completeness().missingShards().isEmpty()) errors.add(error(ValidationCategory.INCOMPLETE_MAP, + ValidationCode.MISSING_SHARD, "Missing shards: " + map.completeness().missingShards())); + if (!map.completeness().duplicateShards().isEmpty()) errors.add(error(ValidationCategory.INCOMPLETE_MAP, + ValidationCode.DUPLICATE_SHARD, "Duplicate shards: " + map.completeness().duplicateShards())); + if (!map.completeness().duplicateTests().isEmpty()) errors.add(error(ValidationCategory.INCOMPLETE_MAP, + ValidationCode.DUPLICATE_TEST_IDENTITY, "Duplicate tests: " + map.completeness().duplicateTests())); + Set unexpectedCompleted = new HashSet<>(map.completeness().completedShards()); + unexpectedCompleted.removeAll(map.completeness().expectedShards()); + if (!unexpectedCompleted.isEmpty()) errors.add(error(ValidationCategory.INCOMPLETE_MAP, + ValidationCode.UNEXPECTED_COMPLETED_SHARD, "Unexpected completed shards: " + unexpectedCompleted)); + } + if (map.lifecycleState() != CoverageMapLifecycleState.PUBLISHED) + errors.add(error(ValidationCategory.INVALID_STRUCTURE, ValidationCode.LIFECYCLE_NOT_PUBLISHED, + "Only PUBLISHED maps may be selector input")); + return new ValidationResult(errors); + } + + public static ValidationError error(ValidationCategory category, ValidationCode code, String message) + { + return new ValidationError(category, code, message); + } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationCategory.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationCategory.java new file mode 100644 index 00000000..6770e478 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationCategory.java @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.validation; + +public enum ValidationCategory +{ + INCOMPATIBLE_SCHEMA, + CORRUPT_MAP, + INVALID_STRUCTURE, + INCOMPLETE_MAP +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationCode.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationCode.java new file mode 100644 index 00000000..9ecd981e --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationCode.java @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.validation; + +public enum ValidationCode +{ + SCHEMA_VERSION_MISMATCH, + HIGHER_SCHEMA_VERSION, + MISSING_REVISION, + MALFORMED_TEST_IDENTITY, + TEST_MAPPED_AND_UNMAPPED, + UNMAPPED_REASON_MISSING, + EMPTY_AFFECTED_CONTAINERS, + DUPLICATE_SETUP_SCOPE_ID, + DUPLICATE_TEST_IDENTITY, + UNEXPECTED_TEST, + MISSING_TEST, + UNEXPECTED_COMPLETED_SHARD, + MISSING_SHARD, + DUPLICATE_SHARD, + CHECKSUM_MISMATCH, + LIFECYCLE_NOT_PUBLISHED +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationError.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationError.java new file mode 100644 index 00000000..1ca02997 --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationError.java @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.validation; + +public record ValidationError(ValidationCategory category, ValidationCode code, String message) {} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationResult.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationResult.java new file mode 100644 index 00000000..dfab0aed --- /dev/null +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/coverage/validation/ValidationResult.java @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage.validation; + +import java.util.List; + +public record ValidationResult(List errors) +{ + public ValidationResult { errors = List.copyOf(errors); } + public boolean isValid() { return errors.isEmpty(); } + public boolean has(ValidationCode code) { return errors.stream().anyMatch(error -> error.code() == code); } + public static ValidationResult valid() { return new ValidationResult(List.of()); } +} diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/engine/CoverageMapEngine.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/engine/CoverageMapEngine.java index 41bc904a..a1851774 100644 --- a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/engine/CoverageMapEngine.java +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/engine/CoverageMapEngine.java @@ -15,6 +15,7 @@ import java.util.List; import java.util.Map; import java.util.TreeSet; +import java.util.TreeMap; import java.util.zip.GZIPOutputStream; import com.google.gson.Gson; @@ -150,7 +151,7 @@ private void writeIndexed(CoverageMapMetadata metadata, Map indexedMappings = new HashMap<>(); + Map indexedMappings = new TreeMap<>(); for (Map.Entry>> entry : testCoverage.entrySet()) { Map> coverage = entry.getValue(); diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/mapper/CoverageMap.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/mapper/CoverageMap.java index a2d69fff..7075acfb 100644 --- a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/mapper/CoverageMap.java +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/mapper/CoverageMap.java @@ -2,9 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 package com.sap.oss.smarttestpicker.mapper; -import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.TreeMap; /** @@ -40,13 +40,13 @@ public class CoverageMap public CoverageMap() { - this.testMappings = new HashMap<>(); + this.testMappings = new TreeMap<>(); } public CoverageMap(CoverageMapMetadata metadata, Map>> testMappings) { this.metadata = metadata; - this.testMappings = testMappings; + this.testMappings = new TreeMap<>(testMappings); } public CoverageMapMetadata getMetadata() @@ -66,7 +66,7 @@ public Map>> getTestMappings() public void setTestMappings(Map>> testMappings) { - this.testMappings = testMappings; + this.testMappings = new TreeMap<>(testMappings); } public Map getClassMetrics() diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/mapper/CoverageMapReader.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/mapper/CoverageMapReader.java index 572a7ad3..14b1c22a 100644 --- a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/mapper/CoverageMapReader.java +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/mapper/CoverageMapReader.java @@ -11,9 +11,9 @@ import java.io.Reader; import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.TreeMap; import java.util.zip.GZIPInputStream; import com.google.gson.Gson; @@ -102,7 +102,7 @@ private static CoverageMap resolveIndexed(JsonObject root) methodIndex.add(el.getAsString()); } - Map>> testMappings = new HashMap<>(); + Map>> testMappings = new TreeMap<>(); JsonObject mappings = root.getAsJsonObject("testMappings"); for (String testName : mappings.keySet()) @@ -127,7 +127,7 @@ private static CoverageMap resolveIndexed(JsonObject root) } } - Map> coverage = new HashMap<>(); + Map> coverage = new TreeMap<>(); coverage.put("classes", classes); coverage.put("methods", methods); testMappings.put(testName, coverage); diff --git a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/mapper/CoverageMapperJaxb.java b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/mapper/CoverageMapperJaxb.java index 63956e08..03c05b0f 100644 --- a/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/mapper/CoverageMapperJaxb.java +++ b/smart-test-picker-common/src/main/java/com/sap/oss/smarttestpicker/mapper/CoverageMapperJaxb.java @@ -10,11 +10,11 @@ import java.io.StringReader; import java.util.ArrayList; import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeMap; import org.xml.sax.InputSource; import org.xml.sax.XMLReader; @@ -51,7 +51,7 @@ public class CoverageMapperJaxb private final File reportsDir; /** Per-class aggregated coverage metrics, populated during mapping. */ - private Map classMetrics = new HashMap<>(); + private Map classMetrics = new TreeMap<>(); /** * Creates a mapper for the given reports directory. @@ -101,7 +101,7 @@ private static JAXBContext initContext() { */ public Map>> generateTestCoverageMapping() { - Map>> testMap = new HashMap<>(); + Map>> testMap = new TreeMap<>(); File[] xmlFiles = reportsDir.listFiles((dir, name) -> name.endsWith(".xml")); if (xmlFiles == null) @@ -158,7 +158,7 @@ public Map>> generateTestCoverageMapping() List sortedMethods = new ArrayList<>(coveredMethods); Collections.sort(sortedMethods); - Map> coverage = new HashMap<>(); + Map> coverage = new TreeMap<>(); coverage.put("classes", sortedClasses); coverage.put("methods", sortedMethods); diff --git a/smart-test-picker-common/src/test/java/com/sap/oss/smarttestpicker/coverage/CoverageMapContractTest.java b/smart-test-picker-common/src/test/java/com/sap/oss/smarttestpicker/coverage/CoverageMapContractTest.java new file mode 100644 index 00000000..7fa8d61e --- /dev/null +++ b/smart-test-picker-common/src/test/java/com/sap/oss/smarttestpicker/coverage/CoverageMapContractTest.java @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sap.oss.smarttestpicker.coverage.model.CollectionStatus; +import com.sap.oss.smarttestpicker.coverage.model.CollectionSummary; +import com.sap.oss.smarttestpicker.coverage.model.CollectionExpectation; +import com.sap.oss.smarttestpicker.coverage.model.Completeness; +import com.sap.oss.smarttestpicker.coverage.model.CoverageFragment; +import com.sap.oss.smarttestpicker.coverage.model.CoverageMap; +import com.sap.oss.smarttestpicker.coverage.model.CoverageMapLifecycleState; +import com.sap.oss.smarttestpicker.coverage.model.CoverageMapRevision; +import com.sap.oss.smarttestpicker.coverage.model.GeneratorProvenance; +import com.sap.oss.smarttestpicker.coverage.model.MapStatistics; +import com.sap.oss.smarttestpicker.coverage.model.MethodCoverageReference; +import com.sap.oss.smarttestpicker.coverage.model.SetupScope; +import com.sap.oss.smarttestpicker.coverage.model.SetupScopeType; +import com.sap.oss.smarttestpicker.coverage.model.ShardId; +import com.sap.oss.smarttestpicker.coverage.model.TestContainer; +import com.sap.oss.smarttestpicker.coverage.model.TestCoverage; +import com.sap.oss.smarttestpicker.coverage.model.TestIdentity; +import com.sap.oss.smarttestpicker.coverage.model.TestInventory; +import com.sap.oss.smarttestpicker.coverage.model.TestOutcome; +import com.sap.oss.smarttestpicker.coverage.model.UnmappedReason; +import com.sap.oss.smarttestpicker.coverage.model.UnmappedTest; +import com.sap.oss.smarttestpicker.coverage.serialization.CoverageFragmentCodec; +import com.sap.oss.smarttestpicker.coverage.serialization.CoverageMapCodec; +import com.sap.oss.smarttestpicker.coverage.validation.CoverageMapValidationException; +import com.sap.oss.smarttestpicker.coverage.validation.CoverageMapValidator; +import com.sap.oss.smarttestpicker.coverage.validation.ValidationCategory; +import com.sap.oss.smarttestpicker.coverage.validation.ValidationCode; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class CoverageMapContractTest +{ + private static final TestIdentity FOO = TestIdentity.parse("com.foo.FooTest#a"); + private static final TestIdentity BAR = TestIdentity.parse("com.foo.BarTest#b"); + private static final TestIdentity BAZ = TestIdentity.parse("com.foo.OuterTest$Inner#nestedTest"); + + @Test void contractVersionsHaveOneCoreAuthority() + { + assertEquals(1, CoverageMapContract.SCHEMA_VERSION); + assertEquals(1, ExecutionPlanContract.VERSION); + } + + @Test void testIdentityUsesFqnAndBinaryNestedName() + { + assertEquals("com.foo.OuterTest$Inner#nestedTest", BAZ.toString()); + assertThrows(IllegalArgumentException.class, () -> TestIdentity.parse("FooTest#test")); + } + + @Test void constructedMapIsImmutable() + { + Map mutable = new HashMap<>(); mutable.put(FOO, covered("com.foo.Service")); + CoverageMap map = map(mutable, List.of(), List.of()); mutable.clear(); + assertEquals(1, map.tests().size()); assertThrows(UnsupportedOperationException.class, () -> map.tests().clear()); + } + + @Test void deterministicSerializationRoundTripAndChecksum() + { + CoverageMapCodec codec = new CoverageMapCodec(); CoverageMap first = sampleMap(); + byte[] one = codec.serialize(first); byte[] two = codec.serialize(first); + assertArrayEquals(one, two); + assertEquals(checksum(one), checksum(two)); + CoverageMap decoded = codec.deserialize(one); + assertArrayEquals(one, codec.serialize(decoded)); + assertEquals(first.tests(), decoded.tests()); + JsonObject json = JsonParser.parseString(new String(one, StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals(List.of("com.foo.BarTest#b", "com.foo.FooTest#a"), json.getAsJsonArray("testIndex").asList().stream().map(e -> e.getAsString()).toList()); + } + + @Test void candidateWithoutIndependentInventoryRoundTripsButIsNotPublishable() + { + CoverageMap published = sampleMap(); + CoverageMap candidate = new CoverageMap(published.schemaVersion(), published.revision(), published.generatedAt(), + published.generator(), published.tests(), published.unmapped(), published.setupScopes(), null, + published.statistics(), CoverageMapLifecycleState.CANDIDATE, null); + CoverageMapCodec codec = new CoverageMapCodec(); + CoverageMap decoded = codec.deserialize(codec.serialize(candidate)); + assertNull(decoded.completeness()); + assertEquals(CoverageMapLifecycleState.CANDIDATE, decoded.lifecycleState()); + assertTrue(CoverageMapValidator.validate(decoded).has(ValidationCode.LIFECYCLE_NOT_PUBLISHED)); + } + + @Test void checksumChangesWithSemanticContentAndRejectsCorruption() + { + CoverageMapCodec codec = new CoverageMapCodec(); byte[] original = codec.serialize(sampleMap()); + byte[] changed = codec.serialize(map(Map.of(FOO, covered("com.foo.Different")), List.of(new UnmappedTest(BAR, UnmappedReason.FAILED)), List.of())); + assertNotEquals(checksum(original), checksum(changed)); + String corrupt = new String(original, StandardCharsets.UTF_8).replace("com.foo.Service", "com.foo.ServiceX"); + CoverageMapValidationException error = assertThrows(CoverageMapValidationException.class, () -> codec.deserialize(corrupt.getBytes(StandardCharsets.UTF_8))); + assertEquals(ValidationCode.CHECKSUM_MISMATCH, error.getError().code()); assertEquals(ValidationCategory.CORRUPT_MAP, error.getError().category()); + } + + @Test void higherSchemaFailsBeforePartialParsing() + { + byte[] bytes = new CoverageMapCodec().serialize(sampleMap()); String json = new String(bytes, StandardCharsets.UTF_8).replace("\"schemaVersion\":1", "\"schemaVersion\":2"); + CoverageMapValidationException error = assertThrows(CoverageMapValidationException.class, () -> new CoverageMapCodec().deserialize(json.getBytes(StandardCharsets.UTF_8))); + assertEquals(ValidationCode.HIGHER_SCHEMA_VERSION, error.getError().code()); assertEquals(ValidationCategory.INCOMPATIBLE_SCHEMA, error.getError().category()); + } + + @Test void validationCategoriesSeparateSchemaStructureAndCompleteness() + { + CoverageMap base = sampleMap(); + CoverageMap oldSchema = new CoverageMap(0, base.revision(), base.generatedAt(), base.generator(), base.tests(), base.unmapped(), base.setupScopes(), base.completeness(), base.statistics(), base.lifecycleState(), null); + assertEquals(ValidationCategory.INCOMPATIBLE_SCHEMA, CoverageMapValidator.validate(oldSchema).errors().get(0).category()); + CoverageMap missingRevision = new CoverageMap(1, null, base.generatedAt(), base.generator(), base.tests(), base.unmapped(), base.setupScopes(), base.completeness(), base.statistics(), base.lifecycleState(), null); + assertTrue(CoverageMapValidator.validate(missingRevision).has(ValidationCode.MISSING_REVISION)); + Completeness incomplete = Completeness.from(expectation(Set.of(FOO, BAR), Set.of(new ShardId("01"))), + new CollectionSummary(Set.of(FOO), List.of(new ShardId("01")), Set.of())); + assertEquals(ValidationCategory.INCOMPLETE_MAP, CoverageMapValidator.validate(withCompleteness(incomplete)).errors().stream().filter(e -> e.code() == ValidationCode.MISSING_TEST).findFirst().orElseThrow().category()); + } + + @Test void setupScopesSupportOneManyAndOverlap() + { + SetupScope one = scope("one", Set.of(new TestContainer("com.foo.FooTest"))); + SetupScope many = scope("many", Set.of(new TestContainer("com.foo.FooTest"), new TestContainer("com.foo.BarTest"))); + CoverageMap decoded = new CoverageMapCodec().deserialize(new CoverageMapCodec().serialize(map(Map.of(FOO, covered("com.foo.Service")), List.of(new UnmappedTest(BAR, UnmappedReason.SKIPPED)), List.of(one, many)))); + assertEquals(2, decoded.setupScopes().size()); assertEquals(2, decoded.setupScopes().get(0).affectedContainers().size()); + assertThrows(IllegalArgumentException.class, () -> scope("empty", Set.of())); + } + + @Test void duplicateSetupScopeIdIsRejected() + { + SetupScope scope = scope("same", Set.of(new TestContainer("com.foo.FooTest"))); + CoverageMap invalid = map(Map.of(FOO, covered("com.foo.Service")), List.of(new UnmappedTest(BAR, UnmappedReason.FAILED)), List.of(scope, scope)); + assertTrue(CoverageMapValidator.validate(invalid).has(ValidationCode.DUPLICATE_SETUP_SCOPE_ID)); + } + + @Test void mappedAndUnmappedOverlapIsRejectedAndAllReasonsRoundTrip() + { + for (UnmappedReason reason : UnmappedReason.values()) + { + CoverageMap decoded = new CoverageMapCodec().deserialize(new CoverageMapCodec().serialize(map(Map.of(FOO, covered("com.foo.Service")), List.of(new UnmappedTest(BAR, reason)), List.of()))); + assertEquals(reason, decoded.unmapped().get(0).reason()); assertFalse(decoded.tests().containsKey(BAR)); + } + CoverageMap overlap = map(Map.of(FOO, covered("com.foo.Service"), BAR, covered("com.foo.Other")), List.of(new UnmappedTest(BAR, UnmappedReason.FAILED)), List.of()); + assertTrue(CoverageMapValidator.validate(overlap).has(ValidationCode.TEST_MAPPED_AND_UNMAPPED)); + } + + @Test void completenessUsesIdentitiesNotCountsAndUnmappedCountsAsReported() + { + CollectionExpectation expectation = expectation(Set.of(FOO, BAR), Set.of(new ShardId("01"))); + Completeness complete = Completeness.from(expectation, new CollectionSummary(Set.of(FOO, BAR), List.of(new ShardId("01")), Set.of())); + assertTrue(complete.isComplete()); + Completeness sameCountDifferentMembers = Completeness.from(expectation, new CollectionSummary(Set.of(FOO, BAZ), List.of(new ShardId("01")), Set.of())); + assertFalse(sameCountDifferentMembers.isComplete()); assertEquals(Set.of(BAR), sameCountDifferentMembers.missingTests()); assertEquals(Set.of(BAZ), sameCountDifferentMembers.unexpectedTests()); + } + + @Test void missingDuplicateAndUnexpectedShardsAreDistinguished() + { + CollectionExpectation expectation = expectation(Set.of(FOO), Set.of(new ShardId("01"), new ShardId("02"))); + Completeness value = Completeness.from(expectation, new CollectionSummary(Set.of(FOO), + List.of(new ShardId("01"), new ShardId("01"), new ShardId("99")), Set.of(FOO))); + assertEquals(Set.of(new ShardId("02")), value.missingShards()); assertEquals(Set.of(new ShardId("01")), value.duplicateShards()); assertEquals(Set.of(FOO), value.duplicateTests()); + CoverageMap invalid = withCompleteness(value); + assertTrue(CoverageMapValidator.validate(invalid).has(ValidationCode.UNEXPECTED_COMPLETED_SHARD)); + } + + @Test void collectorCannotHideAnExpectedShard() + { + CollectionExpectation realExpectation = expectation(Set.of(FOO), + Set.of(new ShardId("A"), new ShardId("B"), new ShardId("C"))); + CollectionSummary internallyConsistentCollectorOutput = new CollectionSummary(Set.of(FOO), + List.of(new ShardId("A"), new ShardId("B")), Set.of()); + Completeness completeness = Completeness.from(realExpectation, internallyConsistentCollectorOutput); + assertFalse(completeness.isComplete()); + assertEquals(Set.of(new ShardId("C")), completeness.missingShards()); + } + + @Test void collectionStatusesAreDistinct() + { + assertDoesNotThrow(() -> new TestCoverage(Set.of(), Set.of(), TestOutcome.PASS, CollectionStatus.COLLECTED_EMPTY)); + assertThrows(IllegalArgumentException.class, () -> new TestCoverage(Set.of(), Set.of(), TestOutcome.PASS, CollectionStatus.COLLECTION_FAILED)); + } + + @Test void failedTestOutcomeDoesNotDiscardValidCoverageOrChangeCompleteness() + { + TestCoverage failedWithCoverage = new TestCoverage(Set.of("com.foo.Service"), + Set.of("com.foo.Service#run"), TestOutcome.FAIL, CollectionStatus.COLLECTED_WITH_COVERAGE); + TestCoverage failedWithEmptyCollection = new TestCoverage(Set.of(), Set.of(), TestOutcome.FAIL, + CollectionStatus.COLLECTED_EMPTY); + CoverageMap roundTripped = new CoverageMapCodec().deserialize(new CoverageMapCodec().serialize( + map(Map.of(FOO, failedWithCoverage, BAR, failedWithEmptyCollection), List.of(), List.of()))); + assertEquals(TestOutcome.FAIL, roundTripped.tests().get(FOO).outcome()); + assertEquals(CollectionStatus.COLLECTED_WITH_COVERAGE, roundTripped.tests().get(FOO).collectionStatus()); + assertEquals(CollectionStatus.COLLECTED_EMPTY, roundTripped.tests().get(BAR).collectionStatus()); + assertTrue(roundTripped.completeness().isComplete()); + } + + @Test void collectionFailureIsUnmappedAndDistinctFromTestFailure() + { + CoverageMap map = map(Map.of(FOO, new TestCoverage(Set.of("com.foo.Service"), Set.of(), + TestOutcome.FAIL, CollectionStatus.COLLECTED_WITH_COVERAGE)), + List.of(new UnmappedTest(BAR, UnmappedReason.COLLECTION_FAILED)), List.of()); + assertTrue(map.tests().containsKey(FOO)); + assertFalse(map.tests().containsKey(BAR)); + assertEquals(UnmappedReason.COLLECTION_FAILED, map.unmapped().get(0).reason()); + } + + @Test void provisionalFragmentRoundTripsIncludingIncompleteAndUnmapped() + { + CoverageFragment fragment = new CoverageFragment(CoverageMapContract.SCHEMA_VERSION, new CoverageMapRevision("abc123"), new ShardId("07"), Map.of(FOO, covered("com.foo.Service")), List.of(new UnmappedTest(BAR, UnmappedReason.COLLECTION_FAILED)), List.of(scope("scope-1", Set.of(new TestContainer("com.foo.FooTest"), new TestContainer("com.foo.BarTest")))), false); + CoverageFragment decoded = new CoverageFragmentCodec().deserialize(new CoverageFragmentCodec().serialize(fragment)); + assertFalse(decoded.collectionCompleted()); assertEquals(fragment.revision(), decoded.revision()); assertEquals(UnmappedReason.COLLECTION_FAILED, decoded.unmapped().get(0).reason()); assertEquals(2, decoded.setupScopes().get(0).affectedContainers().size()); + CoverageFragment other = new CoverageFragment(1, new CoverageMapRevision("other"), new ShardId("08"), Map.of(), List.of(), List.of(), true); + assertFalse(fragment.hasSameRevision(other)); + } + + @Test void separableMethodCoverageDescriptorRoundTrips() + { + String hash = "sha256:" + "a".repeat(64); CoverageMap base = sampleMap(); + CoverageMap withReference = new CoverageMap(base.schemaVersion(), base.revision(), base.generatedAt(), base.generator(), base.tests(), base.unmapped(), base.setupScopes(), base.completeness(), base.statistics(), base.lifecycleState(), new MethodCoverageReference("coverage-methods.json.gz", hash)); + assertEquals(hash, new CoverageMapCodec().deserialize(new CoverageMapCodec().serialize(withReference)).methodCoverageReference().sha256()); + } + + private static CoverageMap sampleMap() { return map(Map.of(FOO, covered("com.foo.Service")), List.of(new UnmappedTest(BAR, UnmappedReason.FAILED)), List.of()); } + private static CoverageMap map(Map tests, List unmapped, List scopes) + { + Set reported = new java.util.HashSet<>(tests.keySet()); unmapped.forEach(value -> reported.add(value.test())); + Completeness completeness = Completeness.from(expectation(reported, Set.of(new ShardId("01"))), + new CollectionSummary(reported, List.of(new ShardId("01")), Set.of())); + return new CoverageMap(1, new CoverageMapRevision("abc123"), Instant.parse("2026-08-30T10:00:00Z"), new GeneratorProvenance("0.2.0", "0.1.0", "0.8.15", "17"), tests, unmapped, scopes, completeness, new MapStatistics(reported.size(), tests.size(), unmapped.size(), scopes.size(), 1, 1), CoverageMapLifecycleState.PUBLISHED, null); + } + private static CoverageMap withCompleteness(Completeness value) + { + return new CoverageMap(1, new CoverageMapRevision("abc123"), Instant.parse("2026-08-30T10:00:00Z"), new GeneratorProvenance("0.2.0", "0.1.0", "0.8.15", "17"), Map.of(FOO, covered("com.foo.Service")), List.of(), List.of(), value, new MapStatistics(1, 1, 0, 0, 1, 1), CoverageMapLifecycleState.PUBLISHED, null); + } + private static CollectionExpectation expectation(Set tests, Set shards) + { + return new CollectionExpectation(new TestInventory(new CoverageMapRevision("abc123"), tests), shards); + } + private static TestCoverage covered(String cls) { return new TestCoverage(Set.of(cls), Set.of(cls + "#run"), TestOutcome.PASS, CollectionStatus.COLLECTED_WITH_COVERAGE); } + private static SetupScope scope(String id, Set containers) { return new SetupScope(id, SetupScopeType.CONTAINER, Set.of("com.foo.Config"), containers); } + private static String checksum(byte[] json) { return JsonParser.parseString(new String(json, StandardCharsets.UTF_8)).getAsJsonObject().get("checksum").getAsString(); } +} diff --git a/smart-test-picker-common/src/test/java/com/sap/oss/smarttestpicker/coverage/JGraphTCoverageMapV1IntegrationTest.java b/smart-test-picker-common/src/test/java/com/sap/oss/smarttestpicker/coverage/JGraphTCoverageMapV1IntegrationTest.java new file mode 100644 index 00000000..2f5d63c6 --- /dev/null +++ b/smart-test-picker-common/src/test/java/com/sap/oss/smarttestpicker/coverage/JGraphTCoverageMapV1IntegrationTest.java @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: 2024-2026 SAP SE or an SAP affiliate company and Smart Test Picker contributors +// SPDX-License-Identifier: Apache-2.0 +package com.sap.oss.smarttestpicker.coverage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sap.oss.smarttestpicker.coverage.model.CollectionStatus; +import com.sap.oss.smarttestpicker.coverage.model.CoverageMap; +import com.sap.oss.smarttestpicker.coverage.model.CoverageMapLifecycleState; +import com.sap.oss.smarttestpicker.coverage.model.CoverageMapRevision; +import com.sap.oss.smarttestpicker.coverage.model.GeneratorProvenance; +import com.sap.oss.smarttestpicker.coverage.model.MapStatistics; +import com.sap.oss.smarttestpicker.coverage.model.TestCoverage; +import com.sap.oss.smarttestpicker.coverage.model.TestIdentity; +import com.sap.oss.smarttestpicker.coverage.model.TestOutcome; +import com.sap.oss.smarttestpicker.coverage.serialization.CoverageMapCodec; +import com.sap.oss.smarttestpicker.coverage.validation.CoverageMapValidationException; +import com.sap.oss.smarttestpicker.coverage.validation.CoverageMapValidator; +import com.sap.oss.smarttestpicker.coverage.validation.ValidationCategory; +import com.sap.oss.smarttestpicker.coverage.validation.ValidationCode; +import org.junit.jupiter.api.Test; + +/** Real-data validation. Reads existing evaluation/source trees and never runs JGraphT. */ +class JGraphTCoverageMapV1IntegrationTest +{ + private static final Pattern LEGACY_KEY = Pattern.compile("^(.+)#(.+)_([0-9a-f]{7})$"); + private static final Pattern PACKAGE = Pattern.compile("(?m)^package\\s+([\\w.]+)\\s*;"); + private static final Pattern JUNIT_ID = Pattern.compile("\\[class:([\\w.$]+)]/\\[method:([\\w$]+)\\(\\)]"); + + @Test void realJGraphTMapSurvivesSchemaV1RoundTrip() throws Exception + { + Path workspace = workspace(); + Path sourceMap = configured("stp.jgraphtCoverageMap", + workspace.resolve("../smart-test-picker-evaluation/jgrapht/results/test-coverage-map.json.gz")); + Path sourceRoot = configured("stp.jgraphtSourceRoot", workspace.resolve("../jgrapht")); + assumeTrue(Files.isRegularFile(sourceMap) && Files.isDirectory(sourceRoot), + "JGraphT evaluation data is optional; set stp.jgraphtCoverageMap and stp.jgraphtSourceRoot"); + + long constructionStart = System.nanoTime(); + JsonObject legacy = readGzipJson(sourceMap); + Map> fqnsByOuterSimpleName = sourceFqns(sourceRoot); + Map> archivedFqnEvidence = archivedFqnEvidence(sourceMap.getParent()); + Conversion conversion = convert(legacy, fqnsByOuterSimpleName, archivedFqnEvidence); + LegacyStats legacyStats = legacyStats(legacy); + long constructionNanos = System.nanoTime() - constructionStart; + + CoverageMap map = candidate(legacy, conversion.tests()); + assertNull(map.completeness(), "legacy data has no independent inventory"); + assertTrue(map.setupScopes().isEmpty(), "setup information is unavailable in the source dataset"); + assertEquals(CoverageMapLifecycleState.CANDIDATE, map.lifecycleState()); + assertTrue(CoverageMapValidator.validate(map).has(ValidationCode.LIFECYCLE_NOT_PUBLISHED)); + + CoverageMapCodec codec = new CoverageMapCodec(); + byte[] first = timed(3, ignored -> codec.serialize(map)).value(); + byte[] second = codec.serialize(map); + assertArrayEquals(first, second); + Timed deserialization = timed(3, ignored -> codec.deserialize(first)); + CoverageMap roundTripped = deserialization.value(); + assertArrayEquals(first, codec.serialize(roundTripped)); + + long validationStart = System.nanoTime(); + assertSemanticEquality(conversion.tests(), roundTripped.tests()); + verifyDictionaryIntegrity(first); + long validationNanos = System.nanoTime() - validationStart; + + String json = new String(first, StandardCharsets.UTF_8); + String corrupt = json.replaceFirst("org\\.jgrapht", "org.xgrapht"); + assertNotEquals(json, corrupt); + CoverageMapValidationException corruptError = assertThrows(CoverageMapValidationException.class, + () -> codec.deserialize(corrupt.getBytes(StandardCharsets.UTF_8))); + assertEquals(ValidationCode.CHECKSUM_MISMATCH, corruptError.getError().code()); + + String higherSchema = json.replaceFirst("\\\"schemaVersion\\\":1", "\\\"schemaVersion\\\":2"); + CoverageMapValidationException schemaError = assertThrows(CoverageMapValidationException.class, + () -> codec.deserialize(higherSchema.getBytes(StandardCharsets.UTF_8))); + assertEquals(ValidationCategory.INCOMPATIBLE_SCHEMA, schemaError.getError().category()); + assertEquals(ValidationCode.HIGHER_SCHEMA_VERSION, schemaError.getError().code()); + + JsonObject wire = JsonParser.parseString(json).getAsJsonObject(); + long gzipSize = gzip(first).length; + byte[] withoutMethods = codec.serialize(withoutMethods(map)); + long methodRawContribution = first.length - withoutMethods.length; + long methodGzipContribution = gzipSize - gzip(withoutMethods).length; + long dictionarySize = jsonBytes(wire, "classIndex", "methodIndex", "containerIndex", "testIndex"); + long testPayloadSize = jsonBytes(wire, "tests"); + System.out.printf("%nJGraphT schema-v1 validation%n" + + "source map: %s%nsource format: gzip-compressed legacy JSON (classMetrics, metadata, testMappings)%n" + + "source revision: %s%nsource tests: %d%nconverted FQN tests: %d%nunresolved tests: %d%n" + + "conversion percentage: %.2f%%%nunresolved identities: %s%n" + + "whole source unique classes/methods: %d/%d%nwhole source class/method edges: %d/%d%n" + + "converted-source/round-trip unique classes: %d/%d%nconverted-source/round-trip unique methods: %d/%d%n" + + "converted-source/round-trip class edges: %d/%d%nconverted-source/round-trip method edges: %d/%d%n" + + "raw/gzip bytes: %d/%d%ndictionary bytes (JSON members): %d%ntest payload bytes (JSON member): %d%n" + + "method coverage raw/gzip contribution bytes: %d/%d%n" + + "SHA-256: %s%ndeterministic and round-trip bytes: identical%n" + + "construction/serialization/deserialization/validation ms: %.2f/%.2f/%.2f/%.2f%n" + + "corruption rejection: CHECKSUM_MISMATCH%nhigher-schema rejection: INCOMPATIBLE_SCHEMA/HIGHER_SCHEMA_VERSION%n", + sourceMap, legacy.getAsJsonObject("metadata").get("commitId").getAsString(), + conversion.sourceMappings(), conversion.tests().size(), conversion.unresolved().size(), + 100.0 * conversion.tests().size() / conversion.sourceMappings(), conversion.unresolved(), + legacyStats.classes(), legacyStats.methods(), legacyStats.classEdges(), legacyStats.methodEdges(), + classes(conversion.tests()).size(), classes(roundTripped.tests()).size(), + methods(conversion.tests()).size(), methods(roundTripped.tests()).size(), + classEdges(conversion.tests()).size(), classEdges(roundTripped.tests()).size(), + methodEdges(conversion.tests()).size(), methodEdges(roundTripped.tests()).size(), + first.length, gzipSize, dictionarySize, testPayloadSize, methodRawContribution, methodGzipContribution, + wire.get("checksum").getAsString(), millis(constructionNanos), timed(3, ignored -> codec.serialize(map)).millis(), + deserialization.millis(), millis(validationNanos)); + } + + private static Conversion convert(JsonObject legacy, Map> fqnsByOuter, + Map> archivedFqnEvidence) { + JsonObject mappings = legacy.getAsJsonObject("testMappings"); + Map converted = new TreeMap<>(); List unresolved = new ArrayList<>(); + for (Map.Entry entry : mappings.entrySet()) { + Matcher key = LEGACY_KEY.matcher(entry.getKey()); + if (!key.matches()) { unresolved.add(entry.getKey()); continue; } + String simpleBinaryName = key.group(1); String outer = simpleBinaryName.split("\\$", 2)[0]; + Set candidates = new TreeSet<>(fqnsByOuter.getOrDefault(outer, Set.of())); + if (candidates.size() > 1) { + Set evidence = archivedFqnEvidence.getOrDefault(simpleBinaryName + "#" + key.group(2), Set.of()); + candidates.retainAll(evidence); + } + if (candidates.size() != 1) { unresolved.add(entry.getKey()); continue; } + String outerFqn = candidates.iterator().next(); + String className = outerFqn + simpleBinaryName.substring(outer.length()); + TestIdentity identity; + try { identity = new TestIdentity(className, key.group(2)); } + catch (IllegalArgumentException invalid) { unresolved.add(entry.getKey()); continue; } + JsonObject coverage = entry.getValue().getAsJsonObject(); + Set coveredClasses = strings(coverage.getAsJsonArray("classes")); + Set coveredMethods = strings(coverage.getAsJsonArray("methods")); + TestCoverage previous = converted.put(identity, new TestCoverage(coveredClasses, coveredMethods, TestOutcome.PASS, + coveredClasses.isEmpty() ? CollectionStatus.COLLECTED_EMPTY : CollectionStatus.COLLECTED_WITH_COVERAGE)); + assertNull(previous, "legacy keys collided after logical identity reconstruction: " + identity); + } + return new Conversion(mappings.size(), Map.copyOf(converted), List.copyOf(unresolved)); + } + + private static CoverageMap candidate(JsonObject legacy, Map tests) { + JsonObject metadata = legacy.getAsJsonObject("metadata"); long classEdges = classEdges(tests).size(); long methodEdges = methodEdges(tests).size(); + return new CoverageMap(CoverageMapContract.SCHEMA_VERSION, + new CoverageMapRevision(metadata.get("commitId").getAsString()), Instant.parse(metadata.get("timestamp").getAsString()), + new GeneratorProvenance("0.2.0-validation", "legacy-evaluation", "unknown", System.getProperty("java.version")), + tests, List.of(), List.of(), null, new MapStatistics(0, tests.size(), 0, 0, classEdges, methodEdges), + CoverageMapLifecycleState.CANDIDATE, null); + } + + private static CoverageMap withoutMethods(CoverageMap map) { + Map tests = new TreeMap<>(); + map.tests().forEach((identity, coverage) -> tests.put(identity, + new TestCoverage(coverage.coveredClasses(), Set.of(), coverage.outcome(), coverage.collectionStatus()))); + MapStatistics statistics = map.statistics(); + return new CoverageMap(map.schemaVersion(), map.revision(), map.generatedAt(), map.generator(), tests, + map.unmapped(), map.setupScopes(), map.completeness(), new MapStatistics(statistics.expectedTests(), + statistics.mappedTests(), statistics.unmappedTests(), statistics.setupScopes(), statistics.classEdges(), 0), + map.lifecycleState(), null); + } + + private static LegacyStats legacyStats(JsonObject legacy) { + Set classes = new HashSet<>(), methods = new HashSet<>(); long classEdges = 0, methodEdges = 0; + for (JsonElement element : legacy.getAsJsonObject("testMappings").asMap().values()) { + JsonObject coverage = element.getAsJsonObject(); + Set testClasses = strings(coverage.getAsJsonArray("classes")); + Set testMethods = strings(coverage.getAsJsonArray("methods")); + classes.addAll(testClasses); methods.addAll(testMethods); classEdges += testClasses.size(); methodEdges += testMethods.size(); + } + return new LegacyStats(classes.size(), methods.size(), classEdges, methodEdges); + } + + private static Map> sourceFqns(Path root) throws IOException { + Map> result = new HashMap<>(); + try (var paths = Files.walk(root)) { + for (Path path : paths.filter(p -> p.toString().contains("/src/test/java/") && p.toString().endsWith(".java")).toList()) { + String source = Files.readString(path); Matcher packageName = PACKAGE.matcher(source); + if (!packageName.find()) continue; + String file = path.getFileName().toString(); String simple = file.substring(0, file.length() - 5); + result.computeIfAbsent(simple, ignored -> new TreeSet<>()).add(packageName.group(1) + "." + simple); + } + } + return result; + } + + private static Map> archivedFqnEvidence(Path resultsRoot) throws IOException { + Map> result = new HashMap<>(); + try (var paths = Files.walk(resultsRoot)) { + for (Path path : paths.filter(p -> Files.isRegularFile(p) && p.toString().endsWith(".xml")).toList()) { + Matcher matcher = JUNIT_ID.matcher(Files.readString(path)); + while (matcher.find()) { + String fqn = matcher.group(1); int dot = fqn.lastIndexOf('.'); + String simple = fqn.substring(dot + 1); + result.computeIfAbsent(simple + "#" + matcher.group(2), ignored -> new TreeSet<>()).add(fqn); + } + } + } + return result; + } + + private static void assertSemanticEquality(Map source, Map actual) { + assertEquals(source.keySet(), actual.keySet()); + assertEquals(classes(source), classes(actual)); assertEquals(methods(source), methods(actual)); + assertEquals(classEdges(source), classEdges(actual)); assertEquals(methodEdges(source), methodEdges(actual)); + assertEquals(source, actual); + } + + private static void verifyDictionaryIntegrity(byte[] bytes) { + JsonObject root = JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)).getAsJsonObject(); + List tests = dictionary(root, "testIndex"), classes = dictionary(root, "classIndex"), methods = dictionary(root, "methodIndex"); + assertSortedUnique(tests); assertSortedUnique(classes); assertSortedUnique(methods); + for (JsonElement element : root.getAsJsonArray("tests")) { + JsonObject value = element.getAsJsonObject(); assertInRange(value.get("test").getAsInt(), tests.size()); + value.getAsJsonArray("classes").forEach(index -> assertInRange(index.getAsInt(), classes.size())); + value.getAsJsonArray("methods").forEach(index -> assertInRange(index.getAsInt(), methods.size())); + } + } + + private static List dictionary(JsonObject root, String name) { return root.getAsJsonArray(name).asList().stream().map(JsonElement::getAsString).toList(); } + private static void assertSortedUnique(List values) { assertEquals(new ArrayList<>(new TreeSet<>(values)), values); } + private static void assertInRange(int value, int size) { assertTrue(value >= 0 && value < size, () -> value + " outside dictionary size " + size); } + private static Set strings(JsonArray values) { Set result = new TreeSet<>(); values.forEach(v -> result.add(v.getAsString())); return result; } + private static Set classes(Map tests) { return values(tests, TestCoverage::coveredClasses); } + private static Set methods(Map tests) { return values(tests, TestCoverage::coveredMethods); } + private static Set values(Map tests, Function> getter) { Set result = new TreeSet<>(); tests.values().forEach(v -> result.addAll(getter.apply(v))); return result; } + private static Set classEdges(Map tests) { return edges(tests, TestCoverage::coveredClasses); } + private static Set methodEdges(Map tests) { return edges(tests, TestCoverage::coveredMethods); } + private static Set edges(Map tests, Function> getter) { Set result = new HashSet<>(); tests.forEach((test, coverage) -> getter.apply(coverage).forEach(target -> result.add(test + " -> " + target))); return result; } + + private static JsonObject readGzipJson(Path path) throws IOException { try (Reader reader = new java.io.InputStreamReader(new GZIPInputStream(Files.newInputStream(path)), StandardCharsets.UTF_8)) { return JsonParser.parseReader(reader).getAsJsonObject(); } } + private static byte[] gzip(byte[] bytes) throws IOException { java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream(); try (GZIPOutputStream gzip = new GZIPOutputStream(output)) { gzip.write(bytes); } return output.toByteArray(); } + private static long jsonBytes(JsonObject root, String... names) { JsonObject selected = new JsonObject(); Arrays.stream(names).forEach(name -> selected.add(name, root.get(name))); return selected.toString().getBytes(StandardCharsets.UTF_8).length; } + private static Path configured(String property, Path fallback) { String value = System.getProperty(property); return value == null ? fallback.normalize() : Path.of(value).toAbsolutePath().normalize(); } + private static Path workspace() { Path current = Path.of("").toAbsolutePath(); return current.getFileName().toString().equals("smart-test-picker-common") ? current.getParent() : current; } + private static double millis(long nanos) { return nanos / 1_000_000.0; } + private static Timed timed(int runs, Function action) { T value = null; long total = 0; for (int run = 0; run < runs; run++) { long start = System.nanoTime(); value = action.apply(run); total += System.nanoTime() - start; } return new Timed<>(value, total / runs); } + private record Conversion(int sourceMappings, Map tests, List unresolved) {} + private record LegacyStats(int classes, int methods, long classEdges, long methodEdges) {} + private record Timed(T value, long nanos) { double millis() { return JGraphTCoverageMapV1IntegrationTest.millis(nanos); } } +} diff --git a/smart-test-picker-maven/build.gradle b/smart-test-picker-maven/build.gradle index 40722615..a9b2dbdc 100644 --- a/smart-test-picker-maven/build.gradle +++ b/smart-test-picker-maven/build.gradle @@ -3,7 +3,7 @@ plugins { id 'java' id 'maven-publish' - id 'de.benediktritter.maven-plugin-development' version '0.4.3' + id 'org.gradlex.maven-plugin-development' version '1.0.3' } group = 'com.sap.oss.smart-test-picker' @@ -30,9 +30,7 @@ test { useJUnitPlatform() } -// Java 17 target required — de.benediktritter.maven-plugin-development -// uses ASM that does not support Java 21 bytecode. -// All other modules can use Java 21 freely. +// Keep published bytecode compatible with the project's Java 17 baseline. java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17