You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/06/15 06:33:27 UTC
(texera) 01/01: test(workflow-operator): add unit test coverage for visualization chart-config classes (#5697)
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 <xi...@uci.edu>
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")
+ }
+}