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-5797-8582cba2f66cfb0eef8e9669381bfa4876dd5bca in repository https://gitbox.apache.org/repos/asf/texera.git
commit c8a4cd9f3a8ee5a832929767e8e720195af918b7 Author: Xinyuan Lin <[email protected]> AuthorDate: Fri Jun 19 19:26:14 2026 -0700 test(workflow-operator): add unit test coverage for small visualization config bags (BulletChartStepDefinition + HierarchySection + BandConfig) (#5797) ### What changes were proposed in this PR? Pin defaults, mutability, JSON round-trip, and annotation surface for three small Jackson-deserializable config classes in the visualization operator subtree. Each one lives on a descriptor's wire-format (frontend ↔ backend JSON), so any drift in defaults, wire-key names, or annotation values silently breaks the workflow saved-state round-trip. No production-code changes. | Spec | Source class | Tests | | --- | --- | --- | | `BulletChartStepDefinitionSpec` | `BulletChartStepDefinition` | 7 | | `HierarchySectionSpec` | `HierarchySection` | 7 | | `BandConfigSpec` | `BandConfig` | 9 | All three spec files follow the `<srcClassName>Spec.scala` one-to-one convention. **Behavior pinned — `BulletChartStepDefinition`** | Surface | Contract | | --- | --- | | Construction | stores both `@JsonCreator` constructor arguments | | Mutability | both `var` fields are reassignable | | Wire keys | serializes under `start` / `end` (tree-API verified) | | JSON round-trip | preserves both fields | | Annotations | `@JsonProperty(\"start\")` and `@JsonProperty(\"end\")` live on the **constructor parameters** (Scala's default for `var` ctor params unless `@meta.field` is used); verified via `Constructor.getParameterAnnotations` | | Instance independence | no static state shared | **Behavior pinned — `HierarchySection`** | Surface | Contract | | --- | --- | | Defaults | `attributeName == \"\"` | | Mutability | `attributeName` is reassignable | | JSON round-trip | preserves the field, including the default-empty case | | Annotations | `@JsonProperty(required = true)` + `@AutofillAttributeName` + `@NotNull(\"Attribute Name cannot be empty\")` | | Instance independence | no static state shared | **Behavior pinned — `BandConfig`** | Surface | Contract | | --- | --- | | Inheritance | extends `LineConfig` (compile-time enforced) | | Defaults | `yUpper`, `yLower`, `fillColor` all default to `\"\"` | | Mutability | all three fields are reassignable | | JSON round-trip | preserves all three fields | | `yUpper` annotations | `@JsonProperty(required = true)` + `@NotNull(\"Y-Axis Upper Bound cannot be empty\")` + `@AutofillAttributeName` | | `yLower` annotations | `@JsonProperty(required = true)` + `@NotNull(\"Y-Axis Lower Bound cannot be empty\")` + `@AutofillAttributeName` | | `fillColor` annotations | `@JsonProperty(required = false)`, **no** `@NotNull` | | Instance independence | no static state shared | ### Any related issues, documentation, discussions? Closes #5794. ### How was this PR tested? Pure unit-test additions; verified locally with: - `sbt \"WorkflowOperator/testOnly org.apache.texera.amber.operator.visualization.bulletChart.BulletChartStepDefinitionSpec org.apache.texera.amber.operator.visualization.hierarchychart.HierarchySectionSpec org.apache.texera.amber.operator.visualization.continuousErrorBands.BandConfigSpec\"` — 23 tests, all green - `sbt \"WorkflowOperator/Test/scalafmtCheck\"` — clean - CI to confirm ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.7 [1M context]) --- .../BulletChartStepDefinitionSpec.scala | 117 +++++++++++++++++++ .../continuousErrorBands/BandConfigSpec.scala | 130 +++++++++++++++++++++ .../hierarchychart/HierarchySectionSpec.scala | 108 +++++++++++++++++ 3 files changed, 355 insertions(+) diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/bulletChart/BulletChartStepDefinitionSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/bulletChart/BulletChartStepDefinitionSpec.scala new file mode 100644 index 0000000000..984c1fd8b0 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/bulletChart/BulletChartStepDefinitionSpec.scala @@ -0,0 +1,117 @@ +/* + * 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.bulletChart + +import com.fasterxml.jackson.annotation.{JsonCreator, JsonProperty} +import org.apache.texera.amber.util.JSONUtils.objectMapper +import org.scalatest.flatspec.AnyFlatSpec + +class BulletChartStepDefinitionSpec extends AnyFlatSpec { + + // --------------------------------------------------------------------------- + // Construction — @JsonCreator constructor accepts both fields + // --------------------------------------------------------------------------- + + "BulletChartStepDefinition" should "store both constructor arguments" in { + val d = new BulletChartStepDefinition("10", "90") + assert(d.start == "10") + assert(d.end == "90") + } + + // --------------------------------------------------------------------------- + // Mutability + // --------------------------------------------------------------------------- + + it should "allow both fields to be reassigned post-construction" in { + val d = new BulletChartStepDefinition("0", "1") + d.start = "low" + d.end = "high" + assert(d.start == "low") + assert(d.end == "high") + } + + // --------------------------------------------------------------------------- + // JSON round-trip — wire keys are `start` / `end` + // --------------------------------------------------------------------------- + + "BulletChartStepDefinition JSON round-trip" should + "serialize start and end under the canonical wire keys" in { + val d = new BulletChartStepDefinition("alpha", "omega") + val tree = objectMapper.readTree(objectMapper.writeValueAsString(d)) + assert(tree.has("start")) + assert(tree.get("start").asText() == "alpha") + assert(tree.has("end")) + assert(tree.get("end").asText() == "omega") + } + + it should "round-trip both fields cleanly" in { + val d = new BulletChartStepDefinition("33", "66") + val restored = objectMapper.readValue( + objectMapper.writeValueAsString(d), + classOf[BulletChartStepDefinition] + ) + assert(restored.start == "33") + assert(restored.end == "66") + } + + // --------------------------------------------------------------------------- + // Annotations — on the @JsonCreator constructor parameters (Scala places + // annotations on `var` ctor params on the parameter, not the synthesized + // field, unless `@(JsonProperty @meta.field)` is used). + // --------------------------------------------------------------------------- + + // Select the @JsonCreator-annotated constructor by its annotation rather than + // by reflection order (`getDeclaredConstructors.head`), so the test stays + // deterministic if an auxiliary constructor is ever added. + private val jsonCreatorCtor = + classOf[BulletChartStepDefinition].getDeclaredConstructors + .find(_.isAnnotationPresent(classOf[JsonCreator])) + .getOrElse( + fail("expected a @JsonCreator constructor on BulletChartStepDefinition") + ) + + private def ctorParamJsonProperty(paramIndex: Int): JsonProperty = { + val annotations = jsonCreatorCtor.getParameterAnnotations()(paramIndex) + annotations.collectFirst { case jp: JsonProperty => jp }.orNull + } + + "BulletChartStepDefinition ctor param[0] (start)" should "carry @JsonProperty(\"start\")" in { + val jp = ctorParamJsonProperty(0) + assert(jp != null) + assert(jp.value == "start") + } + + "BulletChartStepDefinition ctor param[1] (end)" should "carry @JsonProperty(\"end\")" in { + val jp = ctorParamJsonProperty(1) + assert(jp != null) + assert(jp.value == "end") + } + + // --------------------------------------------------------------------------- + // Instance independence + // --------------------------------------------------------------------------- + + it should "construct two independent instances (no static state shared)" in { + val a = new BulletChartStepDefinition("a-start", "a-end") + val b = new BulletChartStepDefinition("b-start", "b-end") + a.start = "mutated" + assert(b.start == "b-start") + } +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/continuousErrorBands/BandConfigSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/continuousErrorBands/BandConfigSpec.scala new file mode 100644 index 0000000000..c33289e276 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/continuousErrorBands/BandConfigSpec.scala @@ -0,0 +1,130 @@ +/* + * 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.continuousErrorBands + +import com.fasterxml.jackson.annotation.JsonProperty +import javax.validation.constraints.NotNull +import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName +import org.apache.texera.amber.operator.visualization.lineChart.LineConfig +import org.apache.texera.amber.util.JSONUtils.objectMapper +import org.scalatest.flatspec.AnyFlatSpec + +class BandConfigSpec extends AnyFlatSpec { + + // --------------------------------------------------------------------------- + // Inheritance + // --------------------------------------------------------------------------- + + "BandConfig" should "extend LineConfig (compile-time enforced)" in { + val lc: LineConfig = new BandConfig + assert(lc != null) + } + + // --------------------------------------------------------------------------- + // Defaults + // --------------------------------------------------------------------------- + + it should "default yUpper, yLower, and fillColor to the empty string" in { + val c = new BandConfig + assert(c.yUpper == "") + assert(c.yLower == "") + assert(c.fillColor == "") + } + + // --------------------------------------------------------------------------- + // Mutability + // --------------------------------------------------------------------------- + + it should "allow all three fields to be reassigned post-construction" in { + val c = new BandConfig + c.yUpper = "upper" + c.yLower = "lower" + c.fillColor = "#ff0000" + assert(c.yUpper == "upper") + assert(c.yLower == "lower") + assert(c.fillColor == "#ff0000") + } + + // --------------------------------------------------------------------------- + // JSON round-trip + // --------------------------------------------------------------------------- + + "BandConfig JSON round-trip" should "preserve yUpper, yLower, and fillColor" in { + val c = new BandConfig + c.yUpper = "u" + c.yLower = "l" + c.fillColor = "rgba(0,0,255,0.5)" + val restored = objectMapper.readValue( + objectMapper.writeValueAsString(c), + classOf[BandConfig] + ) + assert(restored.yUpper == "u") + assert(restored.yLower == "l") + assert(restored.fillColor == "rgba(0,0,255,0.5)") + } + + // --------------------------------------------------------------------------- + // Annotations — required=true on yUpper/yLower, required=false on fillColor + // --------------------------------------------------------------------------- + + "BandConfig#yUpper" should "carry @JsonProperty(required = true) + @NotNull + @AutofillAttributeName" in { + val cls = classOf[BandConfig] + val jp = cls.getDeclaredField("yUpper").getAnnotation(classOf[JsonProperty]) + assert(jp != null) + assert(jp.required) + val notNull = cls.getDeclaredField("yUpper").getAnnotation(classOf[NotNull]) + assert(notNull != null) + assert(notNull.message == "Y-Axis Upper Bound cannot be empty") + val autofill = cls.getDeclaredField("yUpper").getAnnotation(classOf[AutofillAttributeName]) + assert(autofill != null) + } + + "BandConfig#yLower" should "carry @JsonProperty(required = true) + @NotNull + @AutofillAttributeName" in { + val cls = classOf[BandConfig] + val jp = cls.getDeclaredField("yLower").getAnnotation(classOf[JsonProperty]) + assert(jp != null) + assert(jp.required) + val notNull = cls.getDeclaredField("yLower").getAnnotation(classOf[NotNull]) + assert(notNull != null) + assert(notNull.message == "Y-Axis Lower Bound cannot be empty") + val autofill = cls.getDeclaredField("yLower").getAnnotation(classOf[AutofillAttributeName]) + assert(autofill != null) + } + + "BandConfig#fillColor" should "carry @JsonProperty(required = false) and no @NotNull" in { + val cls = classOf[BandConfig] + val jp = cls.getDeclaredField("fillColor").getAnnotation(classOf[JsonProperty]) + assert(jp != null) + assert(!jp.required) + val notNull = cls.getDeclaredField("fillColor").getAnnotation(classOf[NotNull]) + assert(notNull == null) + } + + // --------------------------------------------------------------------------- + // Instance independence + // --------------------------------------------------------------------------- + + it should "construct two independent instances (no static state shared)" in { + val a = new BandConfig + val b = new BandConfig + a.yUpper = "mutated" + assert(b.yUpper == "") + } +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/hierarchychart/HierarchySectionSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/hierarchychart/HierarchySectionSpec.scala new file mode 100644 index 0000000000..cb40df93f8 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/hierarchychart/HierarchySectionSpec.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.hierarchychart + +import com.fasterxml.jackson.annotation.JsonProperty +import javax.validation.constraints.NotNull +import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName +import org.apache.texera.amber.util.JSONUtils.objectMapper +import org.scalatest.flatspec.AnyFlatSpec + +class HierarchySectionSpec extends AnyFlatSpec { + + // --------------------------------------------------------------------------- + // Defaults + // --------------------------------------------------------------------------- + + "HierarchySection" should "default attributeName to the empty string" in { + val s = new HierarchySection + assert(s.attributeName == "") + } + + // --------------------------------------------------------------------------- + // Mutability + // --------------------------------------------------------------------------- + + it should "allow attributeName to be reassigned post-construction" in { + val s = new HierarchySection + s.attributeName = "country" + assert(s.attributeName == "country") + } + + // --------------------------------------------------------------------------- + // JSON round-trip + // --------------------------------------------------------------------------- + + "HierarchySection JSON round-trip" should "preserve attributeName" in { + val s = new HierarchySection + s.attributeName = "region" + val restored = objectMapper.readValue( + objectMapper.writeValueAsString(s), + classOf[HierarchySection] + ) + assert(restored.attributeName == "region") + } + + it should "round-trip default (empty) values cleanly" in { + val restored = objectMapper.readValue( + objectMapper.writeValueAsString(new HierarchySection), + classOf[HierarchySection] + ) + assert(restored.attributeName == "") + } + + // --------------------------------------------------------------------------- + // Annotations — required=true + @AutofillAttributeName + @NotNull + // --------------------------------------------------------------------------- + + "HierarchySection#attributeName" should "carry @JsonProperty(required = true)" in { + val jp = classOf[HierarchySection] + .getDeclaredField("attributeName") + .getAnnotation(classOf[JsonProperty]) + assert(jp != null) + assert(jp.required) + } + + it should "carry @AutofillAttributeName" in { + val ann = classOf[HierarchySection] + .getDeclaredField("attributeName") + .getAnnotation(classOf[AutofillAttributeName]) + assert(ann != null) + } + + it should "carry @NotNull with the canonical error message" in { + val ann = classOf[HierarchySection] + .getDeclaredField("attributeName") + .getAnnotation(classOf[NotNull]) + assert(ann != null) + assert(ann.message == "Attribute Name cannot be empty") + } + + // --------------------------------------------------------------------------- + // Instance independence + // --------------------------------------------------------------------------- + + it should "construct two independent instances (no static state shared)" in { + val a = new HierarchySection + val b = new HierarchySection + a.attributeName = "first" + assert(b.attributeName == "") + } +}
