This is an automated email from the ASF dual-hosted git repository.

aglinxinyuan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new f0e17c27d7 test(amber): add unit tests for visualization OpDescs 
(HeatMap, BarChart, LineChart, PieChart) (#4812)
f0e17c27d7 is described below

commit f0e17c27d72f1b968ea3676a17bd3c1510f1dd53
Author: Yicong Huang <[email protected]>
AuthorDate: Sun May 3 11:16:00 2026 -0700

    test(amber): add unit tests for visualization OpDescs (HeatMap, BarChart, 
LineChart, PieChart) (#4812)
    
    ### What changes were proposed in this PR?
    
    Adds scalatest coverage for four more visualization operator
    descriptors. New specs for `HeatMapOpDesc` and `LineChartOpDesc`;
    existing thin `BarChart` and `PieChart` specs are extended with the same
    shape used in the previous bundle (#4809).
    
    Each spec covers `operatorInfo` (name + group + outputPort count),
    `getOutputSchemas` (single-port `html-content` STRING),
    `generatePythonCode` (operator class + plotly imports +
    `decode_python_template` runtime decode-site count for each
    `EncodableString`), and the missing-required-field behavior — which
    differs by OpDesc:
    
    - `HeatMap` asserts on `x`, `y`, `value` (all three).
    - `BarChart` asserts on `value` and `fields` with explicit messages
    ("Value column cannot be empty" / "Fields cannot be empty").
    - `PieChart` asserts only on `value` — `name` has no guard, so an
    empty-name configuration still renders.
    - `LineChart` has no asserts but its `lines` field defaults to `null`;
    calling `generatePythonCode` on a default-constructed instance raises
    `NullPointerException` (see Bug filed below).
    
    ### Any related issues, documentation, discussions?
    
    Closes #4810.
    
    Bug filed separately: `LineChartOpDesc.generatePythonCode` throws
    `NullPointerException` when `lines` is left at its `null` default,
    instead of raising `AssertionError` like the other visualizers or
    rendering an empty chart.
    
    ### How was this PR tested?
    
    ```
    sbt scalafmtCheckAll
    sbt "WorkflowOperator/testOnly 
org.apache.texera.amber.operator.visualization.heatMap.HeatMapOpDescSpec 
org.apache.texera.amber.operator.visualization.barChart.BarChartOpDescSpec 
org.apache.texera.amber.operator.visualization.lineChart.LineChartOpDescSpec 
org.apache.texera.amber.operator.visualization.pieChart.PieChartOpDescSpec"
    ```
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (claude-opus-4-7)
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
---
 .../barChart/BarChartOpDescSpec.scala              |  62 ++++++++++-
 .../visualization/heatMap/HeatMapOpDescSpec.scala  |  75 +++++++++++++
 .../lineChart/LineChartOpDescSpec.scala            | 117 +++++++++++++++++++++
 .../pieChart/PieChartOpDescSpec.scala              |  70 +++++++++++-
 4 files changed, 322 insertions(+), 2 deletions(-)

diff --git 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/barChart/BarChartOpDescSpec.scala
 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/barChart/BarChartOpDescSpec.scala
index f2b4c8b8b8..3081b49d77 100644
--- 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/barChart/BarChartOpDescSpec.scala
+++ 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/barChart/BarChartOpDescSpec.scala
@@ -19,10 +19,16 @@
 
 package org.apache.texera.amber.operator.visualization.barChart
 
+import org.apache.texera.amber.core.tuple.AttributeType
+import org.apache.texera.amber.operator.metadata.OperatorGroupConstants
 import org.scalatest.BeforeAndAfter
 import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
 
-class BarChartOpDescSpec extends AnyFlatSpec with BeforeAndAfter {
+import java.nio.charset.StandardCharsets
+import java.util.Base64
+
+class BarChartOpDescSpec extends AnyFlatSpec with BeforeAndAfter with Matchers 
{
 
   var opDesc: BarChartOpDesc = _
 
@@ -37,6 +43,9 @@ class BarChartOpDescSpec extends AnyFlatSpec with 
BeforeAndAfter {
   }
 
   it should "list titles of axes in the python code" in {
+    // The plain (un-encoded) template body still carries the literal column
+    // names; only the encoded `generatePythonCode` output runs them through
+    // base64 + decode_python_template wrapping.
     opDesc.fields = "geo.state_name"
     opDesc.value = "person.count"
     val temp = opDesc.manipulateTable().plain
@@ -50,4 +59,55 @@ class BarChartOpDescSpec extends AnyFlatSpec with 
BeforeAndAfter {
     }
   }
 
+  "BarChartOpDesc.operatorInfo" should "advertise the user-friendly name and 
Basic group" in {
+    val info = opDesc.operatorInfo
+    info.userFriendlyName shouldBe "Bar Chart"
+    info.operatorGroupName shouldBe 
OperatorGroupConstants.VISUALIZATION_BASIC_GROUP
+    info.operatorDescription should include("Bar Chart")
+  }
+
+  it should "expose exactly one output port wired through forVisualization" in 
{
+    opDesc.operatorInfo.outputPorts should have length 1
+  }
+
+  "BarChartOpDesc.getOutputSchemas" should "return a single-port schema with 
an html-content STRING column" in {
+    opDesc.value = "v"
+    opDesc.fields = "f"
+    val schemas = opDesc.getOutputSchemas(Map.empty)
+    schemas should have size 1
+    val (portId, schema) = schemas.head
+    portId shouldBe opDesc.operatorInfo.outputPorts.head.id
+    schema.getAttributes should have length 1
+    schema.getAttributes.head.getName shouldBe "html-content"
+    schema.getAttributes.head.getType shouldBe AttributeType.STRING
+  }
+
+  "BarChartOpDesc.generatePythonCode" should "render a UDFTableOperator source 
with runtime decode sites for value AND fields" in {
+    // Use distinct sentinels and assert on the exact base64-wrapped decode
+    // expressions so the test actually proves both `value` *and* `fields`
+    // were wrapped through wrapWithPythonDecoderExpr. A generic
+    // `decodeOccurrences >= 2` could be satisfied by `value` alone since
+    // both fields appear in multiple template positions.
+    opDesc.value = "VAL_SENT"
+    opDesc.fields = "FIELDS_SENT"
+    val code = opDesc.generatePythonCode()
+    code should include("class ProcessTableOperator(UDFTableOperator)")
+    code should include("plotly.express")
+
+    def b64(s: String): String =
+      Base64.getEncoder.encodeToString(s.getBytes(StandardCharsets.UTF_8))
+
+    code should include(s"self.decode_python_template('${b64("VAL_SENT")}')")
+    code should 
include(s"self.decode_python_template('${b64("FIELDS_SENT")}')")
+    code should not include "VAL_SENT"
+    code should not include "FIELDS_SENT"
+  }
+
+  it should "fail-fast when value or fields is unset (asserts inside 
manipulateTable)" in {
+    // manipulateTable asserts nonEmpty on value AND fields with explicit
+    // messages ("Value column cannot be empty" / "Fields cannot be empty").
+    val ex = intercept[AssertionError](opDesc.generatePythonCode())
+    ex.getMessage should (include("Value column") or include("Fields"))
+  }
+
 }
