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
+  }
+
 }

Reply via email to