From 4af2c716cb59a64e8ceb631c33e493cf3453c6e6 Mon Sep 17 00:00:00 2001 From: Hyeoncheol Kim Date: Tue, 24 Feb 2026 15:59:56 +0900 Subject: [PATCH] feat(testkit): expand UTF adapters and real-mongod command translation --- docs/COMPATIBILITY_SCORECARD.md | 109 ++--- .../jongodb/testkit/DifferentialHarness.java | 2 +- .../jongodb/testkit/RealMongodBackend.java | 142 ++++-- .../jongodb/testkit/ScenarioBsonCodec.java | 193 +++++++- .../jongodb/testkit/UnifiedSpecImporter.java | 439 +++++++++++++++++- .../DifferentialHarnessNormalizationTest.java | 35 ++ .../testkit/ScenarioBsonCodecTest.java | 47 ++ .../testkit/UnifiedSpecImporterTest.java | 141 ++++++ 8 files changed, 1003 insertions(+), 105 deletions(-) diff --git a/docs/COMPATIBILITY_SCORECARD.md b/docs/COMPATIBILITY_SCORECARD.md index 18cf23e..1c2c7a2 100644 --- a/docs/COMPATIBILITY_SCORECARD.md +++ b/docs/COMPATIBILITY_SCORECARD.md @@ -2,100 +2,85 @@ Status date: 2026-02-24 -This document is the final R3 certification snapshot used for release decisions. -It is an integration-test compatibility report, not a production MongoDB parity claim. -Scope: release baseline for `v0.1.1` (`f4a8bbb`), not the latest `main` head. +This scorecard tracks integration-test compatibility against MongoDB official specs. +It is not a production MongoDB parity claim. -## Evidence Sources +## Scope -| Evidence | Source run / artifact | Result | -| --- | --- | --- | -| Official UTF sharded differential (baseline) | GitHub Actions `Official Suite Sharded` run `22332998372` | PASS | -| Official UTF sharded differential (rerun/flake gate) | GitHub Actions `Official Suite Sharded` run `22332998372` | PASS | -| R3 failure ledger | GitHub Actions `R3 Failure Ledger` run `22332937657` | PASS | -| External Spring canary certification | GitHub Actions `R3 External Canary Certification` run `22332937633` | PASS | -| Support manifest scorecard | `build/reports/r2-compatibility/r2-compatibility-scorecard.json` | PASS | +- Baseline target: `v0.1.2` and current `main` evidence. +- Primary objective: increase imported differential coverage while keeping behavior deterministic. -## Final Gate Summary +## Evidence Sources -| Gate | Status | Metrics | +| Evidence | Source run / artifact | Result | | --- | --- | --- | -| UTF sharded baseline | PASS | imported=146, match=146, mismatch=0, error=0 | -| UTF sharded rerun | PASS | imported=146, match=146, mismatch=0, error=0 | -| UTF flake consistency | PASS | shard-0/1/2 baseline == rerun | -| R3 failure ledger | PASS | suiteCount=3, failureCount=0 | -| External canary certification | PASS | projectCount=3, canaryPass=3, rollbackSuccess=3, maxRecoverySeconds=38 | -| Spring compatibility matrix | PASS | totalCells=20, pass=20, fail=0, passRate=1.0 | -| Compatibility scorecard gates | PASS | pass=2, fail=0, missing=0 | +| Official UTF sharded differential | GitHub Actions `Official Suite Sharded` run `22339640229` | PASS | +| R3 failure ledger | GitHub Actions `R3 Failure Ledger` run `22339640205` | FAIL (gate reports remaining mismatches) | +| Real mongod baseline | GitHub Actions `Real Mongod Baseline` run `22339640211` | PASS | -## Post-0.1.1 Main Deltas (Not Included in This Snapshot) +## Frozen Baseline (Run `22339640229`) -The following `main` changes landed after `v0.1.1` and require the next release-cycle certification snapshot: - -- `#86`: single-hook Spring test annotation (`@JongodbMongoTest`) -- `#87`: `countDocuments`, `replaceOne`, `findOneAndUpdate`, `findOneAndReplace` command support -- `#88`: ordered `bulkWrite` core subset -- `#91`: projection subset expansion for `findOneAndUpdate` / `findOneAndReplace` - -## Official UTF Coverage Snapshot - -Totals from the baseline shard artifacts in run `22332998372`: +Aggregated baseline metrics from shard artifacts: | Metric | Value | | --- | --- | -| imported | 146 | +| imported | 200 | | skipped | 567 | -| unsupported | 868 | -| total differential cases | 146 | +| unsupported | 814 | +| total differential cases | 200 | | match | 146 | -| mismatch | 0 | +| mismatch | 54 | | error | 0 | -Top unsupported reasons in baseline artifacts: +## R3 Ledger Snapshot (Run `22339640205`) + +| Suite | Imported | Unsupported | Mismatch | Error | +| --- | --- | --- | --- | --- | +| `crud-unified` | 188 | 362 | 54 | 0 | +| `transactions-unified` | 0 | 396 | 0 | 0 | +| `sessions` | 12 | 56 | 0 | 0 | + +Current ledger gate status: + +- `failureCount=54` (all `MISMATCH`, no `ERROR`) +- primary mismatch bucket: `bulkWrite` protocol scenarios + +## Top Unsupported Reasons (Baseline) | Reason | Count | | --- | --- | | `unsupported UTF operation: startTransaction` | 234 | | `unsupported UTF operation: failPoint` | 134 | | `unsupported UTF operation: clientBulkWrite` | 86 | -| `unsupported UTF operation: bulkWrite` | 84 | | `unsupported UTF operation: findOneAndUpdate` | 48 | | `unsupported UTF operation: createEntities` | 40 | | `unsupported UTF operation: findOneAndReplace` | 36 | | `unsupported UTF aggregate stage in pipeline` | 28 | -| `unsupported UTF operation: countDocuments` | 22 | +| `unsupported UTF update option: arrayFilters` | 26 | +| `unsupported UTF operation: countDocuments` | 24 | | `unsupported UTF operation: replaceOne` | 22 | -## Artifact Digest Snapshot +## Policy Exclusions -SHA-256 digests captured during certification assembly: +- `failPoint` is currently treated as a policy exclusion for deterministic in-process execution. +- Scorecard accounting distinguishes this as `unsupported-by-policy UTF operation: failPoint` in importer output. -| Artifact | SHA-256 | -| --- | --- | -| `utf-shard-summary.md` | `93a8fefa915b48e6799f9f3f4004c53bf4d678a7f473b41e75ca5e3ee974fd08` | -| `shard-0 baseline utf-differential-report.json` | `483cd8cfbd8acd3ea590849bfe063b8abf31b4bbe37800e6c6ba5742b99b92af` | -| `shard-1 baseline utf-differential-report.json` | `cf226ab24bdddce9c6748bf1393cbaf776c704358788137f9875f78dbb6856eb` | -| `shard-2 baseline utf-differential-report.json` | `6dabf85f22be0e60bf275ab0fdc896a7fbc35413ce455c678ada5b0e32fe68e3` | -| `r3-failure-ledger.json` | `0dfe884ed25be8be46c2d7937f8386075ec00e90e4d5c5e83ccdbbe2b2079d08` | -| `r2-canary-certification.json` (R3 canary output artifact name) | `8ffd103c55f0cb3ef95c7fc52bfc347454984fcc057419c50e607acf4f565103` | -| `r2-compatibility-scorecard.json` | `3ba3469366a19453786e631841a633645082d6cd80fd1a22a50ed7ba4dead6a0` | -| `r2-support-manifest.json` | `84fd659f98a332134209b02d1302321aceaf56125fd691e0a7d38e564f4f913f` | +## Gap-to-Issue Mapping + +- `#100`: transaction operation adapter coverage (`startTransaction` and lifecycle wiring) +- `#101`: unified CRUD adapter coverage (`findOneAndUpdate`, `findOneAndReplace`, `countDocuments`, `replaceOne`) +- `#102`: `createEntities` subset for official suites +- `#103`: explicit `failPoint` policy for deterministic in-memory backend +- `#104`: aggregate-stage unsupported reduction ## Reproduction -Run the same gates locally: +Run the same ledger locally: ```bash -./.tooling/gradle-8.10.2/bin/gradle --no-daemon springCompatibilityMatrixEvidence - -./.tooling/gradle-8.10.2/bin/gradle --no-daemon \ - r2CompatibilityEvidence \ - -Pr2CompatibilityUtfReport=/tmp/official-utf-summary.json \ - -Pr2CompatibilitySpringMatrixJson=build/reports/spring-matrix/spring-compatibility-matrix.json \ - -Pr2CompatibilityFailOnGate=true - ./.tooling/gradle-8.10.2/bin/gradle --no-daemon \ - r3CanaryCertificationEvidence \ - -Pr3CanaryInputJson=testkit/canary/r3/projects.sample.json \ - -Pr3CanaryFailOnGate=true + -Pr3SpecRepoRoot="" \ + -Pr3FailureLedgerMongoUri="" \ + -Pr3FailureLedgerFailOnFailures=true \ + r3FailureLedger ``` diff --git a/src/main/java/org/jongodb/testkit/DifferentialHarness.java b/src/main/java/org/jongodb/testkit/DifferentialHarness.java index 2ebbd5d..408164e 100644 --- a/src/main/java/org/jongodb/testkit/DifferentialHarness.java +++ b/src/main/java/org/jongodb/testkit/DifferentialHarness.java @@ -217,7 +217,7 @@ private static void compareMap( } private static boolean isEphemeralPath(final String path) { - return path.endsWith(".cursor.ns"); + return path.endsWith(".cursor.ns") || path.endsWith(".cursor.id"); } private static void compareList( diff --git a/src/main/java/org/jongodb/testkit/RealMongodBackend.java b/src/main/java/org/jongodb/testkit/RealMongodBackend.java index 20175c6..8f22a81 100644 --- a/src/main/java/org/jongodb/testkit/RealMongodBackend.java +++ b/src/main/java/org/jongodb/testkit/RealMongodBackend.java @@ -7,11 +7,15 @@ import com.mongodb.client.MongoClients; import com.mongodb.client.MongoDatabase; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import org.bson.BsonArray; import org.bson.BsonDocument; +import org.bson.BsonInt64; +import org.bson.BsonValue; /** * Differential backend adapter that executes scenario commands against real mongod. @@ -52,39 +56,48 @@ public ScenarioOutcome execute(Scenario scenario) { Objects.requireNonNull(scenario, "scenario"); String databaseName = scenarioDatabaseName(scenario.id()); - try (MongoClient client = clientFactory.create(connectionUri); - ClientSession session = client.startSession()) { + try (MongoClient client = clientFactory.create(connectionUri)) { + final Map sessionPool = new HashMap<>(); MongoDatabase database = client.getDatabase(databaseName); resetDatabase(database); List> commandResults = new ArrayList<>(scenario.commands().size()); - for (int i = 0; i < scenario.commands().size(); i++) { - ScenarioCommand command = scenario.commands().get(i); - MongoDatabase commandDatabase = resolveCommandDatabase(client, database, command.commandName()); - BsonDocument commandDocument; - try { - commandDocument = ScenarioBsonCodec.toRealMongodCommandDocument(command, databaseName); - } catch (RuntimeException exception) { - return ScenarioOutcome.failure( - "invalid command payload for " + command.commandName() + ": " + exception.getMessage() - ); - } - - BsonDocument responseBody; - try { - responseBody = commandDatabase.runCommand(session, commandDocument, BsonDocument.class); - } catch (MongoCommandException commandException) { - responseBody = commandException.getResponse(); - } catch (RuntimeException exception) { - return ScenarioOutcome.failure( - "real mongod execution failed for " + command.commandName() + ": " + exception.getMessage() - ); - } - - commandResults.add(ScenarioBsonCodec.toJavaMap(responseBody)); - if (!ScenarioBsonCodec.isSuccess(responseBody)) { - return ScenarioOutcome.failure(ScenarioBsonCodec.formatFailure(command.commandName(), i, responseBody)); + try { + for (int i = 0; i < scenario.commands().size(); i++) { + ScenarioCommand command = scenario.commands().get(i); + MongoDatabase commandDatabase = resolveCommandDatabase(client, database, command.commandName()); + BsonDocument commandDocument; + try { + commandDocument = ScenarioBsonCodec.toRealMongodCommandDocument(command, databaseName); + } catch (RuntimeException exception) { + return ScenarioOutcome.failure( + "invalid command payload for " + command.commandName() + ": " + exception.getMessage() + ); + } + + final ClientSession session = resolveSession(client, command, sessionPool); + + BsonDocument responseBody; + try { + responseBody = session == null + ? commandDatabase.runCommand(commandDocument, BsonDocument.class) + : commandDatabase.runCommand(session, commandDocument, BsonDocument.class); + } catch (MongoCommandException commandException) { + responseBody = commandException.getResponse(); + } catch (RuntimeException exception) { + return ScenarioOutcome.failure( + "real mongod execution failed for " + command.commandName() + ": " + exception.getMessage() + ); + } + responseBody = normalizeResponseForComparison(command, responseBody); + + commandResults.add(ScenarioBsonCodec.toJavaMap(responseBody)); + if (!ScenarioBsonCodec.isSuccess(responseBody)) { + return ScenarioOutcome.failure(ScenarioBsonCodec.formatFailure(command.commandName(), i, responseBody)); + } } + } finally { + closeSessions(sessionPool); } return ScenarioOutcome.success(commandResults); } catch (RuntimeException exception) { @@ -123,6 +136,79 @@ private static MongoDatabase resolveCommandDatabase( return defaultDatabase; } + private static ClientSession resolveSession( + final MongoClient client, + final ScenarioCommand command, + final Map sessionPool + ) { + final String sessionId = readSessionId(command); + if (sessionId == null) { + return null; + } + return sessionPool.computeIfAbsent(sessionId, ignored -> client.startSession()); + } + + private static String readSessionId(final ScenarioCommand command) { + final Object lsidValue = command.payload().get("lsid"); + if (!(lsidValue instanceof Map lsid)) { + return null; + } + final Object idValue = lsid.get("id"); + if (idValue == null) { + return null; + } + final String normalized = String.valueOf(idValue).trim(); + if (normalized.isEmpty()) { + return null; + } + return normalized; + } + + private static void closeSessions(final Map sessions) { + for (final ClientSession session : sessions.values()) { + try { + session.close(); + } catch (RuntimeException ignored) { + // Best-effort cleanup for test harness sessions. + } + } + } + + private static BsonDocument normalizeResponseForComparison( + final ScenarioCommand command, + final BsonDocument responseBody + ) { + if (!"countDocuments".equals(command.commandName())) { + return responseBody; + } + if (!responseBody.containsKey("n") || responseBody.containsKey("count")) { + final BsonValue cursorValue = responseBody.get("cursor"); + if (cursorValue == null || !cursorValue.isDocument()) { + return responseBody; + } + final BsonValue firstBatchValue = cursorValue.asDocument().get("firstBatch"); + if (firstBatchValue == null || !firstBatchValue.isArray()) { + return responseBody; + } + final BsonArray firstBatch = firstBatchValue.asArray(); + long count = 0L; + if (!firstBatch.isEmpty()) { + final BsonValue first = firstBatch.get(0); + if (first != null && first.isDocument()) { + final BsonValue nValue = first.asDocument().get("n"); + if (nValue != null && nValue.isNumber()) { + count = nValue.asNumber().longValue(); + } + } + } + return new BsonDocument() + .append("n", new BsonInt64(count)) + .append("count", new BsonInt64(count)) + .append("ok", responseBody.get("ok")); + } + return responseBody.clone().append("count", responseBody.get("n")); + } + private static void resetDatabase(MongoDatabase database) { Objects.requireNonNull(database, "database"); database.drop(); diff --git a/src/main/java/org/jongodb/testkit/ScenarioBsonCodec.java b/src/main/java/org/jongodb/testkit/ScenarioBsonCodec.java index c79a251..9a5e676 100644 --- a/src/main/java/org/jongodb/testkit/ScenarioBsonCodec.java +++ b/src/main/java/org/jongodb/testkit/ScenarioBsonCodec.java @@ -69,7 +69,14 @@ static BsonDocument toCommandDocument(ScenarioCommand command, String defaultDat } static BsonDocument toRealMongodCommandDocument(ScenarioCommand command, String defaultDatabase) { - BsonDocument commandDocument = toCommandDocument(command, defaultDatabase); + Objects.requireNonNull(command, "command"); + final BsonDocument commandDocument = switch (command.commandName()) { + case "countDocuments" -> translateCountDocumentsCommand(command, defaultDatabase); + case "replaceOne" -> translateReplaceOneCommand(command, defaultDatabase); + case "findOneAndUpdate" -> translateFindOneAndUpdateCommand(command, defaultDatabase); + case "findOneAndReplace" -> translateFindOneAndReplaceCommand(command, defaultDatabase); + default -> toCommandDocument(command, defaultDatabase); + }; commandDocument.remove("$db"); commandDocument.remove("lsid"); ensureTxnNumberIsLong(commandDocument); @@ -245,6 +252,190 @@ private static void ensureTxnNumberIsLong(BsonDocument commandDocument) { commandDocument.put("txnNumber", new BsonInt64(txnNumberValue.asNumber().longValue())); } + private static BsonDocument translateCountDocumentsCommand( + final ScenarioCommand command, + final String defaultDatabase) { + final BsonDocument source = toCommandDocument(command, defaultDatabase); + final BsonValue collection = source.get("countDocuments"); + if (!(collection instanceof BsonString collectionName)) { + return source; + } + + final BsonDocument translated = new BsonDocument("aggregate", new BsonString(collectionName.getValue())); + final BsonValue filter = source.containsKey("filter") ? source.get("filter") : source.get("query"); + final BsonArray pipeline = new BsonArray(); + pipeline.add(new BsonDocument("$match", asDocumentOrEmpty(filter))); + if (source.containsKey("skip")) { + pipeline.add(new BsonDocument("$skip", source.get("skip"))); + } + if (source.containsKey("limit")) { + pipeline.add(new BsonDocument("$limit", source.get("limit"))); + } + pipeline.add(new BsonDocument( + "$group", + new BsonDocument() + .append("_id", new BsonInt32(1)) + .append("n", new BsonDocument("$sum", new BsonInt32(1))))); + translated.put("pipeline", pipeline); + translated.put("cursor", new BsonDocument()); + copyFields( + source, + translated, + "$db", + "hint", + "collation", + "readConcern", + "txnNumber", + "autocommit", + "startTransaction", + "lsid"); + return translated; + } + + private static BsonDocument translateReplaceOneCommand( + final ScenarioCommand command, + final String defaultDatabase) { + final BsonDocument source = toCommandDocument(command, defaultDatabase); + final BsonValue collection = source.get("replaceOne"); + if (!(collection instanceof BsonString collectionName)) { + return source; + } + + final BsonDocument translated = new BsonDocument("update", new BsonString(collectionName.getValue())); + final BsonDocument updateEntry = new BsonDocument() + .append("q", asDocumentOrEmpty(source.get("filter"))) + .append("u", source.get("replacement", new BsonDocument())) + .append("multi", BsonBoolean.FALSE) + .append("upsert", BsonBoolean.valueOf(readBoolean(source.get("upsert"), false))); + appendIfPresent(source, updateEntry, "hint"); + appendIfPresent(source, updateEntry, "collation"); + translated.put("updates", new BsonArray(List.of(updateEntry))); + copyFields( + source, + translated, + "$db", + "readConcern", + "writeConcern", + "txnNumber", + "autocommit", + "startTransaction", + "lsid"); + return translated; + } + + private static BsonDocument translateFindOneAndUpdateCommand( + final ScenarioCommand command, + final String defaultDatabase) { + final BsonDocument source = toCommandDocument(command, defaultDatabase); + final BsonValue collection = source.get("findOneAndUpdate"); + if (!(collection instanceof BsonString collectionName)) { + return source; + } + + final BsonDocument translated = new BsonDocument() + .append("findAndModify", new BsonString(collectionName.getValue())) + .append("query", asDocumentOrEmpty(source.get("filter"))) + .append("update", source.get("update", new BsonDocument())) + .append("remove", BsonBoolean.FALSE) + .append("new", BsonBoolean.valueOf(readReturnDocumentAsAfter(source))); + appendIfPresent(source, translated, "sort"); + if (source.containsKey("projection")) { + translated.put("fields", source.get("projection")); + } + appendIfPresent(source, translated, "upsert"); + appendIfPresent(source, translated, "hint"); + appendIfPresent(source, translated, "collation"); + copyFields( + source, + translated, + "$db", + "readConcern", + "writeConcern", + "txnNumber", + "autocommit", + "startTransaction", + "lsid"); + return translated; + } + + private static BsonDocument translateFindOneAndReplaceCommand( + final ScenarioCommand command, + final String defaultDatabase) { + final BsonDocument source = toCommandDocument(command, defaultDatabase); + final BsonValue collection = source.get("findOneAndReplace"); + if (!(collection instanceof BsonString collectionName)) { + return source; + } + + final BsonDocument translated = new BsonDocument() + .append("findAndModify", new BsonString(collectionName.getValue())) + .append("query", asDocumentOrEmpty(source.get("filter"))) + .append("update", source.get("replacement", new BsonDocument())) + .append("remove", BsonBoolean.FALSE) + .append("new", BsonBoolean.valueOf(readReturnDocumentAsAfter(source))); + appendIfPresent(source, translated, "sort"); + if (source.containsKey("projection")) { + translated.put("fields", source.get("projection")); + } + appendIfPresent(source, translated, "upsert"); + appendIfPresent(source, translated, "hint"); + appendIfPresent(source, translated, "collation"); + copyFields( + source, + translated, + "$db", + "readConcern", + "writeConcern", + "txnNumber", + "autocommit", + "startTransaction", + "lsid"); + return translated; + } + + private static void copyFields( + final BsonDocument source, + final BsonDocument target, + final String... keys) { + for (final String key : keys) { + appendIfPresent(source, target, key); + } + } + + private static void appendIfPresent( + final BsonDocument source, + final BsonDocument target, + final String key) { + if (!source.containsKey(key)) { + return; + } + target.put(key, source.get(key)); + } + + private static BsonDocument asDocumentOrEmpty(final BsonValue value) { + if (value != null && value.isDocument()) { + return value.asDocument(); + } + return new BsonDocument(); + } + + private static boolean readBoolean(final BsonValue value, final boolean defaultValue) { + if (value == null || !value.isBoolean()) { + return defaultValue; + } + return value.asBoolean().getValue(); + } + + private static boolean readReturnDocumentAsAfter(final BsonDocument source) { + final BsonValue returnDocument = source.get("returnDocument"); + if (returnDocument != null && returnDocument.isString()) { + final String value = returnDocument.asString().getValue(); + return "after".equalsIgnoreCase(value); + } + final BsonValue newValue = source.get("new"); + return readBoolean(newValue, false); + } + private static Object toJavaValue(BsonValue value) { if (value == null || value.isNull()) { return null; diff --git a/src/main/java/org/jongodb/testkit/UnifiedSpecImporter.java b/src/main/java/org/jongodb/testkit/UnifiedSpecImporter.java index b2358ef..5fcb1ea 100644 --- a/src/main/java/org/jongodb/testkit/UnifiedSpecImporter.java +++ b/src/main/java/org/jongodb/testkit/UnifiedSpecImporter.java @@ -60,6 +60,10 @@ public ImportResult importCorpus(final Path specRoot) throws IOException { final String defaultDatabase = readDefaultDatabase(spec); final String defaultCollection = readDefaultCollection(spec); + final FileConversionContext baseContext = FileConversionContext.fromSpec( + defaultDatabase, + defaultCollection, + spec); final Object testsValue = spec.get("tests"); if (!(testsValue instanceof List testsRaw)) { skipped.add(new SkippedCase( @@ -125,12 +129,13 @@ public ImportResult importCorpus(final Path specRoot) throws IOException { } final List commands = new ArrayList<>(operations.size()); + final FileConversionContext context = baseContext.copy(); String unsupportedReason = null; String invalidReason = null; for (final Object rawOperation : operations) { final Map operation = asStringObjectMap(rawOperation, "operation"); try { - commands.add(convertOperation(operation, defaultDatabase, defaultCollection)); + commands.addAll(context.convertOperation(operation)); } catch (final UnsupportedOperationException unsupported) { unsupportedReason = unsupported.getMessage(); break; @@ -156,6 +161,14 @@ public ImportResult importCorpus(final Path specRoot) throws IOException { invalidReason)); continue; } + if (commands.isEmpty()) { + skipped.add(new SkippedCase( + caseId, + sourcePath, + SkipKind.SKIPPED, + "no executable operations after setup/policy filtering")); + continue; + } imported.add(new ImportedScenario( caseId, @@ -194,27 +207,24 @@ private Object parseFile(final Path file) throws IOException { throw new IllegalArgumentException("unsupported file extension: " + file); } - private static ScenarioCommand convertOperation( - final Map operation, - final String defaultDatabase, - final String defaultCollection) { - final String operationName = requireText(operation.get("name"), "operation.name"); - final String objectName = trimToEmpty(operation.get("object")); - final Map arguments = asStringObjectMap( - operation.getOrDefault("arguments", Map.of()), - "operation.arguments"); - final String database = fallbackText(arguments.get("database"), defaultDatabase, "database"); - final String collection = fallbackText(arguments.get("collection"), defaultCollection, "collection"); - + private static ScenarioCommand convertCrudOperation( + final String operationName, + final Map arguments, + final String database, + final String collection) { return switch (operationName) { case "insertOne" -> insertOne(arguments, database, collection); case "insertMany" -> insertMany(arguments, database, collection); case "find" -> find(arguments, database, collection); case "aggregate" -> aggregate(arguments, database, collection); + case "countDocuments" -> countDocuments(arguments, database, collection); case "updateOne" -> update(arguments, database, collection, false); case "updateMany" -> update(arguments, database, collection, true); + case "replaceOne" -> replaceOne(arguments, database, collection); case "deleteOne" -> delete(arguments, database, collection, 1); case "deleteMany" -> delete(arguments, database, collection, 0); + case "findOneAndUpdate" -> findOneAndUpdate(arguments, database, collection); + case "findOneAndReplace" -> findOneAndReplace(arguments, database, collection); case "bulkWrite" -> bulkWrite(arguments, database, collection); case "createIndex" -> createIndex(arguments, database, collection); default -> throw new UnsupportedOperationException("unsupported UTF operation: " + operationName); @@ -275,6 +285,19 @@ private static ScenarioCommand find( return new ScenarioCommand("find", immutableMap(payload)); } + private static ScenarioCommand countDocuments( + final Map arguments, + final String database, + final String collection) { + final Map payload = commandEnvelope("countDocuments", database, collection); + payload.put("filter", deepCopyValue(arguments.getOrDefault("filter", Map.of()))); + copyIfPresent(arguments, payload, "skip"); + copyIfPresent(arguments, payload, "limit"); + copyIfPresent(arguments, payload, "hint"); + copyIfPresent(arguments, payload, "collation"); + return new ScenarioCommand("countDocuments", immutableMap(payload)); + } + private static ScenarioCommand aggregate( final Map arguments, final String database, @@ -375,6 +398,22 @@ private static ScenarioCommand update( return new ScenarioCommand("update", immutableMap(payload)); } + private static ScenarioCommand replaceOne( + final Map arguments, + final String database, + final String collection) { + final Map replacement = asStringObjectMap( + arguments.get("replacement"), + "replaceOne.arguments.replacement"); + final Map payload = commandEnvelope("replaceOne", database, collection); + payload.put("filter", deepCopyValue(arguments.getOrDefault("filter", Map.of()))); + payload.put("replacement", deepCopyValue(replacement)); + copyIfPresent(arguments, payload, "upsert"); + copyIfPresent(arguments, payload, "hint"); + copyIfPresent(arguments, payload, "collation"); + return new ScenarioCommand("replaceOne", immutableMap(payload)); + } + private static boolean isReplacementDocument(final Object rawUpdate) { if (!(rawUpdate instanceof Map mapped) || mapped.isEmpty()) { return false; @@ -426,6 +465,52 @@ private static ScenarioCommand delete( return new ScenarioCommand("delete", immutableMap(payload)); } + private static ScenarioCommand findOneAndUpdate( + final Map arguments, + final String database, + final String collection) { + final Object updateValue = arguments.get("update"); + if (updateValue == null) { + throw new IllegalArgumentException("findOneAndUpdate operation requires update argument"); + } + if (updateValue instanceof List) { + throw new UnsupportedOperationException("unsupported UTF update pipeline"); + } + if (arguments.containsKey("arrayFilters")) { + throw new UnsupportedOperationException("unsupported UTF update option: arrayFilters"); + } + + final Map payload = commandEnvelope("findOneAndUpdate", database, collection); + payload.put("filter", deepCopyValue(arguments.getOrDefault("filter", Map.of()))); + payload.put("update", deepCopyValue(updateValue)); + copyIfPresent(arguments, payload, "sort"); + copyIfPresent(arguments, payload, "projection"); + copyIfPresent(arguments, payload, "upsert"); + copyNormalizedReturnDocumentIfPresent(arguments, payload); + copyIfPresent(arguments, payload, "hint"); + copyIfPresent(arguments, payload, "collation"); + return new ScenarioCommand("findOneAndUpdate", immutableMap(payload)); + } + + private static ScenarioCommand findOneAndReplace( + final Map arguments, + final String database, + final String collection) { + final Map replacement = asStringObjectMap( + arguments.get("replacement"), + "findOneAndReplace.arguments.replacement"); + final Map payload = commandEnvelope("findOneAndReplace", database, collection); + payload.put("filter", deepCopyValue(arguments.getOrDefault("filter", Map.of()))); + payload.put("replacement", deepCopyValue(replacement)); + copyIfPresent(arguments, payload, "sort"); + copyIfPresent(arguments, payload, "projection"); + copyIfPresent(arguments, payload, "upsert"); + copyNormalizedReturnDocumentIfPresent(arguments, payload); + copyIfPresent(arguments, payload, "hint"); + copyIfPresent(arguments, payload, "collation"); + return new ScenarioCommand("findOneAndReplace", immutableMap(payload)); + } + private static ScenarioCommand bulkWrite( final Map arguments, final String database, @@ -550,6 +635,20 @@ private static void copyIfPresent( target.put(key, deepCopyValue(source.get(key))); } + private static void copyNormalizedReturnDocumentIfPresent( + final Map source, + final Map target) { + if (!source.containsKey("returnDocument")) { + return; + } + final Object rawValue = source.get("returnDocument"); + if (rawValue instanceof String textValue) { + target.put("returnDocument", textValue.toLowerCase(Locale.ROOT)); + return; + } + target.put("returnDocument", deepCopyValue(rawValue)); + } + private static String readDefaultDatabase(final Map spec) { final String database = firstNonBlank( trimToEmpty(spec.get("database_name")), @@ -707,6 +806,320 @@ private static Map immutableMap(final Map source return Collections.unmodifiableMap(new LinkedHashMap<>(source)); } + private static final class FileConversionContext { + private final String defaultDatabase; + private final String defaultCollection; + private final Map databaseAliases; + private final Map collectionAliases; + private final Map sessions; + + private FileConversionContext( + final String defaultDatabase, + final String defaultCollection, + final Map databaseAliases, + final Map collectionAliases, + final Map sessions) { + this.defaultDatabase = defaultDatabase; + this.defaultCollection = defaultCollection; + this.databaseAliases = databaseAliases; + this.collectionAliases = collectionAliases; + this.sessions = sessions; + } + + private static FileConversionContext fromSpec( + final String defaultDatabase, + final String defaultCollection, + final Map spec) { + final FileConversionContext context = new FileConversionContext( + defaultDatabase, + defaultCollection, + new LinkedHashMap<>(), + new LinkedHashMap<>(), + new LinkedHashMap<>()); + final Object createEntities = spec.get("createEntities"); + if (createEntities != null) { + context.applyCreateEntities(createEntities); + } + return context; + } + + private FileConversionContext copy() { + final Map copiedSessions = new LinkedHashMap<>(sessions.size()); + for (final Map.Entry entry : sessions.entrySet()) { + copiedSessions.put(entry.getKey(), entry.getValue().copy()); + } + return new FileConversionContext( + defaultDatabase, + defaultCollection, + new LinkedHashMap<>(databaseAliases), + new LinkedHashMap<>(collectionAliases), + copiedSessions); + } + + private List convertOperation(final Map operation) { + final String operationName = requireText(operation.get("name"), "operation.name"); + final String objectName = trimToEmpty(operation.get("object")); + final Map arguments = asStringObjectMap( + operation.getOrDefault("arguments", Map.of()), + "operation.arguments"); + + return switch (operationName) { + case "createEntities" -> handleCreateEntities(arguments); + case "startTransaction" -> { + startTransaction(objectName, arguments); + yield List.of(); + } + case "commitTransaction" -> List.of(completeTransaction("commitTransaction", objectName, arguments)); + case "abortTransaction" -> List.of(completeTransaction("abortTransaction", objectName, arguments)); + case "failPoint" -> throw new UnsupportedOperationException("unsupported-by-policy UTF operation: failPoint"); + default -> { + final CollectionTarget target = resolveCollectionTarget(objectName, arguments); + final ScenarioCommand converted = convertCrudOperation( + operationName, + arguments, + target.database(), + target.collection()); + yield List.of(applySessionEnvelope(converted, arguments)); + } + }; + } + + private List handleCreateEntities(final Map arguments) { + applyCreateEntities(arguments.get("entities")); + return List.of(); + } + + private void applyCreateEntities(final Object entitiesValue) { + final List entities = asList(entitiesValue, "createEntities.entities"); + for (final Object entitySpec : entities) { + final Map wrapper = asStringObjectMap(entitySpec, "createEntities.entity"); + if (wrapper.size() != 1) { + throw new IllegalArgumentException("createEntities entity must contain exactly one type"); + } + final Map.Entry entry = wrapper.entrySet().iterator().next(); + final String entityType = entry.getKey(); + final Map entity = asStringObjectMap(entry.getValue(), "createEntities." + entityType); + switch (entityType) { + case "client" -> requireText(entity.get("id"), "createEntities.client.id"); + case "database" -> registerDatabaseEntity(entity); + case "collection" -> registerCollectionEntity(entity); + case "session" -> registerSessionEntity(entity); + default -> throw new UnsupportedOperationException( + "unsupported UTF createEntities entity type: " + entityType); + } + } + } + + private void registerDatabaseEntity(final Map entity) { + final String id = requireText(entity.get("id"), "createEntities.database.id"); + final String databaseName = firstNonBlank( + trimToEmpty(entity.get("databaseName")), + trimToEmpty(entity.get("database_name")), + trimToEmpty(entity.get("database"))); + databaseAliases.put(id, fallbackText(databaseName, defaultDatabase, "createEntities.database.databaseName")); + } + + private void registerCollectionEntity(final Map entity) { + final String id = requireText(entity.get("id"), "createEntities.collection.id"); + final String collectionName = firstNonBlank( + trimToEmpty(entity.get("collectionName")), + trimToEmpty(entity.get("collection_name")), + trimToEmpty(entity.get("collection"))); + final String databaseRef = firstNonBlank( + trimToEmpty(entity.get("database")), + trimToEmpty(entity.get("databaseName")), + trimToEmpty(entity.get("database_name"))); + final String databaseName = resolveDatabaseName(databaseRef); + collectionAliases.put( + id, + new CollectionTarget( + fallbackText(databaseName, defaultDatabase, "database"), + fallbackText(collectionName, defaultCollection, "collection"))); + } + + private void registerSessionEntity(final Map entity) { + final String id = requireText(entity.get("id"), "createEntities.session.id"); + sessions.putIfAbsent(id, new SessionState()); + } + + private String resolveDatabaseName(final String rawDatabase) { + if (rawDatabase == null || rawDatabase.isBlank()) { + return defaultDatabase; + } + final String byAlias = databaseAliases.get(rawDatabase); + if (byAlias != null && !byAlias.isBlank()) { + return byAlias; + } + return rawDatabase; + } + + private CollectionTarget resolveCollectionTarget( + final String objectName, + final Map arguments) { + String database = trimToEmpty(arguments.get("database")); + String collection = trimToEmpty(arguments.get("collection")); + + final CollectionTarget byAlias = objectName.isEmpty() ? null : collectionAliases.get(objectName); + if (byAlias != null) { + if (database.isEmpty()) { + database = byAlias.database(); + } + if (collection.isEmpty()) { + collection = byAlias.collection(); + } + } + + if (database.isEmpty() && !objectName.isEmpty() && databaseAliases.containsKey(objectName)) { + database = databaseAliases.get(objectName); + } + if (collection.isEmpty() && !objectName.isEmpty() && !"testRunner".equals(objectName)) { + collection = objectName; + } + + return new CollectionTarget( + fallbackText(database, defaultDatabase, "database"), + fallbackText(collection, defaultCollection, "collection")); + } + + private void startTransaction(final String objectName, final Map arguments) { + final String sessionId = resolveSessionId("startTransaction", objectName, arguments); + final SessionState state = sessions.computeIfAbsent(sessionId, key -> new SessionState()); + state.startTransaction(arguments.get("readConcern")); + } + + private ScenarioCommand completeTransaction( + final String commandName, + final String objectName, + final Map arguments) { + final String sessionId = resolveSessionId(commandName, objectName, arguments); + final SessionState state = sessions.computeIfAbsent(sessionId, key -> new SessionState()); + final long txnNumber = state.txnNumberForCompletion(); + + final Map payload = new LinkedHashMap<>(); + payload.put(commandName, 1); + payload.put("$db", "admin"); + payload.put("lsid", immutableMap(Map.of("id", sessionId))); + payload.put("txnNumber", txnNumber); + payload.put("autocommit", false); + copyIfPresent(arguments, payload, "writeConcern"); + copyIfPresent(arguments, payload, "maxTimeMS"); + copyIfPresent(arguments, payload, "maxCommitTimeMS"); + + state.finishTransaction(); + return new ScenarioCommand(commandName, immutableMap(payload)); + } + + private ScenarioCommand applySessionEnvelope( + final ScenarioCommand command, + final Map arguments) { + final String sessionId = trimToEmpty(arguments.get("session")); + if (sessionId.isEmpty()) { + return command; + } + + final SessionState state = sessions.computeIfAbsent(sessionId, key -> new SessionState()); + final Map payload = new LinkedHashMap<>(command.payload()); + payload.put("lsid", immutableMap(Map.of("id", sessionId))); + + if (state.activeTxnNumber() != null) { + payload.put("txnNumber", state.activeTxnNumber()); + payload.put("autocommit", false); + if (state.startPending()) { + payload.put("startTransaction", true); + if (state.pendingReadConcern() != null && !payload.containsKey("readConcern")) { + payload.put("readConcern", deepCopyValue(state.pendingReadConcern())); + } + state.markStartConsumed(); + } + } + return new ScenarioCommand(command.commandName(), immutableMap(payload)); + } + + private String resolveSessionId( + final String operationName, + final String objectName, + final Map arguments) { + final String sessionFromArgs = trimToEmpty(arguments.get("session")); + if (!sessionFromArgs.isEmpty()) { + return sessionFromArgs; + } + final String sessionFromObject = trimToEmpty(objectName); + if (!sessionFromObject.isEmpty()) { + return sessionFromObject; + } + throw new IllegalArgumentException(operationName + " requires a session identifier"); + } + } + + private record CollectionTarget(String database, String collection) {} + + private static final class SessionState { + private long nextTxnNumber; + private Long activeTxnNumber; + private Long lastTxnNumber; + private boolean startPending; + private Object pendingReadConcern; + + private SessionState() { + this.nextTxnNumber = 1L; + } + + private SessionState(final SessionState source) { + this.nextTxnNumber = source.nextTxnNumber; + this.activeTxnNumber = source.activeTxnNumber; + this.lastTxnNumber = source.lastTxnNumber; + this.startPending = source.startPending; + this.pendingReadConcern = deepCopyValue(source.pendingReadConcern); + } + + private SessionState copy() { + return new SessionState(this); + } + + private void startTransaction(final Object readConcern) { + final long txnNumber = nextTxnNumber++; + this.activeTxnNumber = txnNumber; + this.lastTxnNumber = txnNumber; + this.startPending = true; + this.pendingReadConcern = deepCopyValue(readConcern); + } + + private long txnNumberForCompletion() { + if (activeTxnNumber != null) { + return activeTxnNumber; + } + if (lastTxnNumber != null) { + return lastTxnNumber; + } + final long fallback = Math.max(1L, nextTxnNumber - 1L); + this.lastTxnNumber = fallback; + return fallback; + } + + private void finishTransaction() { + this.activeTxnNumber = null; + this.startPending = false; + this.pendingReadConcern = null; + } + + private Long activeTxnNumber() { + return activeTxnNumber; + } + + private boolean startPending() { + return startPending; + } + + private Object pendingReadConcern() { + return pendingReadConcern; + } + + private void markStartConsumed() { + this.startPending = false; + this.pendingReadConcern = null; + } + } + public enum SkipKind { SKIPPED, UNSUPPORTED, diff --git a/src/test/java/org/jongodb/testkit/DifferentialHarnessNormalizationTest.java b/src/test/java/org/jongodb/testkit/DifferentialHarnessNormalizationTest.java index ca5d92b..8b9dd54 100644 --- a/src/test/java/org/jongodb/testkit/DifferentialHarnessNormalizationTest.java +++ b/src/test/java/org/jongodb/testkit/DifferentialHarnessNormalizationTest.java @@ -97,6 +97,41 @@ void ignoresCursorNamespaceDifferencesInSuccessComparison() { assertEquals(0, report.mismatchCount()); } + @Test + void ignoresCursorIdDifferencesInSuccessComparison() { + Scenario scenario = new Scenario("s4", "cursor id normalization", List.of(new ScenarioCommand("find", Map.of()))); + DifferentialBackend left = new StaticBackend( + "left", + ScenarioOutcome.success( + List.of( + Map.of( + "ok", + 1, + "cursor", + Map.of("id", 0, "ns", "app.users") + ) + ) + ) + ); + DifferentialBackend right = new StaticBackend( + "right", + ScenarioOutcome.success( + List.of( + Map.of( + "ok", + 1, + "cursor", + Map.of("id", 987654321L, "ns", "app.users") + ) + ) + ) + ); + + DifferentialReport report = new DifferentialHarness(left, right).run(List.of(scenario)); + + assertEquals(0, report.mismatchCount()); + } + private static final class StaticBackend implements DifferentialBackend { private final String name; private final ScenarioOutcome outcome; diff --git a/src/test/java/org/jongodb/testkit/ScenarioBsonCodecTest.java b/src/test/java/org/jongodb/testkit/ScenarioBsonCodecTest.java index 7a6b257..5f1f4ed 100644 --- a/src/test/java/org/jongodb/testkit/ScenarioBsonCodecTest.java +++ b/src/test/java/org/jongodb/testkit/ScenarioBsonCodecTest.java @@ -79,4 +79,51 @@ void formatFailureUsesWriteErrorMessageAndCode() { message ); } + + @Test + void toRealMongodCommandDocumentTranslatesCountDocumentsToAggregatePipeline() { + ScenarioCommand command = new ScenarioCommand( + "countDocuments", + Map.of( + "countDocuments", + "users", + "filter", + Map.of("role", "member"), + "skip", + 1, + "limit", + 2 + ) + ); + + var commandDocument = ScenarioBsonCodec.toRealMongodCommandDocument(command, "testkit_real"); + + assertEquals("users", commandDocument.getString("aggregate").getValue()); + assertTrue(commandDocument.get("pipeline").isArray()); + assertEquals(4, commandDocument.getArray("pipeline").size()); + assertTrue(commandDocument.get("cursor").isDocument()); + } + + @Test + void toRealMongodCommandDocumentTranslatesFindOneAndUpdateToFindAndModify() { + ScenarioCommand command = new ScenarioCommand( + "findOneAndUpdate", + Map.of( + "findOneAndUpdate", + "users", + "filter", + Map.of("_id", 1), + "update", + Map.of("$set", Map.of("name", "after")), + "returnDocument", + "after" + ) + ); + + var commandDocument = ScenarioBsonCodec.toRealMongodCommandDocument(command, "testkit_real"); + + assertEquals("users", commandDocument.getString("findAndModify").getValue()); + assertEquals(1, commandDocument.getDocument("query").getInt32("_id").getValue()); + assertTrue(commandDocument.getBoolean("new").getValue()); + } } diff --git a/src/test/java/org/jongodb/testkit/UnifiedSpecImporterTest.java b/src/test/java/org/jongodb/testkit/UnifiedSpecImporterTest.java index 2a4e120..306c5ff 100644 --- a/src/test/java/org/jongodb/testkit/UnifiedSpecImporterTest.java +++ b/src/test/java/org/jongodb/testkit/UnifiedSpecImporterTest.java @@ -322,4 +322,145 @@ void marksKnownUnsupportedAggregationFeaturesAsUnsupported() throws IOException assertTrue(result.skippedCases().stream().allMatch(skipped -> skipped.kind() == UnifiedSpecImporter.SkipKind.UNSUPPORTED)); } + + @Test + void importsCrudParityOperationsMappedToCommandLayer() throws IOException { + Files.writeString( + tempDir.resolve("crud-parity.json"), + """ + { + "database_name": "app", + "collection_name": "users", + "tests": [ + { + "description": "crud parity operations", + "operations": [ + {"name": "countDocuments", "arguments": {"filter": {"active": true}, "limit": 2}}, + {"name": "replaceOne", "arguments": {"filter": {"_id": 1}, "replacement": {"name": "neo"}, "upsert": true}}, + {"name": "findOneAndUpdate", "arguments": {"filter": {"_id": 1}, "update": {"$set": {"tier": 2}}, "returnDocument": "after"}}, + {"name": "findOneAndReplace", "arguments": {"filter": {"_id": 1}, "replacement": {"name": "trinity"}, "returnDocument": "before"}} + ] + } + ] + } + """); + + final UnifiedSpecImporter importer = new UnifiedSpecImporter(); + final UnifiedSpecImporter.ImportResult result = importer.importCorpus(tempDir); + + assertEquals(1, result.importedCount()); + assertEquals(0, result.unsupportedCount()); + + final Scenario scenario = result.importedScenarios().get(0).scenario(); + assertEquals(4, scenario.commands().size()); + assertEquals("countDocuments", scenario.commands().get(0).commandName()); + assertEquals("replaceOne", scenario.commands().get(1).commandName()); + assertEquals("findOneAndUpdate", scenario.commands().get(2).commandName()); + assertEquals("findOneAndReplace", scenario.commands().get(3).commandName()); + assertEquals("users", scenario.commands().get(0).payload().get("countDocuments")); + assertEquals("users", scenario.commands().get(1).payload().get("replaceOne")); + } + + @Test + void appliesTransactionEnvelopeWithCreateEntitiesSessions() throws IOException { + Files.writeString( + tempDir.resolve("transactions.yml"), + """ + schemaVersion: "1.3" + createEntities: + - client: + id: client0 + - database: + id: database0 + client: client0 + databaseName: tx-db + - collection: + id: collection0 + database: database0 + collectionName: tx-coll + - session: + id: session0 + client: client0 + tests: + - description: txn envelope + operations: + - object: session0 + name: startTransaction + arguments: + readConcern: + level: majority + - object: collection0 + name: insertOne + arguments: + session: session0 + document: + _id: 1 + - object: collection0 + name: countDocuments + arguments: + session: session0 + filter: {} + - object: session0 + name: commitTransaction + """); + + final UnifiedSpecImporter importer = new UnifiedSpecImporter(); + final UnifiedSpecImporter.ImportResult result = importer.importCorpus(tempDir); + + assertEquals(1, result.importedCount()); + assertEquals(0, result.unsupportedCount()); + + final Scenario scenario = result.importedScenarios().get(0).scenario(); + assertEquals(3, scenario.commands().size()); + + final ScenarioCommand first = scenario.commands().get(0); + assertEquals("insert", first.commandName()); + assertEquals("tx-coll", first.payload().get("insert")); + assertEquals("tx-db", first.payload().get("$db")); + assertEquals(true, first.payload().get("startTransaction")); + assertEquals(false, first.payload().get("autocommit")); + assertEquals(1L, first.payload().get("txnNumber")); + + final Object lsid = first.payload().get("lsid"); + assertTrue(lsid instanceof java.util.Map); + final java.util.Map lsidMap = (java.util.Map) lsid; + assertEquals("session0", lsidMap.get("id")); + assertTrue(first.payload().containsKey("readConcern")); + + final ScenarioCommand second = scenario.commands().get(1); + assertEquals("countDocuments", second.commandName()); + assertEquals(false, second.payload().get("autocommit")); + assertEquals(1L, second.payload().get("txnNumber")); + + final ScenarioCommand third = scenario.commands().get(2); + assertEquals("commitTransaction", third.commandName()); + assertEquals("admin", third.payload().get("$db")); + assertEquals(1L, third.payload().get("txnNumber")); + } + + @Test + void marksFailPointAsUnsupportedByPolicy() throws IOException { + Files.writeString( + tempDir.resolve("failpoint.yml"), + """ + tests: + - description: failpoint policy + operations: + - object: testRunner + name: failPoint + arguments: + failPoint: + configureFailPoint: failCommand + mode: alwaysOn + """); + + final UnifiedSpecImporter importer = new UnifiedSpecImporter(); + final UnifiedSpecImporter.ImportResult result = importer.importCorpus(tempDir); + + assertEquals(0, result.importedCount()); + assertEquals(1, result.unsupportedCount()); + assertTrue(result.skippedCases().stream().anyMatch(skipped -> + skipped.kind() == UnifiedSpecImporter.SkipKind.UNSUPPORTED + && skipped.reason().contains("unsupported-by-policy UTF operation: failPoint"))); + } }