-
Notifications
You must be signed in to change notification settings - Fork 124
test(workflow-core): add unit test coverage for port-identity serde helpers #4954
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
aglinxinyuan
wants to merge
5
commits into
apache:main
Choose a base branch
from
aglinxinyuan:test-port-identity-serde-spec
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+316
−0
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
01614a2
test(workflow-core): add unit test coverage for port-identity serde h…
aglinxinyuan 491766b
test(workflow-core): use JSONUtils.objectMapper directly + fix unders…
aglinxinyuan af5fb13
test(workflow-core): cover layerName underscore + lenient deserializeKey
aglinxinyuan f2fd9f5
Merge branch 'main' into test-port-identity-serde-spec
aglinxinyuan 5edd0a9
Merge branch 'main' into test-port-identity-serde-spec
aglinxinyuan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
316 changes: 316 additions & 0 deletions
316
...rkflow-core/src/test/scala/org/apache/texera/amber/util/serde/PortIdentitySerdeSpec.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,316 @@ | ||
| /* | ||
| * Licensed to the Apache Software Foundation (ASF) under one | ||
| * or more contributor license agreements. See the NOTICE file | ||
| * distributed with this work for additional information | ||
| * regarding copyright ownership. The ASF licenses this file | ||
| * to you under the Apache License, Version 2.0 (the | ||
| * "License"); you may not use this file except in compliance | ||
| * with the License. You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, | ||
| * software distributed under the License is distributed on an | ||
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| * KIND, either express or implied. See the License for the | ||
| * specific language governing permissions and limitations | ||
| * under the License. | ||
| */ | ||
|
|
||
| package org.apache.texera.amber.util.serde | ||
|
|
||
| import org.apache.texera.amber.core.virtualidentity.{OperatorIdentity, PhysicalOpIdentity} | ||
| import org.apache.texera.amber.core.workflow.{GlobalPortIdentity, PortIdentity} | ||
| import org.apache.texera.amber.util.JSONUtils.objectMapper | ||
| import org.apache.texera.amber.util.serde.GlobalPortIdentitySerde.SerdeOps | ||
| import org.scalatest.flatspec.AnyFlatSpec | ||
|
|
||
| class PortIdentitySerdeSpec extends AnyFlatSpec { | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // GlobalPortIdentitySerde | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| private def globalPort( | ||
| logical: String = "op-A", | ||
| layer: String = "main", | ||
| portIdValue: Int = 0, | ||
| internal: Boolean = false, | ||
| input: Boolean = true | ||
| ): GlobalPortIdentity = | ||
| GlobalPortIdentity( | ||
| opId = PhysicalOpIdentity(OperatorIdentity(logical), layer), | ||
| portId = PortIdentity(id = portIdValue, internal = internal), | ||
| input = input | ||
| ) | ||
|
|
||
| "GlobalPortIdentitySerde" should "round-trip a default GlobalPortIdentity through serializeAsString → deserializeFromString" in { | ||
| val original = globalPort() | ||
| val restored = GlobalPortIdentitySerde.deserializeFromString(original.serializeAsString) | ||
| assert(restored == original) | ||
| } | ||
|
|
||
| it should "preserve all five fields independently across the round-trip" in { | ||
| // Vary each field individually so a regression that swapped two fields | ||
| // (e.g., isInput / isInternal) would surface here, not as a general | ||
| // round-trip failure. | ||
| val cases = Seq( | ||
| globalPort(logical = "op-A"), | ||
| globalPort(logical = "op-Z"), | ||
| globalPort(layer = "main"), | ||
| globalPort(layer = "extra-layer"), | ||
| globalPort(portIdValue = 0), | ||
| globalPort(portIdValue = 7), | ||
| globalPort(internal = false), | ||
| globalPort(internal = true), | ||
| globalPort(input = true), | ||
| globalPort(input = false) | ||
| ) | ||
| cases.foreach { p => | ||
| val s = p.serializeAsString | ||
| val restored = GlobalPortIdentitySerde.deserializeFromString(s) | ||
| assert(restored == p, s"round-trip mismatch for $p (serialized: $s)") | ||
| } | ||
| } | ||
|
|
||
| it should "produce the documented format for default and non-default values" in { | ||
| // Pin the exact format. If this changes, callers reading existing | ||
| // VFS URIs from disk will break — locking it down forces a deliberate | ||
| // migration story. | ||
| assert( | ||
| globalPort().serializeAsString == | ||
| "(logicalOpId=op-A,layerName=main,portId=0,isInternal=false,isInput=true)" | ||
| ) | ||
| assert( | ||
| globalPort( | ||
| logical = "op-Z", | ||
| layer = "extra-layer", | ||
| portIdValue = 7, | ||
| internal = true, | ||
| input = false | ||
| ).serializeAsString == | ||
| "(logicalOpId=op-Z,layerName=extra-layer,portId=7,isInternal=true,isInput=false)" | ||
| ) | ||
| } | ||
|
|
||
| it should "round-trip identifiers containing dashes and dots (regex non-comma matcher)" in { | ||
| // The deserialization regex uses `[^,]+` for the field body, so any | ||
| // non-comma character is fair game. Cover the realistic counter- | ||
| // examples (dashes, dots) since logical op ids and layer names use | ||
| // both; if the regex were ever tightened to alphanumerics only, this | ||
| // would fail on purpose. | ||
| val p = globalPort(logical = "my.op-with-dashes.v2", layer = "main-1") | ||
| assert(GlobalPortIdentitySerde.deserializeFromString(p.serializeAsString) == p) | ||
| } | ||
|
|
||
| it should "round-trip a negative port id" in { | ||
| // PortIdentity.id is a plain Int; negatives are technically permitted | ||
| // by the type. Pin the round-trip so a future tightening of the | ||
| // numeric regex (e.g. to `\\d+`) breaks this on purpose. | ||
| val p = globalPort(portIdValue = -1) | ||
| assert(GlobalPortIdentitySerde.deserializeFromString(p.serializeAsString) == p) | ||
| } | ||
|
|
||
| it should "throw IllegalArgumentException when the input has the wrong field order" in { | ||
| // The regex pins the documented field order; a swapped order should | ||
| // not silently parse with confused values. | ||
| val swapped = "(layerName=main,logicalOpId=op-A,portId=0,isInternal=false,isInput=true)" | ||
| intercept[IllegalArgumentException] { | ||
| GlobalPortIdentitySerde.deserializeFromString(swapped) | ||
| } | ||
| } | ||
|
|
||
| it should "throw IllegalArgumentException when the input has trailing content past the closing paren" in { | ||
| val withTrailing = | ||
| "(logicalOpId=op-A,layerName=main,portId=0,isInternal=false,isInput=true) extra" | ||
| intercept[IllegalArgumentException] { | ||
| GlobalPortIdentitySerde.deserializeFromString(withTrailing) | ||
| } | ||
| } | ||
|
|
||
| it should "throw IllegalArgumentException when a field body is empty" in { | ||
| // `[^,]+` requires at least one character, so an empty layerName | ||
| // (`,layerName=,`) must fail to match. | ||
| val emptyLayer = "(logicalOpId=op-A,layerName=,portId=0,isInternal=false,isInput=true)" | ||
| intercept[IllegalArgumentException] { | ||
| GlobalPortIdentitySerde.deserializeFromString(emptyLayer) | ||
| } | ||
| } | ||
|
|
||
| it should "throw IllegalArgumentException on a completely malformed string" in { | ||
| val ex = intercept[IllegalArgumentException] { | ||
| GlobalPortIdentitySerde.deserializeFromString("not even close") | ||
| } | ||
| assert(ex.getMessage.contains("not even close")) | ||
| } | ||
|
|
||
| it should "throw IllegalArgumentException when a required field is missing" in { | ||
| // Drop isInput. | ||
| val malformed = "(logicalOpId=op-A,layerName=main,portId=0,isInternal=false)" | ||
| intercept[IllegalArgumentException] { | ||
| GlobalPortIdentitySerde.deserializeFromString(malformed) | ||
| } | ||
| } | ||
|
|
||
| it should "throw NumberFormatException when portId is non-numeric" in { | ||
| // The regex matches (`[^,]+`) but `.toInt` fails. NumberFormatException | ||
| // extends IllegalArgumentException; assert the more specific type so a | ||
| // regression that swallowed/rewrapped it is visible. | ||
| val malformed = "(logicalOpId=op-A,layerName=main,portId=NaN,isInternal=false,isInput=true)" | ||
| intercept[NumberFormatException] { | ||
| GlobalPortIdentitySerde.deserializeFromString(malformed) | ||
| } | ||
| } | ||
|
|
||
| it should "throw IllegalArgumentException when a boolean field is non-boolean" in { | ||
| // `String.toBoolean` is strict: only \"true\" / \"false\" (case-insensitive) | ||
| // pass; anything else throws IllegalArgumentException. | ||
| val malformed = "(logicalOpId=op-A,layerName=main,portId=0,isInternal=maybe,isInput=true)" | ||
| intercept[IllegalArgumentException] { | ||
| GlobalPortIdentitySerde.deserializeFromString(malformed) | ||
| } | ||
| } | ||
|
|
||
| it should "use no underscore in its own format characters (separators / keys)" in { | ||
| // Pin the format-character invariant: the wrapping `(...)`, the field | ||
| // separators `,`, the key=value separators, and the field NAMES | ||
| // themselves contain no underscore. Verify by building the format with | ||
| // empty-string-replacement values for every input field, so anything | ||
| // left in the output is purely from `serializeAsString`'s own format. | ||
| // (For the layerName field the empty-input variant is rejected by the | ||
| // deserializer regex; here we only check the SERIALIZED output, not the | ||
| // round-trip.) | ||
| val s = globalPort(logical = "x", layer = "x").serializeAsString | ||
| val formatChars = s.replace("x", "").replace("0", "").replace("false", "").replace("true", "") | ||
| assert(!formatChars.contains("_"), s"format characters must be underscore-free: $formatChars") | ||
| } | ||
|
|
||
| it should "eventually produce an underscore-free output even for inputs that contain underscores (pendingUntilFixed)" in pendingUntilFixed { | ||
| // Documented contract on `GlobalPortIdentitySerde`: "does not include | ||
| // underscore '_' so that it does not interfere with our own VFS URI | ||
| // parsing." The implementation does NOT enforce this — inputs are | ||
| // interpolated verbatim. Both fields can carry underscores in real | ||
| // production data: | ||
| // - `logicalOpId`: e.g. `__DummyOperator` from `VirtualIdentityUtils` | ||
| // - `layerName`: e.g. `${layerName}_source_${portId}_...` constructed | ||
| // by `SpecialPhysicalOpFactory` | ||
| // Cover BOTH so a partial fix that escapes only one of them flips | ||
| // pendingUntilFixed into a deliberate failure with the second | ||
| // assertion still red. | ||
| val withUnderscoreOpId = globalPort(logical = "__DummyOperator").serializeAsString | ||
| assert( | ||
| !withUnderscoreOpId.contains("_"), | ||
| s"serialized form must be underscore-free for op id: $withUnderscoreOpId" | ||
| ) | ||
| val withUnderscoreLayer = globalPort(layer = "main_source_0_op").serializeAsString | ||
| assert( | ||
| !withUnderscoreLayer.contains("_"), | ||
| s"serialized form must be underscore-free for layer name: $withUnderscoreLayer" | ||
| ) | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // PortIdentityKeySerializer.portIdToString (companion, not the Jackson class) | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| "PortIdentityKeySerializer.portIdToString" should "format a PortIdentity as `id_internal`" in { | ||
| assert(PortIdentityKeySerializer.portIdToString(PortIdentity(0, internal = false)) == "0_false") | ||
| assert(PortIdentityKeySerializer.portIdToString(PortIdentity(7, internal = true)) == "7_true") | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // PortIdentityKeySerializer + PortIdentityKeyDeserializer (Jackson wiring) | ||
| // --------------------------------------------------------------------------- | ||
| // | ||
| // These tests use the production `JSONUtils.objectMapper` directly so a | ||
| // regression in the singleton wiring (e.g. the module that registers the | ||
| // PortIdentity key (de)serializer being removed or reordered) surfaces | ||
| // here, not just on a freshly-constructed mapper. | ||
|
|
||
| "PortIdentity Jackson key (de)serialization" should "round-trip a Map[PortIdentity, String] via JSONUtils.objectMapper" in { | ||
| val original = Map( | ||
| PortIdentity(0, internal = false) -> "a", | ||
| PortIdentity(1, internal = true) -> "b" | ||
| ) | ||
| val json = objectMapper.writeValueAsString(original) | ||
| // Verify the JSON keys match the documented `id_internal` format. | ||
| assert(json.contains("\"0_false\"")) | ||
| assert(json.contains("\"1_true\"")) | ||
| val tref = objectMapper.getTypeFactory | ||
| .constructMapType(classOf[java.util.HashMap[_, _]], classOf[PortIdentity], classOf[String]) | ||
| val restored: java.util.Map[PortIdentity, String] = objectMapper.readValue(json, tref) | ||
| import scala.jdk.CollectionConverters._ | ||
| assert(restored.asScala.toMap == original) | ||
| } | ||
|
|
||
| it should "round-trip an empty Map[PortIdentity, V] without invoking the (de)serializer" in { | ||
| val original = Map.empty[PortIdentity, String] | ||
| val json = objectMapper.writeValueAsString(original) | ||
| val tref = objectMapper.getTypeFactory | ||
| .constructMapType(classOf[java.util.HashMap[_, _]], classOf[PortIdentity], classOf[String]) | ||
| val restored: java.util.Map[PortIdentity, String] = objectMapper.readValue(json, tref) | ||
| assert(restored.isEmpty) | ||
| } | ||
|
|
||
| "PortIdentityKeyDeserializer.deserializeKey" should "throw NumberFormatException for a non-integer id" in { | ||
| val d = new PortIdentityKeyDeserializer | ||
| intercept[NumberFormatException] { | ||
| d.deserializeKey("notAnInt_false", null) | ||
| } | ||
| } | ||
|
|
||
| it should "throw IllegalArgumentException for a non-boolean internal flag" in { | ||
| val d = new PortIdentityKeyDeserializer | ||
| intercept[IllegalArgumentException] { | ||
| d.deserializeKey("0_notABool", null) | ||
| } | ||
| } | ||
|
|
||
| it should "throw NumberFormatException when the underscore separator is missing and the whole string is non-numeric" in { | ||
| // `key.split("_")` on a separator-less non-numeric string yields a | ||
| // single-element array, and `parts(0).toInt` fires first → NFE. | ||
| val d = new PortIdentityKeyDeserializer | ||
| intercept[NumberFormatException] { | ||
| d.deserializeKey("missingSeparator", null) | ||
| } | ||
| } | ||
|
|
||
| it should "throw ArrayIndexOutOfBoundsException when only the id is provided (no `_internal` suffix)" in { | ||
| // Different separator-missing path: `\"5\".split(\"_\")` yields | ||
| // [\"5\"], parts(0).toInt = 5 succeeds, then parts(1) reads past the | ||
| // end. Pin this failure mode explicitly so a future safer parser | ||
| // breaks the spec on purpose (and the safer error type is chosen | ||
| // consciously). | ||
| val d = new PortIdentityKeyDeserializer | ||
| intercept[ArrayIndexOutOfBoundsException] { | ||
| d.deserializeKey("5", null) | ||
| } | ||
| } | ||
|
|
||
| it should "silently accept extra trailing underscore-separated segments (lenient parser, current behavior)" in { | ||
| // Pin the current lenient behavior: `parts(0).toInt` and | ||
| // `parts(1).toBoolean` ignore everything past `parts(1)`, so a key | ||
| // like `"1_true_garbage"` deserializes to `PortIdentity(1, true)` | ||
| // without complaint. The strict-rejection variant lives in a | ||
| // pendingUntilFixed test below; characterizing today's lenient | ||
| // path here means a future-tightening fix would need to update | ||
| // both tests deliberately. | ||
| val d = new PortIdentityKeyDeserializer | ||
| val pid = d.deserializeKey("1_true_garbage", null) | ||
| assert(pid == PortIdentity(1, internal = true)) | ||
| } | ||
|
|
||
| it should "eventually reject keys with extra trailing segments (pendingUntilFixed)" in pendingUntilFixed { | ||
| // Documented contract: a `PortIdentityKeySerializer` output is exactly | ||
| // `id_internal` — two underscore-separated segments. Anything else is | ||
| // corrupt JSON and should be rejected, not silently truncated. The | ||
| // current implementation is lenient (see characterization test | ||
| // above); this pendingUntilFixed flips to passing once the parser | ||
| // is hardened, then `pendingUntilFixed` inverts that into a | ||
| // deliberate failure forcing the marker to be removed. | ||
| val d = new PortIdentityKeyDeserializer | ||
| intercept[IllegalArgumentException] { | ||
| d.deserializeKey("1_true_garbage", null) | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.