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-7989-9fe06578f54c8d79488bb16337b2fe24d8d68dae in repository https://gitbox.apache.org/repos/asf/texera.git
commit 2b8138118d8e56eee6779f8b06bcc416a77e9689 Author: Xinyuan Lin <[email protected]> AuthorDate: Wed Aug 26 23:40:28 2026 +0000 test(workflow-operator): close the bar-chart, sentiment and user-agent gaps (#7989) ### What changes were proposed in this PR? Three small workflow-operator gaps, bundled because none is worth a PR alone. 31 tests across the three specs, one of which is new. Measured with two `WorkflowOperator/jacoco` runs, one fresh sbt batch JVM each, `rm -rf` on the jacoco dir between them, and the identical suite-name filter both times. The before run **reproduced all published Codecov percentages exactly** (88.2 / 90.5 / 88.0 / 85.2 / 83.3), which validates the method. | File | Codecov | JaCoCo line-hit | |---|---|---| | `HuggingFaceSentimentAnalysisOpDesc.scala` | 22/25 = 88.0% → **25/25 = 100%** | 100% → 100% | | `BarChartOpDesc.scala` | 30/34 = 88.2% → **32/34 = 94.1%** | 33/34 → **34/34 = 100%** | | `RandomUserAgent.java` | 23/27 = 85.2% → **26/27** | — | **+8 fully-covered lines and 9 branch arms closed.** Worth noting the shape of the sentiment descriptor: its line-hit was *already* 100%, so its entire gain is partial arms flipping to hits — arms covered went 7 → 12, missed 5 → 0. That is precisely the case Codecov penalises and line-hit hides. `HashJoinProbeOpExec` and `OPVersion.java` were in the original scope and are **absent** — neither survived assessment, and padding the bundle with them would have added nothing. ### Verification 22 mutations. **19 non-equivalent mutants, all 19 killed**, each kill re-derived from scratch with the failing test name and assertion line read out of the ScalaTest XML — the sbt log never names them. The first draft reported no survivors. **It shipped with at least seven live semantic survivors**, all seven re-derived here against a hash-verified tree. **Three mutants survived and are recorded as equivalent, deliberately run to prove a point:** a label-map pair reorder, an empty-cell fill-tuple reorder, and a `getOutputSchemas` guard-clause reorder. Each was applied specifically to demonstrate that the new assertions check *containment and identity* rather than incidental ordering — i.e. they are not over-fitted. The guard-clause one is a pure `||` over side-effect-free predicates, and is distinct from two sibling mutants that swap the variable *inside* a clause, both of which die. ### A mechanism I had wrong, corrected here Every brief in this campaign has said that `FileScanSourceOpExecSpec` aborts inside a git worktree because a worktree's `.git` is a file, throwing `RepositoryNotFoundException` in `beforeAll`. **That is wrong.** Run unfiltered, the suite runs its 7 tests successfully and then aborts at *suite* level with: ``` java.nio.file.FileSystemException: ...test_large_binary.txt: The process cannot access the file because it is being used by another process ``` — a Windows file-lock in its own cleanup, because a reader is still open. The `RepositoryNotFoundException` in the same log comes from `OPVersion.<clinit>` → `LogicalOp.getOperatorVersion`, is caught by `OPVersion`'s own try/catch, and has nothing to do with the abort. The exclusion is still the right call for measurement, but the corrected mechanism carries a consequence the wrong one hid: on any unfiltered run that file **is** leaked into the source tree, so it must be deleted before committing or it trips the licence-header check. The earlier claim that "`find` confirms none exists" was true only because that suite was never allowed to run. Module-wide branch totals are not quoted here, because `IntervalJoinOpExec`'s covering spec uses an unseeded RNG whose branch count drifts run to run. ### Deliberately not included `BarChartOpDesc` keeps two partial lines (108 and 114, mb3/cb3 and mb3/cb5), so 32/34 is its ceiling under a test-only change. Both descriptor specs carry a JSON round-trip test per house convention, and no descriptor subclass is defined in any spec — `PythonCodeRawInvalidTextSpec` instantiates every subclass by reflection, so a test-only subtype would break it. The new spec carries the Apache licence header. No production file is touched, and no stray `test_large_binary.txt` was left behind. ### Any related issues, documentation, discussions? Closes #7987 ### How was this PR tested? ``` sbt "WorkflowOperator/testOnly org.apache.texera.amber.operator.visualization.barChart.BarChartOpDescSpec org.apache.texera.amber.operator.huggingFace.HuggingFaceSentimentAnalysisOpDescSpec org.apache.texera.amber.operator.source.fetcher.RandomUserAgentSpec" ``` ``` [info] Total number of tests run: 31 [info] Tests: succeeded 31, failed 0, canceled 0, ignored 0, pending 0 ``` Both full-module runs were green (2365 → 2379 tests, 289 → 290 suites, zero failures), so neither report is the all-zero artifact. `Test/scalafmtCheck` passes. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --------- Signed-off-by: Xinyuan Lin <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]> --- .../HuggingFaceSentimentAnalysisOpDescSpec.scala | 69 +++++++++ .../source/fetcher/RandomUserAgentSpec.scala | 169 +++++++++++++++++++++ .../barChart/BarChartOpDescSpec.scala | 126 ++++++++++++++- 3 files changed, 357 insertions(+), 7 deletions(-) diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceSentimentAnalysisOpDescSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceSentimentAnalysisOpDescSpec.scala index c63cbf67af..65401d5832 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceSentimentAnalysisOpDescSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceSentimentAnalysisOpDescSpec.scala @@ -46,6 +46,10 @@ class HuggingFaceSentimentAnalysisOpDescSpec extends AnyFlatSpec with Matchers { private def carries(output: String, name: String): Boolean = output.contains(b64(name)) + /** The exact runtime decode expression an EncodableString column renders into. */ + private def decodeSite(name: String): String = + s"self.decode_python_template('${b64(name)}')" + private def configured(): HuggingFaceSentimentAnalysisOpDesc = { val d = new HuggingFaceSentimentAnalysisOpDesc d.attribute = "text" @@ -82,6 +86,48 @@ class HuggingFaceSentimentAnalysisOpDescSpec extends AnyFlatSpec with Matchers { d.getOutputSchemas(Map(d.operatorInfo.inputPorts.head.id -> in)) shouldBe null } + it should "return null when a result column is set but blank" in { + // Each of the three guards has a null half and a whitespace half. The whitespace + // half is the one a user actually hits: a cleared-out text box arrives as " ", + // not as null, and a schema whose column name is blank is not a usable schema. + val in = Schema().add("text", AttributeType.STRING) + val blanks = Seq("", " ", "\t") + blanks.foreach { blank => + val d = configured() + d.resultAttributePositive = blank + withClue(s"positive = [$blank]: ") { + d.getOutputSchemas(Map(d.operatorInfo.inputPorts.head.id -> in)) shouldBe null + } + } + } + + it should "return null when only the neutral result column is unset" in { + // Split out per column: a copy-pasted guard that re-tests `resultAttributePositive` + // in the neutral clause passes the "all three unset" case and this one is what + // catches it. + val in = Schema().add("text", AttributeType.STRING) + + val nulled = configured() + nulled.resultAttributeNeutral = null + nulled.getOutputSchemas(Map(nulled.operatorInfo.inputPorts.head.id -> in)) shouldBe null + + val blanked = configured() + blanked.resultAttributeNeutral = " " + blanked.getOutputSchemas(Map(blanked.operatorInfo.inputPorts.head.id -> in)) shouldBe null + } + + it should "return null when only the negative result column is unset" in { + val in = Schema().add("text", AttributeType.STRING) + + val nulled = configured() + nulled.resultAttributeNegative = null + nulled.getOutputSchemas(Map(nulled.operatorInfo.inputPorts.head.id -> in)) shouldBe null + + val blanked = configured() + blanked.resultAttributeNegative = " " + blanked.getOutputSchemas(Map(blanked.operatorInfo.inputPorts.head.id -> in)) shouldBe null + } + it should "append the three sentiment columns as DOUBLE, keyed by the declared output port" in { val d = configured() val in = Schema().add("text", AttributeType.STRING) @@ -105,6 +151,17 @@ class HuggingFaceSentimentAnalysisOpDescSpec extends AnyFlatSpec with Matchers { carries(code, "pos") shouldBe true // EncodableString columns are base64-encoded, not embedded raw. code should not include "\"text\"]" + + // `carries` is a bare substring probe, so it stays true no matter which model label + // a column is attached to. The label -> column map is the whole point of this + // operator: `labels[self.config.id2label[...]]` is what decides whether a negative + // score lands in the negative column, so pin the PAIRING, not just the presence. + val labelsLine = code.linesIterator + .find(_.contains("labels = {")) + .getOrElse(fail("generated code no longer builds the label->column map")) + labelsLine should include("\"positive\": " + decodeSite("pos")) + labelsLine should include("\"neutral\": " + decodeSite("neu")) + labelsLine should include("\"negative\": " + decodeSite("neg")) } it should "guard an empty text cell before it reaches the tokenizer" in { @@ -118,6 +175,18 @@ class HuggingFaceSentimentAnalysisOpDescSpec extends AnyFlatSpec with Matchers { .getOrElse(fail("generated code no longer guards an empty text cell")) guard should include("strip()") code.indexOf("text is None") should be < code.indexOf("self.tokenizer(") + + // getOutputSchemas advertises all three score columns on the output port, so the + // row this path yields has to carry all three keys -- a fill loop that blanks only + // two of them emits a tuple that does not match the schema the operator declared. + val fill = code.linesIterator + .find(_.contains("for label in (")) + .getOrElse(fail("generated code no longer blanks the score columns for an empty cell")) + Seq("pos", "neu", "neg").foreach { column => + withClue(s"$column missing from the empty-cell fill: ") { + fill should include(decodeSite(column)) + } + } } "HuggingFaceSentimentAnalysisOpDesc.getPhysicalOp" should diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/fetcher/RandomUserAgentSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/fetcher/RandomUserAgentSpec.scala new file mode 100644 index 0000000000..c8cdda394b --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/fetcher/RandomUserAgentSpec.scala @@ -0,0 +1,169 @@ +/* + * 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.source.fetcher + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import scala.jdk.CollectionConverters._ + +/** + * `RandomUserAgent` picks a browser by weighted draw over a private static frequency + * table and then picks one of that browser's user-agent strings at random. + * + * The weights are hard-coded and sum to 98.6, not 100, while the draw is over + * `[0, 100)`. Roughly 1.4% of calls therefore exhaust the loop without any bucket + * claiming them, leaving `browser` null and taking the `"Chrome"` fallback. Waiting + * for that tail to show up on its own would make the suite flaky and slow, so the + * tests below swap the private static `freqMap` for a table they control and restore + * the original in a `finally`. That is a process-wide mutation, which is safe here + * only because this module runs its suites strictly serially + * (`Global / concurrentRestrictions += Tags.limit(Tags.Test, 1)` in build.sbt) and + * because the swap never outlives a single test. + * + * Deliberately NOT covered: the class's implicit default constructor. `RandomUserAgent` + * is a static-only utility that nothing instantiates, so `new RandomUserAgent()` would + * assert nothing about its behaviour. + */ +class RandomUserAgentSpec extends AnyFlatSpec with Matchers { + + private def declaredField(name: String): java.lang.reflect.Field = { + val field = classOf[RandomUserAgent].getDeclaredField(name) + field.setAccessible(true) + field + } + + private def uaMap: java.util.Map[String, Array[String]] = + declaredField("uaMap").get(null).asInstanceOf[java.util.Map[String, Array[String]]] + + private def freqMap: java.util.Map[String, java.lang.Double] = + declaredField("freqMap").get(null).asInstanceOf[java.util.Map[String, java.lang.Double]] + + /** + * Runs `body` with the private static frequency table replaced by `weights`, then + * restores the original table. Restoring matters: `URLFetchUtil` draws from the same + * static table on every fetch, so a leaked replacement would starve every later suite + * in this JVM. + */ + private def withFreqTable[T](weights: (String, Double)*)(body: => T): T = { + val field = declaredField("freqMap") + val original = field.get(null) + val replacement = new java.util.HashMap[String, java.lang.Double]() + weights.foreach { + case (browser, weight) => + replacement.put(browser, java.lang.Double.valueOf(weight)) + } + field.set(null, replacement) + try body + finally field.set(null, original) + } + + "RandomUserAgent.getRandomUserAgent" should + "fall back to a Chrome agent when no frequency bucket claims the draw" in { + // `Math.random() * 100` lands in [0, 100) and the only bucket contributes -1.0, so + // `rand <= count` is false for every draw: the loop runs to exhaustion, `browser` + // stays null, and the fallback has to supply a browser that actually has agents. + // This forces the fallback code path deterministically (the production fallback occurs ~1.4% of the time because weights sum to 98.6 < 100). + val chromeAgents = uaMap.get("Chrome").toSet + chromeAgents should not be empty + + withFreqTable("Firefox" -> -1.0) { + (1 to 50).foreach { _ => + chromeAgents should contain(RandomUserAgent.getRandomUserAgent) + } + } + } + + it should "draw from the bucket the frequency table selects" in { + // A single bucket weighted above the draw range claims every draw, so each browser's + // routing through `uaMap` is pinned deterministically rather than sampled. + uaMap.keySet.asScala.toSeq.foreach { browser => + val agents = uaMap.get(browser).toSet + withFreqTable(browser -> 200.0) { + val drawn = (1 to 20).map(_ => RandomUserAgent.getRandomUserAgent).toSet + withClue(s"draw for $browser strayed outside its bucket: ") { + drawn.subsetOf(agents) shouldBe true + } + // Membership alone would also hold for a class that always returned + // `userAgents[0]` -- which is the one thing a *random* user agent must not do, + // since the point of the class is that a scraped host does not see the same + // header on every request. The smallest bucket holds 173 strings, so 20 draws + // collapsing onto one value has probability (1/173)^19: this is sampled rather + // than deterministic, but the flake window is not physically reachable. + withClue(s"$browser always returned the same agent: ") { + drawn.size should be > 1 + } + } + } + } + + it should "return agents from more than one bucket across a weighted table" in { + // Two buckets that split the whole draw range: neither the fallback nor a single + // bucket can satisfy this, so it pins that the weighted walk really advances past + // the first entry instead of always claiming the draw with it. + val firefox = uaMap.get("Firefox").toSet + val opera = uaMap.get("Opera").toSet + firefox.intersect(opera) shouldBe empty + + withFreqTable("Firefox" -> 50.0, "Opera" -> 50.0) { + val drawn = (1 to 400).map(_ => RandomUserAgent.getRandomUserAgent).toSet + drawn.foreach(agent => (firefox ++ opera) should contain(agent)) + drawn.exists(firefox.contains) shouldBe true + drawn.exists(opera.contains) shouldBe true + } + } + + "RandomUserAgent" should "declare a non-empty user-agent bucket for every browser it can draw" in { + // `getRandomUserAgent` dereferences `uaMap.get(browser)` without a null check, so a + // browser carrying a frequency weight but no agent bucket is an NPE waiting for the + // right draw. Same for the "Chrome" fallback, which no frequency weight guards. + (freqMap.keySet.asScala.toSeq :+ "Chrome").foreach { browser => + withClue(s"$browser can be drawn but has no user-agent bucket: ") { + uaMap.get(browser) should not be null + } + withClue(s"$browser has an empty user-agent bucket: ") { + uaMap.get(browser).length should be > 0 + } + } + } + + it should "stock each browser's bucket with that browser's own agents" in { + // The routing tests above read their expected set out of `uaMap` itself, so they + // only ever prove routing is self-consistent: swap two buckets' keys and they all + // still pass, while every request the frequency table meant to look like Firefox + // goes out advertising Opera. This one compares against a token that is NOT derived + // from the map. A strict majority rather than "all", because a handful of entries in + // each bucket are historical strings that omit the product token (e.g. 1 of the 424 + // Firefox agents, 13 of the 213 Safari ones). + Map( + "Internet Explorer" -> "MSIE", + "Firefox" -> "Firefox", + "Chrome" -> "Chrome", + "Safari" -> "Safari", + "Opera" -> "Opera" + ).foreach { + case (browser, token) => + val bucket = uaMap.get(browser) + withClue(s"$browser's bucket does not look like $browser agents: ") { + bucket.count(_.contains(token)) * 2 should be > bucket.length + } + } + } +} 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 4c8afb0d36..7c9e73147d 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 @@ -20,7 +20,9 @@ package org.apache.texera.amber.operator.visualization.barChart import org.apache.texera.amber.core.tuple.AttributeType +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 @@ -36,6 +38,9 @@ class BarChartOpDescSpec extends AnyFlatSpec with BeforeAndAfter with Matchers { opDesc = new BarChartOpDesc() } + private def b64(s: String): String = + Base64.getEncoder.encodeToString(s.getBytes(StandardCharsets.UTF_8)) + it should "throw assertion error if value is empty" in { assertThrows[AssertionError] { opDesc.manipulateTable() @@ -94,20 +99,29 @@ class BarChartOpDescSpec extends AnyFlatSpec with BeforeAndAfter with Matchers { 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"). + it should "name the Value column when only value is unset" in { + // manipulateTable asserts nonEmpty on value AND fields with distinct messages. + // Asserting `include("Value column") or include("Fields")` would hold even if the + // two messages were swapped, i.e. even if a user who left Value blank were told + // "Fields cannot be empty". Each case therefore leaves exactly one field unset and + // pins the one message that belongs to it. + opDesc.fields = "f" + val ex = intercept[AssertionError](opDesc.generatePythonCode()) + ex.getMessage should include("Value column") + ex.getMessage should not include "Fields" + } + + it should "name Fields when only fields is unset" in { + opDesc.value = "v" val ex = intercept[AssertionError](opDesc.generatePythonCode()) - ex.getMessage should (include("Value column") or include("Fields")) + ex.getMessage should include("Fields") + ex.getMessage should not include "Value column" } "BarChartOpDesc.generatePythonCode" should "treat an unset categoryColumn as no category (color guarded to None)" in { @@ -120,4 +134,102 @@ class BarChartOpDescSpec extends AnyFlatSpec with BeforeAndAfter with Matchers { code should not include "color=self.decode_python_template('') if True else None" } + it should "colour-code by the chosen category column" in { + opDesc.value = "score" + opDesc.fields = "name" + opDesc.categoryColumn = "cat" + val code = opDesc.generatePythonCode() + code should include(s"color=self.decode_python_template('${b64("cat")}') if True else None") + } + + it should "treat the literal 'No Selection' as no category" in { + // "No Selection" is the placeholder the UI shows for the optional category column + // (it is this field's declared JSON defaultValue). It is a sentinel, not a column + // name: passing it through to px.bar(color=...) would look up a column that does + // not exist. The non-emptiness check alone does not stop it. + opDesc.value = "score" + opDesc.fields = "name" + opDesc.categoryColumn = "No Selection" + val code = opDesc.generatePythonCode() + code should include( + s"color=self.decode_python_template('${b64("No Selection")}') if False else None" + ) + code should not include s"self.decode_python_template('${b64("No Selection")}') if True" + } + + it should "enable the horizontal branch of the chart only when horizontalOrientation is set" in { + // Both px.bar calls -- the `orientation = 'h'` one and the vertical one -- are + // literal text in every generated program; which one runs is decided by the Python + // guard this Scala flag splices in. So asserting on "orientation = 'h'" alone would + // pass no matter what the flag says, and asserting only on the True/False literal + // would pass even if the two branch bodies were swapped. Pin the ADJACENCY: the + // guard line, the body directly under it, the `else:`, and the else body -- and the + // axis assignment inside each, since a horizontal bar chart must put the category + // on y and the numeric value on x (the vertical one is the mirror image). + opDesc.value = "score" + opDesc.fields = "name" + val valueRef = s"self.decode_python_template('${b64("score")}')" + val fieldsRef = s"self.decode_python_template('${b64("name")}')" + val horizontalCall = s"px.bar(table, y=$fieldsRef, x=$valueRef," + val verticalCall = s"px.bar(table, y=$valueRef, x=$fieldsRef," + + opDesc.horizontalOrientation = true + val horizontal = opDesc.generatePythonCode() + horizontal should not include "if False:" + val hLines = horizontal.linesIterator.toVector + val hGuard = hLines.indexWhere(_.trim == "if True:") + withClue(s"no `if True:` guard line in:\n$horizontal") { hGuard should be >= 0 } + hLines(hGuard + 2).trim shouldBe "else:" + hLines(hGuard + 1) should include(horizontalCall) + hLines(hGuard + 1) should include("orientation = 'h'") + hLines(hGuard + 3) should include(verticalCall) + hLines(hGuard + 3) should not include "orientation = 'h'" + + opDesc.horizontalOrientation = false + val vertical = opDesc.generatePythonCode() + vertical should not include "if True:" + val vLines = vertical.linesIterator.toVector + val vGuard = vLines.indexWhere(_.trim == "if False:") + withClue(s"no `if False:` guard line in:\n$vertical") { vGuard should be >= 0 } + vLines(vGuard + 2).trim shouldBe "else:" + vLines(vGuard + 1) should include(horizontalCall) + vLines(vGuard + 1) should include("orientation = 'h'") + vLines(vGuard + 3) should include(verticalCall) + vLines(vGuard + 3) should not include "orientation = 'h'" + } + + it should "request a pattern shape only when a pattern column is chosen" in { + opDesc.value = "score" + opDesc.fields = "name" + + val withoutPattern = opDesc.generatePythonCode() + withoutPattern should include( + "pattern_shape=self.decode_python_template('') if False else None" + ) + + opDesc.pattern = "texture" + val withPattern = opDesc.generatePythonCode() + withPattern should include( + s"pattern_shape=self.decode_python_template('${b64("texture")}') if True else None" + ) + } + + "BarChartOpDesc" should "round-trip its config fields through the polymorphic base" in { + opDesc.value = "score" + opDesc.fields = "name" + opDesc.categoryColumn = "cat" + opDesc.pattern = "texture" + opDesc.horizontalOrientation = true + + val restored = + objectMapper.readValue(objectMapper.writeValueAsString(opDesc), classOf[LogicalOp]) + restored shouldBe a[BarChartOpDesc] + val b = restored.asInstanceOf[BarChartOpDesc] + b.value shouldBe "score" + b.fields shouldBe "name" + b.categoryColumn shouldBe "cat" + b.pattern shouldBe "texture" + b.horizontalOrientation shouldBe true + } + }
