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-7018-a3d12b02201e32fff70270fd48ed25abb86ad8af in repository https://gitbox.apache.org/repos/asf/texera.git
commit 123b9f9b8ba6f571810543653be8af267e659938 Author: Xinyuan Lin <[email protected]> AuthorDate: Tue Jul 28 23:33:53 2026 -0700 test(workflow-operator): extend operator descriptor and predicate coverage (#7018) ### What changes were proposed in this PR? Adds 47 tests across five `common/workflow-operator` classes (33 -> 80 in these suites). No source changes; all four extended specs are **pure insertions** (0 deletions), so no existing test was modified. - **`AggregationOperation`**: the SUM/COUNT/MIN/MAX `merge` lambdas — invoked when several workers report into the global stage and never exercised by `AggregateOpSpec` — each cross-checked against the equivalent single-pass aggregation; CONCAT's empty-partial branch; the unknown-function error message; and `getFinal`'s detached-copy contract. - **`FilterPredicate`**: the two attribute-type cases nothing reached — `ANY` (routes through the string path, with numeric coercion and a lexicographic fallback) and `BINARY` (throws `unsupported attribute type`) — plus the `IS_NULL`/`IS_NOT_NULL` short-circuit ahead of the type switch, value trimming, and `NumberFormatException` propagation. - **`ECDFPlotOpDesc`** and **`TimeSeriesOpDesc`**: `operatorInfo` and output schema, the optional settings that gate generated Plotly arguments, the generated-code empty-table guards, and — per the repo convention for LogicalOp subtypes — a **JSON round-trip** through `LogicalOp` asserting every config field survives, plus a minimal payload deserializing to the documented defaults. Neither spec had a round-trip before. - **`OPVersion`** (new spec): the `"N/A"` fallback for a path with no commit history, and the name-keyed memoization contract. Some branches were deliberately **not** contrived, and are listed with reasons in the review notes — notably `FilterPredicate`'s unreachable `default:` throw and dead null-guard ternaries (`evaluate` returns before them), and `OPVersion`'s success path (needs an openable repository; inside a git worktree the `.git` entry is a file and jgit leaves the handle null, so the spec asserts only what holds in both environments). ### Any related issues, documentation, discussions? Closes #7016. ### How was this PR tested? `sbt -java-home <jbr-17> "WorkflowOperator/testOnly *AggregationOperationSpec *FilterPredicateSpec *ECDFPlotOpDescSpec *TimeSeriesOpDescSpec *OPVersionSpec"` -> 80 succeeded, 0 failed. `Test/scalafmtCheck` + `Test/scalafix --check` clean. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.8 [1M context]) --- .../aggregate/AggregationOperationSpec.scala | 135 ++++++++++++++ .../operator/filter/FilterPredicateSpec.scala | 65 +++++++ .../amber/operator/metadata/OPVersionSpec.scala | 156 ++++++++++++++++ .../timeSeriesPlot/TimeSeriesOpDescSpec.scala | 197 ++++++++++++++++++++ .../ecdfPlot/ECDFPlotOpDescSpec.scala | 198 +++++++++++++++++++++ 5 files changed, 751 insertions(+) diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/aggregate/AggregationOperationSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/aggregate/AggregationOperationSpec.scala index 0931e3bab7..44f0f1b3da 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/aggregate/AggregationOperationSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/aggregate/AggregationOperationSpec.scala @@ -259,4 +259,139 @@ class AggregationOperationSpec extends AnyFlatSpec { agg.iterate(agg.init(), tupleOf("v", AttributeType.TIMESTAMP, null)) assert(agg.finalAgg(allNull) == null) } + + // --- merge: the partial-combination lambda of each aggregation -------------- + + // `AggregateOpSpec` drives init/iterate/finalAgg for every aggregation but only + // ever merges AVERAGE/CONCAT partials. The `merge` lambdas of SUM, COUNT, MIN + // and MAX are what the global stage calls when several workers report in, so + // each one is exercised here directly and cross-checked against the equivalent + // single-pass aggregation. + + private def aggregateAll( + agg: DistributedAggregation[Object], + t: AttributeType, + values: Seq[AnyRef] + ): Object = + agg.finalAgg(values.map(v => tupleOf("v", t, v)).foldLeft(agg.init())(agg.iterate)) + + "SUM aggregation merge" should "add two partials, matching a single-pass SUM" in { + val agg = op(AggregationFunction.SUM).getAggFunc(AttributeType.LONG) + val left = Seq[AnyRef](Long.box(1L), Long.box(2L)) + val right = Seq[AnyRef](Long.box(10L), Long.box(20L)) + + val p1 = left.map(v => tupleOf("v", AttributeType.LONG, v)).foldLeft(agg.init())(agg.iterate) + val p2 = right.map(v => tupleOf("v", AttributeType.LONG, v)).foldLeft(agg.init())(agg.iterate) + + assert(agg.finalAgg(agg.merge(p1, p2)) == Long.box(33L)) + assert(agg.finalAgg(agg.merge(p1, p2)) == aggregateAll(agg, AttributeType.LONG, left ++ right)) + // merging with an untouched (zero) partial must be a no-op + assert(agg.finalAgg(agg.merge(p1, agg.init())) == Long.box(3L)) + } + + "COUNT aggregation merge" should "add the per-worker counts" in { + val agg = op(AggregationFunction.COUNT).getAggFunc(AttributeType.INTEGER) + val p1 = Seq[AnyRef](Int.box(1), null, Int.box(3)) + .map(v => tupleOf("v", AttributeType.INTEGER, v)) + .foldLeft(agg.init())(agg.iterate) + val p2 = Seq[AnyRef](Int.box(4)) + .map(v => tupleOf("v", AttributeType.INTEGER, v)) + .foldLeft(agg.init())(agg.iterate) + + // COUNT(v) skips the null, so 2 + 1 == 3 + assert(agg.finalAgg(agg.merge(p1, p2)) == Int.box(3)) + assert(agg.finalAgg(agg.merge(agg.init(), agg.init())) == Int.box(0)) + } + + "MIN and MAX aggregation merge" should "pick the smaller and larger partial respectively" in { + val minAgg = op(AggregationFunction.MIN).getAggFunc(AttributeType.DOUBLE) + val maxAgg = op(AggregationFunction.MAX).getAggFunc(AttributeType.DOUBLE) + val left = Seq[AnyRef](Double.box(4.0), Double.box(9.0)) + val right = Seq[AnyRef](Double.box(-1.5), Double.box(2.0)) + + def partialOf(agg: DistributedAggregation[Object], values: Seq[AnyRef]): Object = + values.map(v => tupleOf("v", AttributeType.DOUBLE, v)).foldLeft(agg.init())(agg.iterate) + + assert( + minAgg.finalAgg(minAgg.merge(partialOf(minAgg, left), partialOf(minAgg, right))) + == Double.box(-1.5) + ) + // merge must be symmetric + assert( + minAgg.finalAgg(minAgg.merge(partialOf(minAgg, right), partialOf(minAgg, left))) + == Double.box(-1.5) + ) + assert( + maxAgg.finalAgg(maxAgg.merge(partialOf(maxAgg, left), partialOf(maxAgg, right))) + == Double.box(9.0) + ) + assert( + maxAgg.finalAgg(maxAgg.merge(partialOf(maxAgg, right), partialOf(maxAgg, left))) + == Double.box(9.0) + ) + } + + "MIN aggregation merge" should "stay neutral when one side saw no values" in { + val agg = op(AggregationFunction.MIN).getAggFunc(AttributeType.INTEGER) + val seen = agg.iterate(agg.init(), tupleOf("v", AttributeType.INTEGER, Int.box(7))) + + // An empty partial is the sentinel maxValue, so it must lose the comparison. + assert(agg.finalAgg(agg.merge(seen, agg.init())) == Int.box(7)) + assert(agg.finalAgg(agg.merge(agg.init(), seen)) == Int.box(7)) + // Two empty partials still finalize to null (no rows anywhere). + assert(agg.finalAgg(agg.merge(agg.init(), agg.init())) == null) + } + + // --- CONCAT: a null first value seeds the partial with an empty string ------ + + "CONCAT aggregation" should "swallow leading nulls but keep interior ones as empty slots" in { + // AggregateOpSpec only ever concatenates a null in the middle of the stream. + // A null on the very first tuple takes the `partial == ""` side of the branch + // and leaves the partial empty, so — unlike an interior null — it does not + // occupy a slot in the comma-joined output. + val agg = op(AggregationFunction.CONCAT).getAggFunc(AttributeType.STRING) + val result = Seq[AnyRef](null, "red", null, "blue") + .map(v => tupleOf("v", AttributeType.STRING, v)) + .foldLeft(agg.init())(agg.iterate) + + assert(agg.finalAgg(result) == "red,,blue") + } + + it should "return an empty string when every value is null" in { + val agg = op(AggregationFunction.CONCAT).getAggFunc(AttributeType.STRING) + val onlyNull = agg.iterate(agg.init(), tupleOf("v", AttributeType.STRING, null)) + + assert(agg.finalAgg(onlyNull) == "") + } + + it should "stringify non-string values it is pointed at" in { + // CONCAT is schema-restricted to STRING columns in the UI, but the executor + // only ever calls `.toString`, so an INTEGER column still concatenates. + val agg = op(AggregationFunction.CONCAT).getAggFunc(AttributeType.STRING) + val result = Seq[AnyRef](Int.box(1), Int.box(2)) + .map(v => tupleOf("v", AttributeType.INTEGER, v)) + .foldLeft(agg.init())(agg.iterate) + + assert(agg.finalAgg(result) == "1,2") + } + + // --- getAggregationAttribute / getFinal: message and identity guarantees ---- + + "getAggregationAttribute" should "name the unknown aggregation function in its error" in { + val ex = intercept[RuntimeException](op(null).getAggregationAttribute(AttributeType.INTEGER)) + assert(ex.getMessage == "Unknown aggregation function: null") + } + + "getFinal" should "produce a detached copy that re-reads the result column" in { + val original = op(AggregationFunction.MAX, attribute = "src", resultAttribute = "dst") + val copy = original.getFinal + + assert(copy ne original) + assert(copy.aggFunction == AggregationFunction.MAX) + // the final stage reads and writes the same (result) column + assert(copy.attribute == "dst") + assert(copy.resultAttribute == "dst") + // the original is left untouched + assert(original.attribute == "src") + } } diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/filter/FilterPredicateSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/filter/FilterPredicateSpec.scala index 231c2c3278..e683c7b4b5 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/filter/FilterPredicateSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/filter/FilterPredicateSpec.scala @@ -145,4 +145,69 @@ class FilterPredicateSpec extends AnyFlatSpec with Matchers { p.equals(null) shouldBe false p.equals("not a predicate") shouldBe false } + + // --- attribute types that no other test reaches ---------------------------- + + it should "route ANY columns through the string comparison path" in { + // ANY shares the STRING case of the type switch: the field is stringified and + // then compared numerically when both sides parse. + val t = singleFieldTuple(AttributeType.ANY, java.lang.Integer.valueOf(42)) + new FilterPredicate("col", ComparisonType.GREATER_THAN, "9").evaluate(t) shouldBe true + new FilterPredicate("col", ComparisonType.EQUAL_TO, "42").evaluate(t) shouldBe true + new FilterPredicate("col", ComparisonType.LESS_THAN, "9").evaluate(t) shouldBe false + } + + it should "fall back to lexicographic comparison when an ANY field is not numeric" in { + val t = singleFieldTuple(AttributeType.ANY, java.lang.Boolean.TRUE) + new FilterPredicate("col", ComparisonType.EQUAL_TO, "true").evaluate(t) shouldBe true + new FilterPredicate("col", ComparisonType.NOT_EQUAL_TO, "false").evaluate(t) shouldBe true + } + + it should "reject attribute types it cannot compare" in { + val t = singleFieldTuple(AttributeType.BINARY, Array[Byte](1, 2, 3)) + val ex = intercept[RuntimeException] { + new FilterPredicate("col", ComparisonType.EQUAL_TO, "1").evaluate(t) + } + ex.getMessage shouldBe "unsupported attribute type: binary" + } + + it should "still answer the null checks on an otherwise unsupported type" in { + // IS_NULL / IS_NOT_NULL short-circuit before the type switch, so a BINARY + // column is filterable for nullness even though it cannot be compared. + val t = singleFieldTuple(AttributeType.BINARY, Array[Byte](1)) + new FilterPredicate("col", ComparisonType.IS_NOT_NULL, null).evaluate(t) shouldBe true + new FilterPredicate("col", ComparisonType.IS_NULL, null).evaluate(t) shouldBe false + } + + // --- value-side parsing ---------------------------------------------------- + + it should "compare a numeric string field lexicographically when the value is not numeric" in { + // The tuple side parses as a number but the user-supplied value does not, so + // the numeric attempt aborts and both sides are compared as text. + val t = singleFieldTuple(AttributeType.STRING, "10") + new FilterPredicate("col", ComparisonType.LESS_THAN, "abc").evaluate(t) shouldBe true + new FilterPredicate("col", ComparisonType.EQUAL_TO, "abc").evaluate(t) shouldBe false + } + + it should "propagate a parse failure when the value cannot be read as the column's type" in { + val doubleTuple = singleFieldTuple(AttributeType.DOUBLE, java.lang.Double.valueOf(1.0)) + intercept[NumberFormatException] { + new FilterPredicate("col", ComparisonType.EQUAL_TO, "not-a-number").evaluate(doubleTuple) + } + val longTuple = singleFieldTuple(AttributeType.LONG, java.lang.Long.valueOf(1L)) + intercept[NumberFormatException] { + new FilterPredicate("col", ComparisonType.EQUAL_TO, "1.5").evaluate(longTuple) + } + } + + it should "trim surrounding whitespace off the value for boolean, long and timestamp columns" in { + new FilterPredicate("col", ComparisonType.EQUAL_TO, " TrUe ") + .evaluate(singleFieldTuple(AttributeType.BOOLEAN, java.lang.Boolean.TRUE)) shouldBe true + new FilterPredicate("col", ComparisonType.EQUAL_TO, " 100 ") + .evaluate(singleFieldTuple(AttributeType.LONG, java.lang.Long.valueOf(100L))) shouldBe true + new FilterPredicate("col", ComparisonType.LESS_THAN, " 2021-01-01 00:00:00 ") + .evaluate( + singleFieldTuple(AttributeType.TIMESTAMP, Timestamp.valueOf("2020-01-01 00:00:00")) + ) shouldBe true + } } diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/metadata/OPVersionSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/metadata/OPVersionSpec.scala new file mode 100644 index 0000000000..8ca517a7de --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/metadata/OPVersionSpec.scala @@ -0,0 +1,156 @@ +/* + * 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.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.util.UUID + +/** + * `OPVersion` resolves an operator's version from the git history of the file + * that defines it, memoizing the answer in a process-wide static map. + * + * Its git handle is opened once in a static initializer against `TEXERA_HOME` + * (defaulting to the working directory). Whether that succeeds depends entirely + * on how the tree was checked out — inside a `git worktree` the `.git` entry is + * a file rather than a directory and jgit raises `RepositoryNotFoundException`, + * leaving the handle null; in a plain clone the handle opens and an unknown path + * yields an empty log instead. Rather than assert whatever both happen to share, + * this spec pins the handle to a known state: every test forces the private + * static `git` field to null for its duration and restores the original value + * afterwards, so the assertions describe one deterministic code path regardless + * of how the tree was checked out (and no other suite in the JVM is affected). + * + * With the handle null, `git.log()` throws `NullPointerException` and resolution + * takes the `"N/A"` fallback. On top of that this spec pins the memoization + * contract: the answer is cached per operator name and the path is ignored on a + * cache hit. + * + * Deliberately NOT covered: the success path that returns a real commit hash and + * the `GitAPIException` catch (which, note, leaves `opMap` unpopulated and so + * returns null). Both require a specific, openable repository state that is not + * guaranteed for a test run. + */ +class OPVersionSpec extends AnyFlatSpec with Matchers { + + /** The cache is static and shared, so every test uses a name nothing else can collide with. */ + private def uniqueName(): String = s"OPVersionSpec-${UUID.randomUUID()}" + + private def uniqueMissingPath(): String = s"no/such/operator/path/${UUID.randomUUID()}" + + private def declaredField(name: String): java.lang.reflect.Field = { + val field = classOf[OPVersion].getDeclaredField(name) + field.setAccessible(true) + field + } + + /** The private static memo table, so tests can seed it and clean up after themselves. */ + private def opMap: java.util.Map[String, String] = { + declaredField("opMap").get(null).asInstanceOf[java.util.Map[String, String]] + } + + /** + * Runs `body` with `OPVersion`'s private static git handle forced to null, so the + * fallback path is exercised deterministically, then restores whatever was there. + */ + private def withNullGit[T](body: => T): T = { + val field = declaredField("git") + val original = field.get(null) + field.set(null, null) + try body + finally field.set(null, original) + } + + private def withCleanCache[T](names: String*)(body: => T): T = + try body + finally names.foreach(opMap.remove) + + "OPVersion.getVersion" should "fall back to \"N/A\" when the git handle is unavailable" in { + val name = uniqueName() + withCleanCache(name) { + withNullGit { + OPVersion.getVersion(name, uniqueMissingPath()) shouldBe "N/A" + } + } + } + + it should "never return null, whatever it resolves" in { + val name = uniqueName() + withCleanCache(name) { + withNullGit { + OPVersion.getVersion(name, "common/workflow-operator/src/main/scala") should not be null + } + } + } + + it should "memoize the resolved version under the operator name" in { + val name = uniqueName() + withCleanCache(name) { + withNullGit { + opMap.containsKey(name) shouldBe false + val resolved = OPVersion.getVersion(name, uniqueMissingPath()) + opMap.containsKey(name) shouldBe true + opMap.get(name) shouldBe resolved + } + } + } + + it should "serve the memoized value and ignore the path on subsequent calls" in { + val name = uniqueName() + withCleanCache(name) { + withNullGit { + // Seed a value no lookup could ever produce: if the cache were bypassed, + // the call below would recompute and answer "N/A" instead. + opMap.put(name, "seeded-version") + OPVersion.getVersion(name, uniqueMissingPath()) shouldBe "seeded-version" + OPVersion.getVersion(name, "a/completely/different/path") shouldBe "seeded-version" + } + } + } + + it should "key the cache by operator name rather than by path" in { + val first = uniqueName() + val second = uniqueName() + withCleanCache(first, second) { + withNullGit { + opMap.put(first, "version-of-first") + opMap.put(second, "version-of-second") + + val sharedPath = "common/workflow-operator/src/main/scala" + OPVersion.getVersion(first, sharedPath) shouldBe "version-of-first" + OPVersion.getVersion(second, sharedPath) shouldBe "version-of-second" + } + } + } + + it should "resolve unknown paths consistently across repeated calls" in { + val name = uniqueName() + withCleanCache(name) { + withNullGit { + val path = uniqueMissingPath() + val first = OPVersion.getVersion(name, path) + val second = OPVersion.getVersion(name, path) + first shouldBe "N/A" + second shouldBe first + } + } + } +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/timeSeriesPlot/TimeSeriesOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/timeSeriesPlot/TimeSeriesOpDescSpec.scala index ba6d6e6ccb..75478edf23 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/timeSeriesPlot/TimeSeriesOpDescSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/timeSeriesPlot/TimeSeriesOpDescSpec.scala @@ -19,9 +19,17 @@ package org.apache.texera.amber.operator.timeSeriesPlot +import org.apache.texera.amber.core.tuple.{AttributeType, Schema} +import org.apache.texera.amber.core.workflow.PortIdentity +import org.apache.texera.amber.operator.LogicalOp +import org.apache.texera.amber.operator.metadata.OperatorGroupConstants import org.apache.texera.amber.operator.visualization.timeSeriesplot.TimeSeriesOpDesc +import org.apache.texera.amber.util.JSONUtils.objectMapper import org.scalatest.funsuite.AnyFunSuite +import java.nio.charset.StandardCharsets +import java.util.Base64 + class TimeSeriesOpDescSpec extends AnyFunSuite { test("generatePythonCode returns non-empty python code") { @@ -41,4 +49,193 @@ class TimeSeriesOpDescSpec extends AnyFunSuite { assert(py.contains("class ProcessTableOperator")) assert(py.contains("def process_table")) } + + // --- helpers --------------------------------------------------------------- + + /** Column names reach the emitted Python base64-encoded inside a decode call. */ + private val decodeSite = "self.decode_python_template" + + private def b64(s: String): String = + Base64.getEncoder.encodeToString(s.getBytes(StandardCharsets.UTF_8)) + + private def decodeSiteCount(s: String): Int = + s.split(java.util.regex.Pattern.quote(decodeSite), -1).length - 1 + + private def lineContaining(code: String, marker: String): String = + code.linesIterator + .find(_.contains(marker)) + .getOrElse(fail(s"no line containing '$marker' in:\n$code")) + + /** + * The bracketed `dropna(subset=[...])` list only. The surrounding line also + * carries a `sort_values(by=...)` splice, so it cannot be counted wholesale. + * Base64 payloads never contain ']', so the first ']' closes the list. + */ + private def dropnaSubset(code: String): String = { + val line = lineContaining(code, "table.dropna(subset=[") + val start = line.indexOf("subset=[") + "subset=[".length + line.substring(start, line.indexOf(']', start)) + } + + /** Minimally configured operator: both optional columns left at "No Selection". */ + private def minimalOp(): TimeSeriesOpDesc = { + val op = new TimeSeriesOpDesc + op.timeColumn = "date" + op.valueColumn = "value" + op + } + + // --- operator metadata ------------------------------------------------------ + + test("operatorInfo advertises the plot name, basic viz group, and a 1-in/1-out shape") { + val info = minimalOp().operatorInfo + assert(info.userFriendlyName == "Time Series Plot") + assert(info.operatorDescription == "Visualize trends and patterns over time.") + assert(info.operatorGroupName == OperatorGroupConstants.VISUALIZATION_BASIC_GROUP) + assert(info.inputPorts.length == 1) + assert(info.outputPorts.length == 1) + } + + test("getOutputSchemas emits one html-content STRING column on the declared output port") { + val op = minimalOp() + val inputPort = op.operatorInfo.inputPorts.head.id + val out = op.getOutputSchemas( + Map(inputPort -> Schema().add("date", AttributeType.TIMESTAMP)) + ) + + assert(out.keySet == Set(op.operatorInfo.outputPorts.head.id)) + val schema = out(op.operatorInfo.outputPorts.head.id) + assert(schema.getAttributeNames == List("html-content")) + assert(schema.getAttribute("html-content").getType == AttributeType.STRING) + } + + test("getOutputSchemas ignores the input schema (the plot output shape is fixed)") { + val op = minimalOp() + val fromEmpty = op.getOutputSchemas(Map.empty[PortIdentity, Schema]) + val fromPopulated = + op.getOutputSchemas(Map(PortIdentity(3) -> Schema().add("x", AttributeType.INTEGER))) + assert(fromEmpty == fromPopulated) + } + + // --- optional columns ------------------------------------------------------- + + test("the default 'No Selection' optional columns add neither color, facet, nor dropna entries") { + val py = minimalOp().generatePythonCode() + + assert(!py.contains(", color=")) + assert(!py.contains(", facet_col=")) + // only timeColumn and valueColumn are required to be non-null + assert(decodeSiteCount(dropnaSubset(py)) == 2) + } + + test("a configured category column adds a color argument and a dropna entry") { + val op = minimalOp() + op.CategoryColumn = "region" + val py = op.generatePythonCode() + + assert(py.contains(", color=")) + assert(!py.contains(", facet_col=")) + assert(py.contains(b64("region"))) + assert(decodeSiteCount(dropnaSubset(py)) == 3) + } + + test("a configured facet column adds a facet_col argument and a dropna entry") { + val op = minimalOp() + op.CategoryColumn = "region" + op.facetColumn = "store" + val py = op.generatePythonCode() + + assert(py.contains(", color=")) + assert(py.contains(", facet_col=")) + assert(py.contains(b64("store"))) + assert(decodeSiteCount(dropnaSubset(py)) == 4) + } + + // --- plot type and range slider --------------------------------------------- + + test("plotType selects px.line by default and px.area when asked for an area chart") { + assert(minimalOp().generatePythonCode().contains("fig = px.line(table")) + + val areaOp = minimalOp() + areaOp.plotType = "area" + val areaPy = areaOp.generatePythonCode() + assert(areaPy.contains("fig = px.area(table")) + assert(!areaPy.contains("px.line(")) + + // any unrecognized plot type falls back to a line chart + val oddOp = minimalOp() + oddOp.plotType = "bar" + assert(oddOp.generatePythonCode().contains("fig = px.line(table")) + } + + test("showRangeSlider gates the rangeslider_visible call") { + val off = minimalOp() + off.showRangeSlider = false + val offPy = off.generatePythonCode() + assert(offPy.contains("if False:")) + assert(!offPy.contains("if True:")) + + val on = minimalOp() + on.showRangeSlider = true + val onPy = on.generatePythonCode() + assert(onPy.contains("if True:")) + assert(onPy.contains("fig.update_xaxes(rangeslider_visible=True)")) + } + + test("showRangeSlider defaults to false on a freshly constructed descriptor") { + assert(!new TimeSeriesOpDesc().showRangeSlider) + } + + // --- generated code shape ---------------------------------------------------- + + test("generated code coerces the time column, sorts by it, and guards both empty cases") { + val py = minimalOp().generatePythonCode() + + assert(py.contains("pd.to_datetime(")) + assert(py.contains("errors='coerce'")) + assert(py.contains(".sort_values(by=")) + assert(py.contains("Input table is empty.")) + assert(py.contains("Table became empty after filtering.")) + assert(py.contains("except Exception as e:")) + assert(py.contains("plotly.io.to_html(fig, include_plotlyjs='cdn', full_html=False)")) + // column names are encoded, never spliced raw + assert(py.contains(b64("date")) && py.contains(b64("value"))) + } + + // --- JSON round-trip --------------------------------------------------------- + + test("the descriptor round-trips all of its config fields through the polymorphic base") { + val op = new TimeSeriesOpDesc + op.timeColumn = "date" + op.valueColumn = "sales" + op.CategoryColumn = "region" + op.facetColumn = "store" + op.plotType = "area" + op.showRangeSlider = true + + val restored = objectMapper.readValue(objectMapper.writeValueAsString(op), classOf[LogicalOp]) + + assert(restored.isInstanceOf[TimeSeriesOpDesc]) + val d = restored.asInstanceOf[TimeSeriesOpDesc] + assert(d.timeColumn == "date") + assert(d.valueColumn == "sales") + assert(d.CategoryColumn == "region") + assert(d.facetColumn == "store") + assert(d.plotType == "area") + assert(d.showRangeSlider) + } + + test("a minimal JSON payload deserializes with the documented defaults") { + val json = + """{"operatorType":"TimeSeriesPlot","operatorID":"TimeSeriesPlot-1",""" + + """"timeColumn":"date","valueColumn":"sales"}""" + val d = objectMapper.readValue(json, classOf[LogicalOp]).asInstanceOf[TimeSeriesOpDesc] + + assert(d.timeColumn == "date") + assert(d.valueColumn == "sales") + assert(d.CategoryColumn == "No Selection") + assert(d.facetColumn == "No Selection") + assert(d.plotType == "line") + assert(!d.showRangeSlider) + } } diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/ecdfPlot/ECDFPlotOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/ecdfPlot/ECDFPlotOpDescSpec.scala index 8bded2f414..b1fe949935 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/ecdfPlot/ECDFPlotOpDescSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/visualization/ecdfPlot/ECDFPlotOpDescSpec.scala @@ -19,6 +19,11 @@ package org.apache.texera.amber.operator.visualization.ecdfPlot +import org.apache.texera.amber.core.tuple.{AttributeType, Schema} +import org.apache.texera.amber.core.workflow.PortIdentity +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.BeforeAndAfter import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers @@ -97,4 +102,197 @@ class ECDFPlotOpDescSpec extends AnyFlatSpec with BeforeAndAfter with Matchers { val code = opDesc.generatePythonCode() assert(carries(code, "ecdf_value_col")) } + + // --- helpers for the argument-shape assertions ----------------------------- + + // Every column name spliced into the emitted Python becomes a decode site, so + // counting them on one line tells us how many columns that line references. + private val decodeSite = "self.decode_python_template" + + private def decodeSiteCount(s: String): Int = + s.split(java.util.regex.Pattern.quote(decodeSite), -1).length - 1 + + private def lineContaining(code: String, marker: String): String = + code.linesIterator + .find(_.contains(marker)) + .getOrElse(fail(s"no line containing '$marker' in:\n$code")) + + // --- operator metadata ------------------------------------------------------ + + "ECDFPlotOpDesc.operatorInfo" should + "advertise the visualization name, group, and a 1-in/1-out shape" in { + val info = opDesc.operatorInfo + info.userFriendlyName shouldBe "Empirical Cumulative Distribution Plot" + info.operatorDescription shouldBe + "Visualize the empirical cumulative distribution of a numeric column." + info.operatorGroupName shouldBe OperatorGroupConstants.VISUALIZATION_STATISTICAL_GROUP + info.inputPorts should have length 1 + info.outputPorts should have length 1 + } + + "ECDFPlotOpDesc.getOutputSchemas" should + "emit a single html-content STRING column keyed by the declared output port" in { + opDesc.valueColumn = "score" + val inputPort = opDesc.operatorInfo.inputPorts.head.id + val out = opDesc.getOutputSchemas( + Map(inputPort -> Schema().add("score", AttributeType.DOUBLE)) + ) + + out.keySet shouldBe Set(opDesc.operatorInfo.outputPorts.head.id) + val schema = out(opDesc.operatorInfo.outputPorts.head.id) + schema.getAttributeNames should contain theSameElementsAs List("html-content") + schema.getAttribute("html-content").getType shouldBe AttributeType.STRING + } + + it should "ignore the input schema entirely (the plot output shape is fixed)" in { + opDesc.valueColumn = "score" + val fromEmpty = opDesc.getOutputSchemas(Map.empty[PortIdentity, Schema]) + val fromPopulated = opDesc.getOutputSchemas( + Map(PortIdentity(7) -> Schema().add("anything", AttributeType.INTEGER)) + ) + fromEmpty shouldBe fromPopulated + } + + // --- createPlotlyFigure: the argument list it assembles --------------------- + + "ECDFPlotOpDesc.createPlotlyFigure" should + "emit only the value column when every optional setting is left at its default" in { + opDesc.valueColumn = "score" + val call = lineContaining(opDesc.createPlotlyFigure().plain, "px.ecdf(") + + decodeSiteCount(call) shouldBe 1 + call should include(s"px.ecdf(table, x=$decodeSite") + call should not include "ecdfnorm" + call should not include ", y=" + call should not include "color=" + call should not include "facet_col=" + call should not include "ecdfmode=" + call should not include "orientation=" + call should not include "markers=True" + call should not include "marginal=" + } + + it should "request a cumulative sum by turning off normalization and passing y" in { + opDesc.valueColumn = "score" + opDesc.yAxisMode = "sum" + val call = lineContaining(opDesc.createPlotlyFigure().plain, "px.ecdf(") + + call should include("ecdfnorm=None") + call should include(s", y=$decodeSite") + // the value column is spliced twice: once as x, once as y + decodeSiteCount(call) shouldBe 2 + } + + it should "turn off normalization without a y argument for raw counts" in { + opDesc.valueColumn = "score" + opDesc.yAxisMode = "count" + val call = lineContaining(opDesc.createPlotlyFigure().plain, "px.ecdf(") + + call should include("ecdfnorm=None") + call should not include ", y=" + decodeSiteCount(call) shouldBe 1 + } + + it should "omit ecdfmode, orientation and marginal when they hold their default values" in { + opDesc.valueColumn = "score" + opDesc.cdfMode = "standard" + opDesc.orientation = "vertical" + opDesc.marginal = "none" + opDesc.showMarkers = false + val call = lineContaining(opDesc.createPlotlyFigure().plain, "px.ecdf(") + + call should not include "ecdfmode=" + call should not include "orientation=" + call should not include "marginal=" + call should not include "markers=" + } + + // --- manipulateTable: the dropna column list ------------------------------- + + "ECDFPlotOpDesc.manipulateTable" should + "require only the value column when no optional column is configured" in { + opDesc.valueColumn = "score" + val required = lineContaining(opDesc.manipulateTable().plain, "required_cols = [") + + decodeSiteCount(required) shouldBe 1 + assert(carries(required, "score")) + } + + it should "require the color and separate-by columns too when they are configured" in { + opDesc.valueColumn = "score" + opDesc.colorColumn = "group" + opDesc.separateBy = "category" + val required = lineContaining(opDesc.manipulateTable().plain, "required_cols = [") + + decodeSiteCount(required) shouldBe 3 + assert(carries(required, "score")) + assert(carries(required, "group")) + assert(carries(required, "category")) + } + + it should "coerce the value column to numeric and drop the rows that fail" in { + opDesc.valueColumn = "score" + val table = opDesc.manipulateTable().plain + + table should include("pd.to_numeric(") + table should include("errors='coerce'") + table should include("inplace=True") + } + + // --- generated code --------------------------------------------------------- + + "ECDFPlotOpDesc.generatePythonCode" should + "wrap the figure in a table operator that guards both empty-table cases" in { + opDesc.valueColumn = "score" + val code = opDesc.generatePythonCode() + + code should include("class ProcessTableOperator(UDFTableOperator)") + code should include("def process_table") + code should include("input table is empty.") + code should include("no valid rows left after removing missing or non-numeric values.") + code should include("plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False)") + code should include("yield {'html-content': html}") + } + + // --- JSON round-trip -------------------------------------------------------- + + "ECDFPlotOpDesc" should "round-trip all of its config fields through the polymorphic base" in { + opDesc.valueColumn = "score" + opDesc.colorColumn = "group" + opDesc.separateBy = "category" + opDesc.yAxisMode = "sum" + opDesc.cdfMode = "complementary" + opDesc.orientation = "horizontal" + opDesc.showMarkers = true + opDesc.marginal = "rug" + + val restored = + objectMapper.readValue(objectMapper.writeValueAsString(opDesc), classOf[LogicalOp]) + + restored shouldBe a[ECDFPlotOpDesc] + val d = restored.asInstanceOf[ECDFPlotOpDesc] + d.valueColumn shouldBe "score" + d.colorColumn shouldBe "group" + d.separateBy shouldBe "category" + d.yAxisMode shouldBe "sum" + d.cdfMode shouldBe "complementary" + d.orientation shouldBe "horizontal" + d.showMarkers shouldBe true + d.marginal shouldBe "rug" + } + + it should "keep its documented defaults when deserialized from a minimal JSON payload" in { + val json = + """{"operatorType":"ECDFPlot","operatorID":"ECDFPlot-1","valueColumn":"score"}""" + val d = objectMapper.readValue(json, classOf[LogicalOp]).asInstanceOf[ECDFPlotOpDesc] + + d.valueColumn shouldBe "score" + d.colorColumn shouldBe "" + d.separateBy shouldBe "" + d.yAxisMode shouldBe "probability" + d.cdfMode shouldBe "standard" + d.orientation shouldBe "vertical" + d.showMarkers shouldBe false + d.marginal shouldBe "none" + } }
