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-7768-0f54e934f147721ee53b04056808f194607333db
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 8be174308d4e597f120253cc68dbd4f21c0af1ed
Author: Eugene Gu <[email protected]>
AuthorDate: Wed Aug 19 04:38:51 2026 +0000

    test(workflow-operator): guard visualization output schemas (#7768)
    
    ### What changes were proposed in this PR?
    
    The frontend renders a chart by reading a single `html-content` STRING
    column from a visualization operator's one output port. All 48 Python
    visualization descriptors re-declare that contract in identical
    three-line `getOutputSchemas` overrides, but 24 of them never had the
    method called by any test, and no spec swept the operator registry for
    the contract as a whole — so a new chart, or an edit to an existing one,
    could silently ship a schema the frontend cannot render.
    
    This PR adds one new guard spec, `VisualizationOutputSchemaSpec`,
    instead of 24 per-operator tests. It walks
    `OperatorMetadataGenerator.operatorTypeMap`, selects the visualization
    descriptors by package, instantiates each, and asserts the contract, so
    the next chart operator is covered the moment it is registered. Four
    tests: a floor assertion (at least 48 swept) so a package rename cannot
    turn the sweep into a vacuous pass; an assertion that the only
    visualization-package registrations outside `PythonOperatorDescriptor`
    are exactly `HtmlVizOpDesc` and `UrlVizOpDesc`, which derive their
    schemas through `SchemaPropagationFunc` and are covered by their own
    specs, so a new non-Python visualization operator must be consciously
    added to the exclusion list; the main per-descriptor sweep asserting
    exactly one output port whose schema is a single `html-content` STRING
    attribute, with a per-operator `withClue` for diagnosis; and an
    assertion that the derived schema is independent of the input schemas.
    
    No production code changed; all 48 descriptors satisfy the contract
    today. The registry-wide-guard shape follows the precedents in the same
    package, `OutputPortReuseFlagSpec` and `AttributeTypeRuleTargetSpec`
    (#7249).
    
    ### Any related issues, documentation, discussions?
    
    Resolves #7766
    
    ### How was this PR tested?
    
    This PR is test-only. The new suite passes locally: `sbt
    "WorkflowOperator/testOnly *VisualizationOutputSchemaSpec"` runs 4 tests
    sweeping all 48 descriptors, all green, and
    `WorkflowOperator/Test/scalafmtCheck` passes. The full
    `WorkflowOperator/test` suite was also run locally to confirm the new
    spec introduces no cross-test interference.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Fable 5)
---
 .../metadata/VisualizationOutputSchemaSpec.scala   | 108 +++++++++++++++++++++
 1 file changed, 108 insertions(+)

diff --git 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/metadata/VisualizationOutputSchemaSpec.scala
 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/metadata/VisualizationOutputSchemaSpec.scala
new file mode 100644
index 0000000000..d1f5ee04a0
--- /dev/null
+++ 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/metadata/VisualizationOutputSchemaSpec.scala
@@ -0,0 +1,108 @@
+/*
+ * 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.metadata
+
+import org.apache.texera.amber.core.tuple.{AttributeType, Schema}
+import org.apache.texera.amber.core.workflow.PortIdentity
+import org.apache.texera.amber.operator.PythonOperatorDescriptor
+import org.apache.texera.amber.operator.visualization.htmlviz.HtmlVizOpDesc
+import org.apache.texera.amber.operator.visualization.urlviz.UrlVizOpDesc
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+/**
+  * Guard for the visualization output-schema contract, over every registered
+  * visualization descriptor.
+  *
+  * The frontend locates the chart HTML a visualization returns by reading a
+  * single `html-content` STRING column from the operator's one output port.
+  * Every Python visualization descriptor re-declares that contract in its own
+  * `getOutputSchemas` override, so a new chart (or an edit to an existing one)
+  * can silently ship a schema the frontend cannot render, and only that
+  * operator's own spec -- if it asserts the schema at all -- would notice.
+  *
+  * Sweeping the registry rather than testing the descriptors one by one is
+  * deliberate: the contract lives in 48 identical three-line overrides, so the
+  * mistake is as easy to make in the next chart as in these, and a 
per-operator
+  * test would not exist for the next chart until someone remembers to write 
it.
+  */
+class VisualizationOutputSchemaSpec extends AnyFlatSpec with Matchers {
+
+  // HtmlViz and UrlViz are Scala map operators: they do not extend
+  // PythonOperatorDescriptor and derive their output schema through
+  // getPhysicalOp's SchemaPropagationFunc instead, covered by their own specs.
+  // They are excluded by explicit class so that any NEW visualization
+  // descriptor is swept by default and must be consciously added here to
+  // escape the contract.
+  private val nonPythonVisualizations: Set[Class[_]] =
+    Set(classOf[HtmlVizOpDesc], classOf[UrlVizOpDesc])
+
+  private val visualizationClasses =
+    OperatorMetadataGenerator.operatorTypeMap.keys.toSeq
+      
.filter(_.getName.startsWith("org.apache.texera.amber.operator.visualization."))
+      .sortBy(_.getSimpleName)
+
+  private val sweptClasses =
+    visualizationClasses.filterNot(nonPythonVisualizations.contains)
+
+  private def instantiate(opClass: Class[_]): PythonOperatorDescriptor =
+    
opClass.getConstructor().newInstance().asInstanceOf[PythonOperatorDescriptor]
+
+  "the registry" should "contain the visualization descriptors this sweep 
guards" in {
+    // If the selection ever comes back empty (say, the package is renamed),
+    // the per-operator assertions below would vacuously pass; pin a floor.
+    sweptClasses.size should be >= 48
+  }
+
+  it should "register no visualization descriptor outside the Python contract 
except the known two" in {
+    val nonPython =
+      
visualizationClasses.filterNot(classOf[PythonOperatorDescriptor].isAssignableFrom)
+    nonPython.toSet shouldBe nonPythonVisualizations
+  }
+
+  "every Python visualization descriptor" should "declare a single 
html-content STRING column on its one output port" in {
+    sweptClasses.foreach { opClass =>
+      withClue(s"${opClass.getSimpleName}: ") {
+        val op = instantiate(opClass)
+        val outputPortIds = op.operatorInfo.outputPorts.map(_.id)
+        outputPortIds should have length 1
+
+        val outputSchemas = op.getOutputSchemas(Map.empty[PortIdentity, 
Schema])
+        outputSchemas.keySet shouldBe Set(outputPortIds.head)
+
+        val schema = outputSchemas(outputPortIds.head)
+        schema.getAttributeNames shouldBe List("html-content")
+        schema.getAttribute("html-content").getType shouldBe 
AttributeType.STRING
+      }
+    }
+  }
+
+  it should "derive that schema independently of the input schemas" in {
+    val populatedInput =
+      Map(PortIdentity() -> Schema().add("x", AttributeType.INTEGER))
+    sweptClasses.foreach { opClass =>
+      withClue(s"${opClass.getSimpleName}: ") {
+        val op = instantiate(opClass)
+        op.getOutputSchemas(Map.empty[PortIdentity, Schema]) shouldBe
+          op.getOutputSchemas(populatedInput)
+      }
+    }
+  }
+}

Reply via email to