This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5697-0f79251c56d36ca9b4c05e53cf2625a6ad49a131 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 612a79cbe48dc915e2975d856541847a918322b4 Author: Xinyuan Lin <[email protected]> AuthorDate: Sun Jun 14 23:33:05 2026 -0700 test(workflow-operator): add unit test coverage for visualization chart-config classes (#5697) ### What changes were proposed in this PR? Pin behavior of four previously-uncovered Jackson-annotated config classes that back visualization operators in `common/workflow-operator/operator/visualization/`. No production-code changes. | Spec | Source class | Tests | | --- | --- | --- | | `TablesConfigSpec` | `TablesConfig` | 7 | | `NestedTableConfigSpec` | `NestedTableConfig` | 9 | | `FigureFactoryTableConfigSpec` | `FigureFactoryTableConfig` | 7 | | `DumbbellDotConfigSpec` | `DumbbellDotConfig` | 9 | All four spec files follow the `<srcClassName>Spec.scala` one-to-one convention. **Behavior pinned (each class)** | Surface | Contract | | --- | --- | | Default field values | empty strings on a fresh instance | | Mutability | `var` fields are assignable post-construction | | JSON round-trip via `objectMapper.writeValueAsString` + `readValue` | preserves every field | | `@JsonProperty(required = true)` annotation | present on every documented required field — verified via reflection | | Distinct instances | no static-field leakage across `new` calls | **Per-class specifics** | Spec | Additional pins | | --- | --- | | `TablesConfigSpec` | `@NotNull` annotation on `attributeName` (jakarta validation) | | `NestedTableConfigSpec` | `newName` serializes under wire-key `name` (per `@JsonProperty(value = "name")`); the field name `newName` MUST NOT appear as a JSON key; deserialization from `{"name":...}` re-populates `newName` | | `FigureFactoryTableConfigSpec` | `@AutofillAttributeName` annotation on `attributeName` (UI dropdown contract) | | `DumbbellDotConfigSpec` | `dotValue` serializes under wire-key `dot`; `@NotNull` + `@AutofillAttributeName` annotations; class-level `@JsonSchemaInject` restricts `dot` to integer/long/double attribute types | ### Any related issues, documentation, discussions? Closes #5694. ### How was this PR tested? Pure unit-test additions; verified locally with: - `sbt "WorkflowOperator/testOnly org.apache.texera.amber.operator.visualization.tablesChart.TablesConfigSpec org.apache.texera.amber.operator.visualization.nestedTable.NestedTableConfigSpec org.apache.texera.amber.operator.visualization.figureFactoryTable.FigureFactoryTableConfigSpec org.apache.texera.amber.operator.visualization.dumbbellPlot.DumbbellDotConfigSpec"` — 32 tests, all green - `sbt scalafmtCheckAll` — clean - CI to confirm ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.7 [1M context]) --- .../dumbbellPlot/DumbbellDotConfigSpec.scala | 134 +++++++++++++++++++++ .../FigureFactoryTableConfigSpec.scala | 99 +++++++++++++++ .../nestedTable/NestedTableConfigSpec.scala | 132 ++++++++++++++++++++ .../tablesChart/TablesConfigSpec.scala | 108 +++++++++++++++++ 4 files changed, 473 insertions(+) diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/dumbbellPlot/DumbbellDotConfigSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/dumbbellPlot/DumbbellDotConfigSpec.scala new file mode 100644 index 0000000000..d1761817d5 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/dumbbellPlot/DumbbellDotConfigSpec.scala @@ -0,0 +1,134 @@ +/* + * 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.operator.visualization.dumbbellPlot + +import com.fasterxml.jackson.annotation.JsonProperty +import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaInject +import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName +import org.apache.texera.amber.util.JSONUtils.objectMapper +import org.scalatest.flatspec.AnyFlatSpec + +import javax.validation.constraints.NotNull + +class DumbbellDotConfigSpec extends AnyFlatSpec { + + // --------------------------------------------------------------------------- + // Default state + // --------------------------------------------------------------------------- + + "DumbbellDotConfig" should "default dotValue to the empty string" in { + val c = new DumbbellDotConfig + assert(c.dotValue == "") + } + + // --------------------------------------------------------------------------- + // Mutability + // --------------------------------------------------------------------------- + + it should "allow dotValue to be assigned post-construction" in { + val c = new DumbbellDotConfig + c.dotValue = "numeric-col" + assert(c.dotValue == "numeric-col") + } + + // --------------------------------------------------------------------------- + // JSON round-trip — verify both the Scala field name AND the wire key + // --------------------------------------------------------------------------- + + "DumbbellDotConfig JSON round-trip" should + "preserve dotValue via the wire-key `dot` (per @JsonProperty(value = \"dot\"))" in { + val original = new DumbbellDotConfig + original.dotValue = "amount" + val json = objectMapper.writeValueAsString(original) + // Parse the JSON into a tree and assert on field presence + value + // directly — this stays robust to formatting changes (spaces, key + // ordering) that pure substring matching would mistake for drift. + val tree = objectMapper.readTree(json) + assert(tree.has("dot"), s"expected wire-key 'dot' in JSON, got: $json") + assert(tree.get("dot").asText() == "amount") + assert( + !tree.has("dotValue"), + s"field name 'dotValue' must NOT appear as a JSON key, got: $json" + ) + val restored = objectMapper.readValue(json, classOf[DumbbellDotConfig]) + assert(restored.dotValue == "amount") + } + + it should "deserialize from the wire-key `dot` back into dotValue" in { + val json = """{"dot":"amount"}""" + val restored = objectMapper.readValue(json, classOf[DumbbellDotConfig]) + assert(restored.dotValue == "amount") + } + + // --------------------------------------------------------------------------- + // Annotations + // --------------------------------------------------------------------------- + + "DumbbellDotConfig#dotValue" should + "carry @JsonProperty(value = 'dot', required = true)" in { + val jp = classOf[DumbbellDotConfig] + .getDeclaredField("dotValue") + .getAnnotation(classOf[JsonProperty]) + assert(jp != null) + val actualValue = jp.value + assert(actualValue == "dot", s"expected value='dot', got: '$actualValue'") + assert(jp.required, "dotValue must be marked required") + } + + it should "carry @NotNull (javax.validation contract)" in { + val notNull = classOf[DumbbellDotConfig] + .getDeclaredField("dotValue") + .getAnnotation(classOf[NotNull]) + assert(notNull != null, "dotValue must carry @NotNull for javax.validation") + } + + it should "carry @AutofillAttributeName (UI populates the dropdown from the input schema)" in { + val ann = classOf[DumbbellDotConfig] + .getDeclaredField("dotValue") + .getAnnotation(classOf[AutofillAttributeName]) + assert(ann != null) + } + + "DumbbellDotConfig (class-level)" should + "carry @JsonSchemaInject restricting `dot` to integer/long/double attribute types" in { + // The class-level @JsonSchemaInject is what tells the UI to filter the + // attribute dropdown to numeric columns only. Drift here would silently + // accept non-numeric attributes and break the chart at render time. + val ann = classOf[DumbbellDotConfig].getAnnotation(classOf[JsonSchemaInject]) + assert(ann != null, "class-level @JsonSchemaInject must be present") + val payload = ann.json + assert(payload.contains("attributeTypeRules")) + assert(payload.contains("\"dot\"")) + assert(payload.contains("integer")) + assert(payload.contains("long")) + assert(payload.contains("double")) + } + + // --------------------------------------------------------------------------- + // Instance independence + // --------------------------------------------------------------------------- + + it should "construct two independent instances (no static state shared)" in { + val a = new DumbbellDotConfig + val b = new DumbbellDotConfig + a.dotValue = "first" + assert(b.dotValue == "") + } +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableConfigSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableConfigSpec.scala new file mode 100644 index 0000000000..e7a6329159 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/figureFactoryTable/FigureFactoryTableConfigSpec.scala @@ -0,0 +1,99 @@ +/* + * 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.operator.visualization.figureFactoryTable + +import com.fasterxml.jackson.annotation.JsonProperty +import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName +import org.apache.texera.amber.util.JSONUtils.objectMapper +import org.scalatest.flatspec.AnyFlatSpec + +class FigureFactoryTableConfigSpec extends AnyFlatSpec { + + // --------------------------------------------------------------------------- + // Default state + // --------------------------------------------------------------------------- + + "FigureFactoryTableConfig" should "default attributeName to the empty string" in { + val c = new FigureFactoryTableConfig + assert(c.attributeName == "") + } + + // --------------------------------------------------------------------------- + // Mutability + // --------------------------------------------------------------------------- + + it should "allow attributeName to be assigned post-construction" in { + val c = new FigureFactoryTableConfig + c.attributeName = "col-z" + assert(c.attributeName == "col-z") + } + + // --------------------------------------------------------------------------- + // JSON round-trip + // --------------------------------------------------------------------------- + + "FigureFactoryTableConfig JSON round-trip" should "preserve attributeName" in { + val original = new FigureFactoryTableConfig + original.attributeName = "x" + val restored = objectMapper.readValue( + objectMapper.writeValueAsString(original), + classOf[FigureFactoryTableConfig] + ) + assert(restored.attributeName == "x") + } + + it should "round-trip the default empty attributeName" in { + val restored = objectMapper.readValue( + objectMapper.writeValueAsString(new FigureFactoryTableConfig), + classOf[FigureFactoryTableConfig] + ) + assert(restored.attributeName == "") + } + + // --------------------------------------------------------------------------- + // Annotations + // --------------------------------------------------------------------------- + + "FigureFactoryTableConfig#attributeName" should "carry @JsonProperty(required = true)" in { + val jp = classOf[FigureFactoryTableConfig] + .getDeclaredField("attributeName") + .getAnnotation(classOf[JsonProperty]) + assert(jp != null && jp.required) + } + + it should + "carry @AutofillAttributeName (UI relies on this to populate a dropdown)" in { + val ann = classOf[FigureFactoryTableConfig] + .getDeclaredField("attributeName") + .getAnnotation(classOf[AutofillAttributeName]) + assert(ann != null, "@AutofillAttributeName must be present so the UI auto-populates") + } + + // --------------------------------------------------------------------------- + // Instance independence + // --------------------------------------------------------------------------- + + it should "construct two independent instances (no static state shared)" in { + val a = new FigureFactoryTableConfig + val b = new FigureFactoryTableConfig + a.attributeName = "first" + assert(b.attributeName == "") + } +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/nestedTable/NestedTableConfigSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/nestedTable/NestedTableConfigSpec.scala new file mode 100644 index 0000000000..199efab00c --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/nestedTable/NestedTableConfigSpec.scala @@ -0,0 +1,132 @@ +/* + * 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.operator.visualization.nestedTable + +import com.fasterxml.jackson.annotation.JsonProperty +import org.apache.texera.amber.util.JSONUtils.objectMapper +import org.scalatest.flatspec.AnyFlatSpec + +class NestedTableConfigSpec extends AnyFlatSpec { + + // --------------------------------------------------------------------------- + // Default state + // --------------------------------------------------------------------------- + + "NestedTableConfig" should "default every field to the empty string" in { + val c = new NestedTableConfig + assert(c.attributeGroup == "") + assert(c.originalName == "") + assert(c.newName == "") + } + + // --------------------------------------------------------------------------- + // Mutability + // --------------------------------------------------------------------------- + + it should "allow every field to be assigned post-construction" in { + val c = new NestedTableConfig + c.attributeGroup = "g" + c.originalName = "orig" + c.newName = "renamed" + assert(c.attributeGroup == "g") + assert(c.originalName == "orig") + assert(c.newName == "renamed") + } + + // --------------------------------------------------------------------------- + // JSON round-trip + // --------------------------------------------------------------------------- + + "NestedTableConfig JSON round-trip" should "preserve all three fields" in { + val original = new NestedTableConfig + original.attributeGroup = "group-1" + original.originalName = "src-name" + original.newName = "dst-name" + val json = objectMapper.writeValueAsString(original) + val restored = objectMapper.readValue(json, classOf[NestedTableConfig]) + assert(restored.attributeGroup == "group-1") + assert(restored.originalName == "src-name") + assert(restored.newName == "dst-name") + } + + it should + "serialize newName under the JSON key `name` (per @JsonProperty(value = \"name\"))" in { + // The wire-key for `newName` is `name`, not `newName` — a regression + // that drifted to `newName` would silently break every workflow JSON + // that carries this config. Parse the JSON into a tree so the + // assertion is robust to Jackson formatting (spaces, key ordering) + // and unambiguous about which key carries which value. + val c = new NestedTableConfig + c.newName = "renamed" + val tree = objectMapper.readTree(objectMapper.writeValueAsString(c)) + assert(tree.has("name"), s"expected wire-key 'name' in JSON tree: $tree") + assert(tree.get("name").asText() == "renamed") + assert( + !tree.has("newName"), + s"field name 'newName' must NOT appear as a JSON key, got: $tree" + ) + } + + it should "deserialize from the wire-key `name` back into newName" in { + val json = """{"attributeGroup":"g","originalName":"orig","name":"renamed"}""" + val restored = objectMapper.readValue(json, classOf[NestedTableConfig]) + assert(restored.newName == "renamed") + } + + // --------------------------------------------------------------------------- + // Annotations — required/optional via reflection + // --------------------------------------------------------------------------- + + "NestedTableConfig#attributeGroup" should "carry @JsonProperty(required = true)" in { + val jp = classOf[NestedTableConfig] + .getDeclaredField("attributeGroup") + .getAnnotation(classOf[JsonProperty]) + assert(jp != null && jp.required) + } + + "NestedTableConfig#originalName" should "carry @JsonProperty(required = true)" in { + val jp = classOf[NestedTableConfig] + .getDeclaredField("originalName") + .getAnnotation(classOf[JsonProperty]) + assert(jp != null && jp.required) + } + + "NestedTableConfig#newName" should + "carry @JsonProperty(value = 'name', required = false) — the optional renamed field" in { + val jp = classOf[NestedTableConfig] + .getDeclaredField("newName") + .getAnnotation(classOf[JsonProperty]) + assert(jp != null) + val actualValue = jp.value + assert(actualValue == "name", s"expected value='name', got: '$actualValue'") + assert(!jp.required, "newName must NOT be marked required") + } + + // --------------------------------------------------------------------------- + // Instance independence + // --------------------------------------------------------------------------- + + it should "construct two independent instances (no static state shared)" in { + val a = new NestedTableConfig + val b = new NestedTableConfig + a.attributeGroup = "first" + assert(b.attributeGroup == "") + } +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesConfigSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesConfigSpec.scala new file mode 100644 index 0000000000..48ea3fe136 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/tablesChart/TablesConfigSpec.scala @@ -0,0 +1,108 @@ +/* + * 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.operator.visualization.tablesChart + +import com.fasterxml.jackson.annotation.JsonProperty +import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName +import org.apache.texera.amber.util.JSONUtils.objectMapper +import org.scalatest.flatspec.AnyFlatSpec + +import javax.validation.constraints.NotNull + +class TablesConfigSpec extends AnyFlatSpec { + + // --------------------------------------------------------------------------- + // Default state + // --------------------------------------------------------------------------- + + "TablesConfig" should "default attributeName to the empty string" in { + val c = new TablesConfig + assert(c.attributeName == "") + } + + // --------------------------------------------------------------------------- + // Mutability — the @JsonProperty bag is `var`-based by design + // --------------------------------------------------------------------------- + + it should "allow attributeName to be assigned post-construction" in { + val c = new TablesConfig + c.attributeName = "col-a" + assert(c.attributeName == "col-a") + } + + // --------------------------------------------------------------------------- + // JSON round-trip preserves the field + // --------------------------------------------------------------------------- + + "TablesConfig JSON round-trip" should + "serialize and deserialize attributeName unchanged" in { + val original = new TablesConfig + original.attributeName = "my-attr" + val json = objectMapper.writeValueAsString(original) + val restored = objectMapper.readValue(json, classOf[TablesConfig]) + assert(restored.attributeName == "my-attr") + } + + it should "round-trip the default empty attributeName" in { + val original = new TablesConfig + val restored = objectMapper.readValue( + objectMapper.writeValueAsString(original), + classOf[TablesConfig] + ) + assert(restored.attributeName == "") + } + + // --------------------------------------------------------------------------- + // Independent instances — no static-field leakage + // --------------------------------------------------------------------------- + + it should "construct two independent instances (no static state shared)" in { + val a = new TablesConfig + val b = new TablesConfig + a.attributeName = "first" + assert(b.attributeName == "", "second instance must not see first instance's mutation") + } + + // --------------------------------------------------------------------------- + // Annotations — verified via reflection (the Jackson + validation + // contract is what consumers actually depend on) + // --------------------------------------------------------------------------- + + "TablesConfig#attributeName" should "carry @JsonProperty(required = true)" in { + val field = classOf[TablesConfig].getDeclaredField("attributeName") + val jp = field.getAnnotation(classOf[JsonProperty]) + assert(jp != null, "attributeName must carry @JsonProperty") + assert(jp.required, "attributeName must be marked required") + } + + it should "carry @NotNull (javax.validation contract)" in { + val field = classOf[TablesConfig].getDeclaredField("attributeName") + val notNull = field.getAnnotation(classOf[NotNull]) + assert(notNull != null, "attributeName must carry @NotNull for javax.validation") + } + + it should + "carry @AutofillAttributeName (UI populates the dropdown from the input schema)" in { + val ann = classOf[TablesConfig] + .getDeclaredField("attributeName") + .getAnnotation(classOf[AutofillAttributeName]) + assert(ann != null, "@AutofillAttributeName must be present so the UI auto-populates") + } +}
