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-5897-9b50ddb134f25127de187b3cc0dfd4b0a26a6730
in repository https://gitbox.apache.org/repos/asf/texera.git

commit a0154d57dfa4bbcecf9582f0d01e1f58777b9f9e
Author: Xinyuan Lin <[email protected]>
AuthorDate: Tue Jun 23 14:16:02 2026 -0700

    test(workflow-operator): add unit test coverage for visualization plot 
descriptors (QuiverPlot, RadarPlot) (#5897)
    
    ### What changes were proposed in this PR?
    
    Pin behavior of two previously-untested visualization plot descriptors
    in `common/workflow-operator`, plus a small null-guard fix surfaced by
    the tests.
    
    | Spec | Source class | Tests |
    | --- | --- | --- |
    | `QuiverPlotOpDescSpec` | `QuiverPlotOpDesc` | 5 |
    | `RadarPlotOpDescSpec` | `RadarPlotOpDesc` | 6 |
    
    **Production fix (`RadarPlotOpDesc`)**
    
    `generateRadarPlotCode` dereferenced `linePattern.getLinePattern` with
    no guard, so `generatePythonCode()` threw a bare `NullPointerException`
    when `linePattern` was unset. Added `require(linePattern != null, "Line
    pattern must be specified")` for a clear error; the spec asserts
    `IllegalArgumentException` instead of pinning the NPE (per @mengw15's
    review).
    
    **Behavior pinned**
    
    | Surface | Contract |
    | --- | --- |
    | `operatorInfo` | exact name + description; Scientific group;
    1-in/1-out |
    | field defaults | Quiver `x/y/u/v` empty; Radar booleans default
    `true`, optional columns empty, `selectedAttributes`/`linePattern` null
    |
    | `getOutputSchemas` | single `html-content` STRING column keyed by the
    declared output port |
    | `generatePythonCode` | emits the Plotly figure (`ff.create_quiver(` /
    `go.Scatterpolar`) and carries the configured columns; rejects a missing
    `linePattern` with a clear error |
    | Round-trip | config fields preserved through the polymorphic
    `LogicalOp` base |
    
    Note: column fields are `EncodableString`, so in the emitted (encoded)
    code they appear as `self.decode_python_template('<base64>')`; the
    `carries` helper asserts on the base64 form only, so a raw column name
    appearing for unrelated reasons can't mask a missing splice.
    
    ### Any related issues, documentation, discussions?
    
    Part of the ongoing `workflow-operator` unit-test coverage effort
    (follow-up to #5843, #5844).
    
    ### How was this PR tested?
    
    - `sbt "WorkflowOperator/testOnly *QuiverPlotOpDescSpec
    *RadarPlotOpDescSpec"` — 11 tests, all green
    - `sbt "WorkflowOperator/Test/scalafmtCheck"` and `sbt
    "WorkflowOperator/scalafixAll --check"` — clean
    - CI to confirm
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8 [1M context])
---
 .../visualization/radarPlot/RadarPlotOpDesc.scala  |   1 +
 .../quiverPlot/QuiverPlotOpDescSpec.scala          |  99 ++++++++++++++++++
 .../radarPlot/RadarPlotOpDescSpec.scala            | 114 +++++++++++++++++++++
 3 files changed, 214 insertions(+)

diff --git 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/radarPlot/RadarPlotOpDesc.scala
 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/radarPlot/RadarPlotOpDesc.scala
index 5bdfc24b29..c21c6f3b31 100644
--- 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/radarPlot/RadarPlotOpDesc.scala
+++ 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/visualization/radarPlot/RadarPlotOpDesc.scala
@@ -122,6 +122,7 @@ class RadarPlotOpDesc extends PythonOperatorDescriptor {
     }
 
   def generateRadarPlotCode(): PythonTemplateBuilder = {
+    require(linePattern != null, "Line pattern must be specified")
     val attributes = Option(selectedAttributes).getOrElse(Nil)
     val attrList = attributes.map(attr => pyb"$attr").mkString(", ")
     val traceNameCol = optionalColumnExpr(traceNameAttribute)
diff --git 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/quiverPlot/QuiverPlotOpDescSpec.scala
 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/quiverPlot/QuiverPlotOpDescSpec.scala
new file mode 100644
index 0000000000..4331139f78
--- /dev/null
+++ 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/quiverPlot/QuiverPlotOpDescSpec.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.quiverPlot
+
+import org.apache.texera.amber.core.tuple.{AttributeType, Schema}
+import org.apache.texera.amber.operator.LogicalOp
+import org.apache.texera.amber.operator.metadata.OperatorGroupConstants
+import org.apache.texera.amber.util.JSONUtils.objectMapper
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import java.nio.charset.StandardCharsets
+import java.util.Base64
+
+class QuiverPlotOpDescSpec extends AnyFlatSpec with Matchers {
+
+  private def b64(s: String): String =
+    Base64.getEncoder.encodeToString(s.getBytes(StandardCharsets.UTF_8))
+
+  // EncodableString columns are always base64-wrapped in .encode mode
+  // (self.decode_python_template('<base64>')), so assert on the base64 form 
only rather than
+  // the raw column name, which could appear in the generated Python for 
unrelated reasons.
+  private def carries(output: String, name: String): Boolean =
+    output.contains(b64(name))
+
+  "QuiverPlotOpDesc.operatorInfo" should
+    "advertise the name and Scientific visualization group with a 1-in/1-out 
shape" in {
+    val info = (new QuiverPlotOpDesc).operatorInfo
+    info.userFriendlyName shouldBe "Quiver Plot"
+    info.operatorDescription shouldBe "Visualize vector data in a Quiver Plot"
+    info.operatorGroupName shouldBe 
OperatorGroupConstants.VISUALIZATION_SCIENTIFIC_GROUP
+    info.inputPorts should have length 1
+    info.outputPorts should have length 1
+  }
+
+  "QuiverPlotOpDesc" should "default the x/y/u/v columns to empty strings" in {
+    val d = new QuiverPlotOpDesc
+    d.x shouldBe ""
+    d.y shouldBe ""
+    d.u shouldBe ""
+    d.v shouldBe ""
+  }
+
+  "QuiverPlotOpDesc.getOutputSchemas" should
+    "produce a single html-content STRING column keyed by the declared output 
port" in {
+    val op = new QuiverPlotOpDesc
+    op.getOutputSchemas(Map.empty) shouldBe Map(
+      op.operatorInfo.outputPorts.head.id -> Schema().add("html-content", 
AttributeType.STRING)
+    )
+  }
+
+  "QuiverPlotOpDesc.generatePythonCode" should
+    "emit a Plotly figure-factory quiver carrying the configured columns" in {
+    val d = new QuiverPlotOpDesc
+    d.x = "vx"
+    d.y = "vy"
+    d.u = "vu"
+    d.v = "vv"
+    val code = d.generatePythonCode()
+    code should include("class ProcessTableOperator(UDFTableOperator)")
+    code should include("ff.create_quiver(")
+    carries(code, "vx") shouldBe true
+    carries(code, "vy") shouldBe true
+    carries(code, "vu") shouldBe true
+    carries(code, "vv") shouldBe true
+  }
+
+  "QuiverPlotOpDesc" should "round-trip its columns through the polymorphic 
base" in {
+    val d = new QuiverPlotOpDesc
+    d.x = "vx"
+    d.y = "vy"
+    d.u = "vu"
+    d.v = "vv"
+    val restored = objectMapper.readValue(objectMapper.writeValueAsString(d), 
classOf[LogicalOp])
+    restored shouldBe a[QuiverPlotOpDesc]
+    val q = restored.asInstanceOf[QuiverPlotOpDesc]
+    q.x shouldBe "vx"
+    q.y shouldBe "vy"
+    q.u shouldBe "vu"
+    q.v shouldBe "vv"
+  }
+}
diff --git 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/radarPlot/RadarPlotOpDescSpec.scala
 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/radarPlot/RadarPlotOpDescSpec.scala