diff --git 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/heatMap/HeatMapOpDescSpec.scala
 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/heatMap/HeatMapOpDescSpec.scala
new file mode 100644
index 0000000000..a46dffdde7
--- /dev/null
+++ 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/heatMap/HeatMapOpDescSpec.scala
@@ -0,0 +1,75 @@
+/*
+ * 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.heatMap
+
+import org.apache.texera.amber.core.tuple.AttributeType
+import org.apache.texera.amber.operator.metadata.OperatorGroupConstants
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class HeatMapOpDescSpec extends AnyFlatSpec with Matchers {
+
+  private def configured: HeatMapOpDesc = {
+    val op = new HeatMapOpDesc
+    op.x = "ax"
+    op.y = "ay"
+    op.value = "v"
+    op
+  }
+
+  "HeatMapOpDesc.operatorInfo" should "advertise the user-friendly name and 
Scientific group" in {
+    val info = (new HeatMapOpDesc).operatorInfo
+    info.userFriendlyName shouldBe "Heatmap"
+    info.operatorGroupName shouldBe 
OperatorGroupConstants.VISUALIZATION_SCIENTIFIC_GROUP
+    info.operatorDescription should include("HeatMap")
+  }
+
+  it should "expose exactly one output port wired through forVisualization" in 
{
+    (new HeatMapOpDesc).operatorInfo.outputPorts should have length 1
+  }
+
+  "HeatMapOpDesc.getOutputSchemas" should "return a single-port schema with an 
html-content STRING column" in {
+    val op = configured
+    val schemas = op.getOutputSchemas(Map.empty)
+    schemas should have size 1
+    val (portId, schema) = schemas.head
+    portId shouldBe op.operatorInfo.outputPorts.head.id
+    schema.getAttributes should have length 1
+    schema.getAttributes.head.getName shouldBe "html-content"
+    schema.getAttributes.head.getType shouldBe AttributeType.STRING
+  }
+
+  "HeatMapOpDesc.generatePythonCode" should "render a UDFTableOperator source 
with three runtime decode sites for x/y/value" in {
+    // EncodableString fields are wrapped in `self.decode_python_template(...)`
+    // calls by the pyb macro; pin a structural count instead of literal names.
+    val code = configured.generatePythonCode()
+    code should include("class ProcessTableOperator(UDFTableOperator)")
+    code should include("plotly.graph_objects")
+    val decodeOccurrences = "decode_python_template".r.findAllIn(code).length
+    decodeOccurrences should be >= 3
+  }
+
+  it should "fail-fast when any required field is unset (asserts inside 
createHeatMap)" in {
+    // createHeatMap asserts nonEmpty on x, y, AND value. Empty defaults
+    // ("") hit the assert path and surface as AssertionError.
+    val op = new HeatMapOpDesc
+    assertThrows[AssertionError](op.generatePythonCode())
+  }
+}
diff --git 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/lineChart/LineChartOpDescSpec.scala
 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/lineChart/LineChartOpDescSpec.scala
new file mode 100644
index 0000000000..2608437098
--- /dev/null
+++ 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/lineChart/LineChartOpDescSpec.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.lineChart
+
+import org.apache.texera.amber.core.tuple.AttributeType
+import org.apache.texera.amber.operator.metadata.OperatorGroupConstants
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import java.nio.charset.StandardCharsets
+import java.util
+import java.util.Base64
+
+class LineChartOpDescSpec extends AnyFlatSpec with Matchers {
+
+  private def lineConfig(x: String, y: String): LineConfig = {
+    val c = new LineConfig
+    c.xValue = x
+    c.yValue = y
+    c
+  }
+
+  private def configured: LineChartOpDesc = {
+    val op = new LineChartOpDesc
+    op.xLabel = "x_col"
+    op.yLabel = "y_col"
+    val ls = new util.ArrayList[LineConfig]()
+    ls.add(lineConfig("x_col", "y_col"))
+    op.lines = ls
+    op
+  }
+
+  "LineChartOpDesc.operatorInfo" should "advertise the user-friendly name and 
Basic group" in {
+    val info = (new LineChartOpDesc).operatorInfo
+    info.userFriendlyName shouldBe "Line Chart"
+    info.operatorGroupName shouldBe 
OperatorGroupConstants.VISUALIZATION_BASIC_GROUP
+    info.operatorDescription should include("line chart")
+  }
+
+  it should "expose exactly one output port wired through forVisualization" in 
{
+    (new LineChartOpDesc).operatorInfo.outputPorts should have length 1
+  }
+
+  "LineChartOpDesc.getOutputSchemas" should "return a single-port schema with 
an html-content STRING column" in {
+    val op = configured
+    val schemas = op.getOutputSchemas(Map.empty)
+    schemas should have size 1
+    val (portId, schema) = schemas.head
+    portId shouldBe op.operatorInfo.outputPorts.head.id
+    schema.getAttributes should have length 1
+    schema.getAttributes.head.getName shouldBe "html-content"
+    schema.getAttributes.head.getType shouldBe AttributeType.STRING
+  }
+
+  "LineChartOpDesc.generatePythonCode" should "render Python source with 
runtime decode sites for both labels" in {
+    // Use distinct sentinels for the two LABELS *and* the LineConfig values
+    // so the spec actually exercises both label fields. Asserting on the
+    // exact base64 payloads proves each field was wrapped through
+    // wrapWithPythonDecoderExpr individually — `decodeOccurrences >= 2`
+    // could otherwise be satisfied by xValue/yValue alone.
+    val op = new LineChartOpDesc
+    op.xLabel = "X_LBL_SENT"
+    op.yLabel = "Y_LBL_SENT"
+    val ls = new util.ArrayList[LineConfig]()
+    ls.add(lineConfig("X_VAL_SENT", "Y_VAL_SENT"))
+    op.lines = ls
+
+    val code = op.generatePythonCode()
+    code should include("plotly")
+
+    def b64(s: String): String =
+      Base64.getEncoder.encodeToString(s.getBytes(StandardCharsets.UTF_8))
+
+    code should include(s"self.decode_python_template('${b64("X_LBL_SENT")}')")
+    code should include(s"self.decode_python_template('${b64("Y_LBL_SENT")}')")
+    // Raw sentinels must be absent from the encoded output — their presence
+    // would mean the field was never run through the decoder wrapper.
+    code should not include "X_LBL_SENT"
+    code should not include "Y_LBL_SENT"
+  }
+
+  it should "raise NullPointerException when lines is left at its null 
default" in {
+    // Pin: `var lines: util.List[LineConfig] = _` defaults to null, and
+    // `createPlotlyFigure` calls `lines.asScala.map(...)` without a null
+    // check. Calling `generatePythonCode` on a default-constructed LineChart
+    // therefore throws NPE rather than rendering an empty chart or raising
+    // an AssertionError. Documenting so a future fix that null-guards lines
+    // breaks this spec deliberately.
+    val op = new LineChartOpDesc
+    assertThrows[NullPointerException](op.generatePythonCode())
+  }
+
+  it should "render code with an empty lines list (no NPE, no assertion)" in {
+    val op = configured
+    op.lines = new util.ArrayList[LineConfig]()
+    val code = op.generatePythonCode()
+    code should include("plotly")
+    code should include("fig = go.Figure()")
+  }
+}
diff --git 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/pieChart/PieChartOpDescSpec.scala
 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/pieChart/PieChartOpDescSpec.scala
index 6c989a0a0e..a166ac0a27 100644
--- 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/pieChart/PieChartOpDescSpec.scala
+++ 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/pieChart/PieChartOpDescSpec.scala
@@ -19,10 +19,16 @@
 
 package org.apache.texera.amber.operator.visualization.pieChart
 
+import org.apache.texera.amber.core.tuple.AttributeType
+import org.apache.texera.amber.operator.metadata.OperatorGroupConstants
 import org.scalatest.BeforeAndAfter
 import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
 
-class PieChartOpDescSpec extends AnyFlatSpec with BeforeAndAfter {
+import java.nio.charset.StandardCharsets
+import java.util.Base64
+
+class PieChartOpDescSpec extends AnyFlatSpec with BeforeAndAfter with Matchers 
{
   var opDesc: PieChartOpDesc = _
   before {
     opDesc = new PieChartOpDesc()
@@ -33,4 +39,66 @@ class PieChartOpDescSpec extends AnyFlatSpec with 
BeforeAndAfter {
       opDesc.manipulateTable()
     }
   }
+
+  "PieChartOpDesc.operatorInfo" should "advertise the user-friendly name and 
Basic group" in {
+    val info = opDesc.operatorInfo
+    info.userFriendlyName shouldBe "Pie Chart"
+    info.operatorGroupName shouldBe 
OperatorGroupConstants.VISUALIZATION_BASIC_GROUP
+    info.operatorDescription should include("Pie Chart")
+  }
+
+  it should "expose exactly one output port wired through forVisualization" in 
{
+    opDesc.operatorInfo.outputPorts should have length 1
+  }
+
+  "PieChartOpDesc.getOutputSchemas" should "return a single-port schema with 
an html-content STRING column" in {
+    opDesc.value = "amount"
+    opDesc.name = "label"
+    val schemas = opDesc.getOutputSchemas(Map.empty)
+    schemas should have size 1
+    val (portId, schema) = schemas.head
+    portId shouldBe opDesc.operatorInfo.outputPorts.head.id
+    schema.getAttributes should have length 1
+    schema.getAttributes.head.getName shouldBe "html-content"
+    schema.getAttributes.head.getType shouldBe AttributeType.STRING
+  }
+
+  "PieChartOpDesc.generatePythonCode" should "render Python source with 
runtime decode sites for value and name" in {
+    // Use distinct sentinels and assert on the exact base64-wrapped decode
+    // expressions so a regression that leaves `name` as a raw literal
+    // cannot satisfy a generic `decodeOccurrences >= 2` (since `value` is
+    // referenced multiple times in the generated template anyway).
+    opDesc.value = "VAL_SENT"
+    opDesc.name = "NAME_SENT"
+    val code = opDesc.generatePythonCode()
+    code should include("class ProcessTableOperator(UDFTableOperator)")
+    code should include("plotly.express")
+
+    def b64(s: String): String =
+      Base64.getEncoder.encodeToString(s.getBytes(StandardCharsets.UTF_8))
+
+    code should include(s"self.decode_python_template('${b64("VAL_SENT")}')")
+    code should include(s"self.decode_python_template('${b64("NAME_SENT")}')")
+    code should not include "VAL_SENT"
+    code should not include "NAME_SENT"
+  }
+
+  it should "fail-fast when value is unset even if name is set (only `value` 
is asserted)" in {
+    // Pin: PieChartOpDesc.manipulateTable and createPlotlyFigure both assert
+    // nonEmpty on `value`, but neither asserts on `name`. Setting just `name`
+    // is therefore not enough to satisfy the guards.
+    opDesc.name = "label"
+    assertThrows[AssertionError](opDesc.generatePythonCode())
+  }
+
+  it should "render successfully when only name is empty (asymmetric guard, 
current behavior)" in {
+    // Pin: name has no assert guard. With value set and name empty, the
+    // generated Python still renders — only the runtime call site receives
+    // an empty decode. This asymmetry between value (asserted) and name
+    // (not asserted) is documented here.
+    opDesc.value = "amount"
+    opDesc.name = ""
+    val code = opDesc.generatePythonCode()
+    code should include("class ProcessTableOperator(UDFTableOperator)")
+  }
 }

Reply via email to