This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/texera.git
commit 6d31f46ecfe894fb04f783972c8f9566afb5a16b Author: Xinyuan Lin <[email protected]> AuthorDate: Sat Jun 20 20:58:52 2026 -0700 test(workflow-operator): add unit test coverage for visualization operator descriptors (#5827) ### What changes were proposed in this PR? Pin behavior of three previously-untested visualization descriptors in `common/workflow-operator/`. No production-code changes. | Spec | Source class | Tests | | --- | --- | --- | | `HtmlVizOpDescSpec` | `HtmlVizOpDesc` | 4 | | `CandlestickChartOpDescSpec` | `CandlestickChartOpDesc` | 5 | | `Histogram2DOpDescSpec` | `Histogram2DOpDesc` | 6 | All three spec files follow the `<srcClassName>Spec.scala` one-to-one convention. **Behavior pinned** | Surface | Contract | | --- | --- | | `operatorInfo` | exact name + visualization group (`MEDIA` / `FINANCIAL` / `STATISTICAL`); one input / one output | | Output schema | all three emit a single `html-content` STRING column (`HtmlViz` via `getExternalOutputSchemas`; charts via `getOutputSchemas`) | | `getPhysicalOp` wiring (`HtmlViz`) | `OpExecWithClassName("…htmlviz.HtmlVizOpExec")`; port **identities** carried forward | | Field defaults | `Candlestick` OHLC columns default `""`; `Histogram2D` `xBins`/`yBins == 10`, `normalize == DENSITY` | | `generatePythonCode` | `Candlestick` emits a Plotly `go.Candlestick(` figure; `Histogram2D` emits `px.density_heatmap(` and **rejects a non-positive bin count** (`AssertionError`) | | Round-trip | all config fields preserved through the polymorphic base | The specs pin the stable contract (operatorInfo + output schema + codegen guards) rather than the full Plotly template, and never assert on interpolated `EncodableString` values (which are decoded at runtime, not embedded raw). ### Any related issues, documentation, discussions? Closes #5824. ### How was this PR tested? Pure unit-test additions; verified locally with: - `sbt "WorkflowOperator/testOnly org.apache.texera.amber.operator.visualization.htmlviz.HtmlVizOpDescSpec org.apache.texera.amber.operator.visualization.candlestickChart.CandlestickChartOpDescSpec org.apache.texera.amber.operator.visualization.histogram2d.Histogram2DOpDescSpec"` — 15 tests, all green - `sbt "WorkflowOperator/Test/scalafmtCheck"` and `sbt "WorkflowOperator/Test/scalafix --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]) --- .../CandlestickChartOpDescSpec.scala | 85 +++++++++++++++++++ .../histogram2d/Histogram2DOpDescSpec.scala | 93 +++++++++++++++++++++ .../visualization/htmlviz/HtmlVizOpDescSpec.scala | 94 ++++++++++++++++++++++ 3 files changed, 272 insertions(+) diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/candlestickChart/CandlestickChartOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/candlestickChart/CandlestickChartOpDescSpec.scala new file mode 100644 index 0000000000..cdaabd3bc3 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/candlestickChart/CandlestickChartOpDescSpec.scala @@ -0,0 +1,85 @@ +/* + * 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.candlestickChart + +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 + +class CandlestickChartOpDescSpec extends AnyFlatSpec with Matchers { + + private def configured(): CandlestickChartOpDesc = { + val d = new CandlestickChartOpDesc + d.date = "day" + d.open = "o" + d.high = "h" + d.low = "l" + d.close = "c" + d + } + + "CandlestickChartOpDesc.operatorInfo" should + "advertise the name and Financial visualization group" in { + val info = (new CandlestickChartOpDesc).operatorInfo + info.userFriendlyName shouldBe "Candlestick Chart" + info.operatorGroupName shouldBe OperatorGroupConstants.VISUALIZATION_FINANCIAL_GROUP + info.inputPorts should have length 1 + info.outputPorts should have length 1 + } + + "CandlestickChartOpDesc" should "default all OHLC column fields to the empty string" in { + val d = new CandlestickChartOpDesc + d.date shouldBe "" + d.open shouldBe "" + d.high shouldBe "" + d.low shouldBe "" + d.close shouldBe "" + } + + "CandlestickChartOpDesc.getOutputSchemas" should + "produce a single html-content STRING column keyed by the declared output port" in { + val op = new CandlestickChartOpDesc + val out = op.getOutputSchemas(Map(op.operatorInfo.inputPorts.head.id -> Schema())) + out shouldBe Map( + op.operatorInfo.outputPorts.head.id -> Schema().add("html-content", AttributeType.STRING) + ) + } + + "CandlestickChartOpDesc.generatePythonCode" should "emit a Plotly Candlestick figure" in { + val code = configured().generatePythonCode() + code should include("go.Candlestick(") + code should include("html-content") + } + + "CandlestickChartOpDesc" should "round-trip its OHLC fields through the polymorphic base" in { + val restored = + objectMapper.readValue(objectMapper.writeValueAsString(configured()), classOf[LogicalOp]) + restored shouldBe a[CandlestickChartOpDesc] + val c = restored.asInstanceOf[CandlestickChartOpDesc] + c.date shouldBe "day" + c.open shouldBe "o" + c.high shouldBe "h" + c.low shouldBe "l" + c.close shouldBe "c" + } +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/histogram2d/Histogram2DOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/histogram2d/Histogram2DOpDescSpec.scala new file mode 100644 index 0000000000..bc56ab72b1 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/histogram2d/Histogram2DOpDescSpec.scala @@ -0,0 +1,93 @@ +/* + * 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.histogram2d + +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 + +class Histogram2DOpDescSpec extends AnyFlatSpec with Matchers { + + private def configured(): Histogram2DOpDesc = { + val d = new Histogram2DOpDesc + d.xColumn = "x" + d.yColumn = "y" + d + } + + "Histogram2DOpDesc.operatorInfo" should + "advertise the name and Statistical visualization group" in { + val info = (new Histogram2DOpDesc).operatorInfo + info.userFriendlyName shouldBe "Histogram2D" + info.operatorGroupName shouldBe OperatorGroupConstants.VISUALIZATION_STATISTICAL_GROUP + info.inputPorts should have length 1 + info.outputPorts should have length 1 + } + + "Histogram2DOpDesc" should "default bins to 10 and normalization to DENSITY" in { + val d = new Histogram2DOpDesc + d.xBins shouldBe 10 + d.yBins shouldBe 10 + d.normalize shouldBe NormalizationType.DENSITY + d.xColumn shouldBe "" + d.yColumn shouldBe "" + } + + "Histogram2DOpDesc.getOutputSchemas" should + "produce a single html-content STRING column keyed by the declared output port" in { + val op = new Histogram2DOpDesc + val out = op.getOutputSchemas(Map(op.operatorInfo.inputPorts.head.id -> Schema())) + out shouldBe Map( + op.operatorInfo.outputPorts.head.id -> Schema().add("html-content", AttributeType.STRING) + ) + } + + "Histogram2DOpDesc.generatePythonCode" should "reject a non-positive bin count" in { + val d = configured() + d.xBins = 0 + intercept[AssertionError] { + d.generatePythonCode() + } + } + + it should "emit a Plotly density-heatmap figure for a valid config" in { + val code = configured().generatePythonCode() + code should include("px.density_heatmap(") + code should include("html-content") + } + + "Histogram2DOpDesc" should "round-trip its fields through the polymorphic base" in { + val d = configured() + d.xBins = 20 + d.yBins = 5 + d.normalize = NormalizationType.PROBABILITY + val restored = objectMapper.readValue(objectMapper.writeValueAsString(d), classOf[LogicalOp]) + restored shouldBe a[Histogram2DOpDesc] + val h = restored.asInstanceOf[Histogram2DOpDesc] + h.xColumn shouldBe "x" + h.yColumn shouldBe "y" + h.xBins shouldBe 20 + h.yBins shouldBe 5 + h.normalize shouldBe NormalizationType.PROBABILITY + } +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/htmlviz/HtmlVizOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/htmlviz/HtmlVizOpDescSpec.scala new file mode 100644 index 0000000000..09ceafc3ce --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/htmlviz/HtmlVizOpDescSpec.scala @@ -0,0 +1,94 @@ +/* + * 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.htmlviz + +import com.fasterxml.jackson.annotation.JsonProperty +import javax.validation.constraints.NotNull +import org.apache.texera.amber.core.executor.OpExecWithClassName +import org.apache.texera.amber.core.tuple.{Attribute, AttributeType, Schema} +import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, WorkflowIdentity} +import org.apache.texera.amber.operator.LogicalOp +import org.apache.texera.amber.operator.metadata.OperatorGroupConstants +import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName +import org.apache.texera.amber.util.JSONUtils.objectMapper +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class HtmlVizOpDescSpec extends AnyFlatSpec with Matchers { + + private val workflowId = WorkflowIdentity(1L) + private val executionId = ExecutionIdentity(1L) + + "HtmlVizOpDesc.operatorInfo" should "advertise the name and Media visualization group" in { + val info = (new HtmlVizOpDesc).operatorInfo + info.userFriendlyName shouldBe "HTML Visualizer" + info.operatorGroupName shouldBe OperatorGroupConstants.VISUALIZATION_MEDIA_GROUP + info.inputPorts should have length 1 + info.outputPorts should have length 1 + } + + "HtmlVizOpDesc.htmlContentAttrName" should "default to the empty string" in { + (new HtmlVizOpDesc).htmlContentAttrName shouldBe "" + } + + it should "carry @JsonProperty(required = true) + @AutofillAttributeName + @NotNull" in { + val field = classOf[HtmlVizOpDesc].getDeclaredField("htmlContentAttrName") + val jp = field.getAnnotation(classOf[JsonProperty]) + jp should not be null + jp.required shouldBe true + field.getAnnotation(classOf[AutofillAttributeName]) should not be null + val notNull = field.getAnnotation(classOf[NotNull]) + notNull should not be null + notNull.message shouldBe "HTML content cannot be empty" + } + + "HtmlVizOpDesc" should "round-trip htmlContentAttrName through the polymorphic base" in { + val op = new HtmlVizOpDesc + op.htmlContentAttrName = "myCol" + val restored = objectMapper.readValue(objectMapper.writeValueAsString(op), classOf[LogicalOp]) + restored shouldBe a[HtmlVizOpDesc] + restored.asInstanceOf[HtmlVizOpDesc].htmlContentAttrName shouldBe "myCol" + } + + "HtmlVizOpDesc.getPhysicalOp" should "wire HtmlVizOpExec and carry port identities" in { + val op = new HtmlVizOpDesc + op.htmlContentAttrName = "html" + val physical = op.getPhysicalOp(workflowId, executionId) + physical.opExecInitInfo match { + case OpExecWithClassName(className, descString) => + className shouldBe "org.apache.texera.amber.operator.visualization.htmlviz.HtmlVizOpExec" + descString should not be empty + case other => fail(s"expected OpExecWithClassName, got $other") + } + physical.inputPorts.keySet shouldBe op.operatorInfo.inputPorts.map(_.id).toSet + physical.outputPorts.keySet shouldBe op.operatorInfo.outputPorts.map(_.id).toSet + } + + "HtmlVizOpDesc schema propagation" should + "emit a single html-content STRING column keyed by the declared output port" in { + val op = new HtmlVizOpDesc + op.htmlContentAttrName = "html" + val input = Schema().add(new Attribute("html", AttributeType.STRING)) + val out = op.getExternalOutputSchemas(Map(op.operatorInfo.inputPorts.head.id -> input)) + out shouldBe Map( + op.operatorInfo.outputPorts.head.id -> Schema().add("html-content", AttributeType.STRING) + ) + } +}
