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-8803d084cd68fc98c06c782aa7a56450e7ddc9ee in repository https://gitbox.apache.org/repos/asf/texera.git
commit aec31f32bfc75b025190d84c2554e819273244b1 Author: Xinyuan Lin <[email protected]> AuthorDate: Tue Jun 23 04:27:58 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 + } +}
