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)")
+ }
}