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
The following commit(s) were added to refs/heads/main by this push:
new 2b8138118d test(workflow-operator): close the bar-chart, sentiment and
user-agent gaps (#7989)
2b8138118d is described below
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
+ }
+
}