new file mode 100644
index 0000000000..0c96559343
--- /dev/null
+++ 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/radarPlot/RadarPlotOpDescSpec.scala
@@ -0,0 +1,114 @@
+/*
+ * 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.radarPlot
+
+import org.apache.texera.amber.core.tuple.{AttributeType, Schema}
+import org.apache.texera.amber.operator.LogicalOp
+import org.apache.texera.amber.operator.metadata.OperatorGroupConstants
+import org.apache.texera.amber.util.JSONUtils.objectMapper
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import java.nio.charset.StandardCharsets
+import java.util.Base64
+
+class RadarPlotOpDescSpec extends AnyFlatSpec with Matchers {
+
+  private def b64(s: String): String =
+    Base64.getEncoder.encodeToString(s.getBytes(StandardCharsets.UTF_8))
+
+  // EncodableString axes are always base64-wrapped in .encode mode
+  // (self.decode_python_template('<base64>')), so assert on the base64 form 
only rather than
+  // the raw column name, which could appear in the generated Python for 
unrelated reasons.
+  private def carries(output: String, name: String): Boolean =
+    output.contains(b64(name))
+
+  "RadarPlotOpDesc.operatorInfo" should
+    "advertise the name and Scientific visualization group with a 1-in/1-out 
shape" in {
+    val info = (new RadarPlotOpDesc).operatorInfo
+    info.userFriendlyName shouldBe "Radar Plot"
+    info.operatorDescription shouldBe "View the result in a radar plot."
+    info.operatorGroupName shouldBe 
OperatorGroupConstants.VISUALIZATION_SCIENTIFIC_GROUP
+    info.inputPorts should have length 1
+    info.outputPorts should have length 1
+  }
+
+  "RadarPlotOpDesc" should "default the boolean flags to true and the optional 
columns to empty" in {
+    val d = new RadarPlotOpDesc
+    d.maxNormalize shouldBe true
+    d.fillTrace shouldBe true
+    d.showMarkers shouldBe true
+    d.showLegend shouldBe true
+    d.traceNameAttribute shouldBe ""
+    d.traceColorAttribute shouldBe ""
+    d.selectedAttributes shouldBe null
+    d.linePattern shouldBe null
+  }
+
+  "RadarPlotOpDesc.getOutputSchemas" should
+    "produce a single html-content STRING column keyed by the declared output 
port" in {
+    val op = new RadarPlotOpDesc
+    op.getOutputSchemas(Map.empty) shouldBe Map(
+      op.operatorInfo.outputPorts.head.id -> Schema().add("html-content", 
AttributeType.STRING)
+    )
+  }
+
+  "RadarPlotOpDesc.generatePythonCode" should
+    "reject a missing line pattern with a clear error" in {
+    val d = new RadarPlotOpDesc
+    d.selectedAttributes = List("m1", "m2")
+    val ex = intercept[IllegalArgumentException](d.generatePythonCode())
+    ex.getMessage should include("Line pattern must be specified")
+  }
+
+  it should "emit a Plotly Scatterpolar figure carrying the configured axes" 
in {
+    val d = new RadarPlotOpDesc
+    d.selectedAttributes = List("m1", "m2")
+    d.linePattern = RadarPlotLinePattern.DASH
+    val code = d.generatePythonCode()
+    code should include("class ProcessTableOperator(UDFTableOperator)")
+    code should include("go.Scatterpolar")
+    carries(code, "m1") shouldBe true
+    carries(code, "m2") shouldBe true
+  }
+
+  "RadarPlotOpDesc" should "round-trip its config fields through the 
polymorphic base" in {
+    val d = new RadarPlotOpDesc
+    d.selectedAttributes = List("m1", "m2")
+    d.traceNameAttribute = "name"
+    d.traceColorAttribute = "color"
+    d.linePattern = RadarPlotLinePattern.DASH
+    d.maxNormalize = false
+    d.fillTrace = false
+    d.showMarkers = false
+    d.showLegend = false
+    val restored = objectMapper.readValue(objectMapper.writeValueAsString(d), 
classOf[LogicalOp])
+    restored shouldBe a[RadarPlotOpDesc]
+    val r = restored.asInstanceOf[RadarPlotOpDesc]
+    r.selectedAttributes shouldBe List("m1", "m2")
+    r.traceNameAttribute shouldBe "name"
+    r.traceColorAttribute shouldBe "color"
+    r.linePattern shouldBe RadarPlotLinePattern.DASH
+    r.maxNormalize shouldBe false
+    r.fillTrace shouldBe false
+    r.showMarkers shouldBe false
+    r.showLegend shouldBe false
+  }
+}

Reply via email to