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 fcfd112fbc test(workflow-operator): pin the filter predicate's 
comparison arms (#7883)
fcfd112fbc is described below

commit fcfd112fbc8fd8c3aa59d77003debc881da0392f
Author: Xinyuan Lin <[email protected]>
AuthorDate: Sun Aug 23 08:07:40 2026 +0000

    test(workflow-operator): pin the filter predicate's comparison arms (#7883)
    
    ### What changes were proposed in this PR?
    
    Two operator-evaluation files whose gaps were entirely branch-arm
    partials on already-executed lines. 33 tests → 44.
    
    Measured with a full-module `WorkflowOperator/jacoco`, one fresh sbt JVM
    per run, `rm -rf` on the jacoco dir between them, and the same
    suite-name exclusion on both sides — `FileScanSourceOpExecSpec` aborts
    in this worktree on a pre-existing Windows file lock, and since
    sbt-jacoco runs unforked a failing test task yields *no report directory
    at all*, not an all-zero one.
    
    | File | Codecov | Branch arms |
    |---|---|---|
    | `DictionaryMatcherOpExec.scala` | 44/50 = 88.0% → **49/50 = 98.0%** |
    8 missed → **1** |
    | `FilterPredicate.java` | 54/60 = 90.0% → **56/60 = 93.3%** | 6 missed
    → **3** |
    | **bundle** | 98/110 = 89.1% → **105/110 = 95.5%** | +10 arms |
    
    JaCoCo line-hit was already 100% on the Scala file and 59/60 on the Java
    one — every gap was a partial arm on a line that already executed, which
    is exactly the case Codecov penalises and line-hit hides.
    
    **This is +7 lines, and I would rather say so than dress it up.** What
    earns the PR is the behaviour underneath.
    
    ### The behaviour was much less pinned than the percentages suggested
    
    Two findings that are corrections to my own first draft, both material:
    
    - **`FilterPredicate`'s comparison switch was barely constrained.** The
    first draft called the file "essentially exhausted" — true of coverage,
    false of behaviour. Four of its six comparison arms could be swapped for
    a neighbouring arm and stay green against all 2314 module tests. Those
    are now pinned.
    - **`URL_STOP_WORDS_SET` had no proof it did anything.** The first draft
    claimed its new test was "the first proof anywhere in the repo that that
    list does anything at all". That was true of the single entry `"www"`
    and nothing else — truncating the whole list to `List("www")` survived.
    The test is now a loop that pins every entry.
    
    ### Verification
    
    26 mutations re-derived from scratch, **25 killed by a named test with
    its exact failure message, 1 equivalent mutant.**
    
    The first draft reported no survivors. **Eleven semantic mutants were
    alive on it** — five on `FilterPredicate`'s comparison switch, five on
    `DictionaryMatcherOpExec` (comma delimiter, field case-folding, null
    handling), and one that had slipped past both stop-word tests. All
    eleven now die, each re-credited to the test that kills it.
    
    The one mutant left alive drops a field from `hashCode`; killing it
    would require asserting a specific hash value, so it is recorded as
    equivalent rather than counted as a survivor.
    
    ### Corrections, including to a reviewer
    
    - A reviewer suggested an operand-swap variant
    (`Objects.equals(attribute, that.value)`) as a survivor for these
    fixtures. **Wrong** — I ran it, and it is killed at HEAD by two
    pre-existing tests, because the attribute name and the compared value
    are distinguishable in those fixtures.
    - The first draft's fallback estimate ("drop that one test and the
    bundle falls to +6 lines / +8 arms") was off by one arm; the real
    contribution of that test is 1 line and 3 arms.
    - Its module-wide branch figure was off by one: the baseline is 1214 →
    1203, of which −10 comes from these two files and −1 from an unrelated
    file drifting.
    - Its claim that two lines "report as fully covered despite
    never-executed instructions" was true of one line, at baseline only.
    - A judgement call flagged in the first draft no longer applies: the
    test no longer pins `intercept[MatchError]`, so it does not cement
    scalac's exception choice as the misconfiguration contract.
    
    ### A landmine first recorded, then removed after review
    
    The first draft *documented* an order-dependence in a NOTE instead of
    fixing it, and a review comment was right that removing it was the
    better call. `5974ffa` removes it.
    
    `DictionaryMatcherOpExecSpec`'s `opExec` is a shared `var`, and the
    pre-existing "close properly" test asserted `tokenizedDictionaryEntries
    == null` on whatever the *previous* test left behind. That held only
    because the preceding test used `SUBSTRING`: `close()` clears
    `tokenizedDictionaryEntries` but never nulls it — only
    `dictionaryEntries` and `luceneAnalyzer` are nulled. So inserting any
    `CONJUNCTION_INDEXBASED` test above it broke it, and three of the new
    tests are `CONJUNCTION`.
    
    `close properly` now builds its own `SCANBASED` executor. One deviation
    from the review suggestion: it does not assert "null **or** empty",
    because a disjunction over the two outcomes pins neither. Each arm of
    `close()`'s guard is instead pinned exactly, by a test owning its own
    executor:
    
    | Arm | `tokenizedDictionaryEntries` after `close()` | Pinned by |
    |---|---|---|
    | `SCANBASED` — buffer never allocated | `null`, this path's own
    contract | `close properly` |
    | `CONJUNCTION_INDEXBASED` — buffer allocated | cleared, **not** nulled
    | "empty the tokenized dictionary on close" (added here) |
    
    `close properly` also closes twice, keeping the idempotence the old
    ordering exercised by accident. The suite is now order-independent and
    the NOTE is gone.
    
    Verified in both directions rather than asserted: inserting a
    `CONJUNCTION_INDEXBASED` test directly above `close properly` — the edit
    the NOTE forbade — fails at `abd61dc` with `ListBuffer() did not equal
    null`, and passes at `5974ffa`.
    
    Coverage is unchanged by that commit: `DictionaryMatcherOpExec` stays at
    49/50 lines and 1 missed arm of 26, measured with the same scoped
    `WorkflowOperator/jacoco` on both commits, so the table above still
    holds. Test counts are unchanged too — it modifies a test rather than
    adding one.
    
    ### Deliberately not included
    
    `DictionaryMatcherOpExec` line 62 keeps one structurally unreachable
    arm, so 49/50 is its ceiling.
    
    `FilterPredicate` lines 66, 80, 130 and 144 stay open and all four are
    dead: 66 and 80 would need reflection into the private static
    `evaluateFilter` or a production seam, and 130/144 are null ternaries
    the caller already guards. Widening any of them would be a production
    edit — refused.
    
    No production file is touched. Both files are md5-identical to the
    pre-mutation snapshots, and no stray `test_large_binary.txt` was left
    behind.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7882
    
    ### How was this PR tested?
    
    ```
    sbt "WorkflowOperator/testOnly 
org.apache.texera.amber.operator.dictionary.DictionaryMatcherOpExecSpec 
org.apache.texera.amber.operator.filter.FilterPredicateSpec"
    ```
    
    ```
    [info] Total number of tests run: 44
    [info] Tests: succeeded 44, failed 0, canceled 0, ignored 0, pending 0
    ```
    
    Test counts read from the JUnit XML `tests=` attribute rather than
    counted by eye: `DictionaryMatcherOpExecSpec` 11 → 20,
    `FilterPredicateSpec` 22 → 24. `Test/scalafmtCheck` passes.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
---
 .../dictionary/DictionaryMatcherOpExecSpec.scala   | 143 +++++++++++++++++++++
 .../operator/filter/FilterPredicateSpec.scala      |  44 +++++++
 2 files changed, 187 insertions(+)

diff --git 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/dictionary/DictionaryMatcherOpExecSpec.scala
 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/dictionary/DictionaryMatcherOpExecSpec.scala
index 20a10c9ef5..8f71c54a36 100644
--- 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/dictionary/DictionaryMatcherOpExecSpec.scala
+++ 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/dictionary/DictionaryMatcherOpExecSpec.scala
@@ -195,9 +195,152 @@ class DictionaryMatcherOpExecSpec extends AnyFlatSpec 
with BeforeAndAfter {
   }
 
   it should "close properly" in {
+    opDesc.matchingType = MatchingType.SCANBASED
+    opExec = new 
DictionaryMatcherOpExec(objectMapper.writeValueAsString(opDesc))
+    opExec.open()
     opExec.close()
     assert(opExec.dictionaryEntries == null)
+    // SCANBASED never allocates the tokenized buffer, so null here is this 
path's own
+    // contract rather than a leftover from whichever test ran last. close()'s 
other
+    // arm -- the buffer exists and is cleared, not nulled -- is pinned by 
"empty the
+    // tokenized dictionary on close" below.
     assert(opExec.tokenizedDictionaryEntries == null)
     assert(opExec.luceneAnalyzer == null)
+    // Idempotent: a second close() re-enters with all three fields already at 
their
+    // post-close values and must not throw.
+    opExec.close()
+    assert(opExec.dictionaryEntries == null)
+  }
+
+  private def tupleWith(field1: String): Tuple =
+    Tuple
+      .builder(tupleSchema)
+      .add(new Attribute("field1", AttributeType.STRING), field1)
+      .add(new Attribute("field2", AttributeType.INTEGER), 1)
+      .add(new Attribute("field3", AttributeType.BOOLEAN), true)
+      .build()
+
+  private def isMatched(inputTuple: Tuple): Boolean =
+    opExec
+      .processTuple(inputTuple, 0)
+      .next()
+      .asInstanceOf[SchemaEnforceable]
+      .enforceSchema(outputSchema)
+      .getField[Boolean]("matched")
+
+  it should "not label an empty field as matched even though every entry 
contains the empty string" in {
+    opDesc.dictionary = dictionaryScan
+    opDesc.matchingType = MatchingType.SUBSTRING
+    opExec = new 
DictionaryMatcherOpExec(objectMapper.writeValueAsString(opDesc))
+    opExec.open()
+    // Without the empty-text guard, SUBSTRING would ask whether "nice a a 
person"
+    // contains "" -- which every string does -- and report a match.
+    assert(!isMatched(tupleWith("")))
+    opExec.close()
+  }
+
+  it should "not match under CONJUNCTION_INDEXBASED when the field tokenizes 
to nothing" in {
+    opDesc.dictionary = "the"
+    opDesc.matchingType = MatchingType.CONJUNCTION_INDEXBASED
+    opExec = new 
DictionaryMatcherOpExec(objectMapper.writeValueAsString(opDesc))
+    opExec.open()
+    // "the" is an English stop word, so both the entry and the field tokenize 
to
+    // the empty set, and the empty set is trivially a subset of itself. Only 
the
+    // explicit non-emptiness check keeps this from being reported as a match.
+    assert(!isMatched(tupleWith("the")))
+    opExec.close()
+  }
+
+  it should "ignore dictionary tokens that stem into an English stop word" in {
+    opDesc.dictionary = "nice willing person"
+    opDesc.matchingType = MatchingType.CONJUNCTION_INDEXBASED
+    opExec = new 
DictionaryMatcherOpExec(objectMapper.writeValueAsString(opDesc))
+    opExec.open()
+    // The analyzer stems "willing" to "will", which is an English stop word, 
so the
+    // entry reduces to {nice, person} and is still a subset of the field's
+    // {person, nice}. The field deliberately reverses the two words so that 
plain
+    // substring containment ("nice willing person" contains "person nice") is
+    // false: only the tokenized conjunction path can satisfy this assertion.
+    assert(isMatched(tupleWith("person nice")))
+    opExec.close()
+  }
+
+  it should "ignore dictionary tokens that are URL stop words" in {
+    // Each of these survives the English analyzer unchanged and is not an 
English
+    // stop word, so each is dropped only by the operator's own URL list. 
"https"
+    // is deliberately absent: the Porter stemmer rewrites it to "http", so 
that
+    // entry can never fire (recorded as dead data, not pinned). As above, the
+    // field reverses the word order so substring containment cannot satisfy 
it.
+    for (urlWord <- List("http", "org", "net", "com", "store", "www", "html")) 
{
+      opDesc.dictionary = s"nice $urlWord person"
+      opDesc.matchingType = MatchingType.CONJUNCTION_INDEXBASED
+      opExec = new 
DictionaryMatcherOpExec(objectMapper.writeValueAsString(opDesc))
+      opExec.open()
+      withClue(s"URL stop word $urlWord: ")(assert(isMatched(tupleWith("person 
nice"))))
+      opExec.close()
+    }
+  }
+
+  it should "split the dictionary on commas" in {
+    opDesc.dictionary = "cat,dog"
+    opDesc.matchingType = MatchingType.SCANBASED
+    opExec = new 
DictionaryMatcherOpExec(objectMapper.writeValueAsString(opDesc))
+    opExec.open()
+    // Two entries, not one: "dog" is an entry on its own and the raw text is 
not.
+    assert(isMatched(tupleWith("dog")))
+    assert(!isMatched(tupleWith("cat,dog")))
+    opExec.close()
+  }
+
+  it should "lower-case the tuple field before comparing it to the dictionary" 
in {
+    opDesc.dictionary = dictionaryScan
+    opDesc.matchingType = MatchingType.SCANBASED
+    opExec = new 
DictionaryMatcherOpExec(objectMapper.writeValueAsString(opDesc))
+    opExec.open()
+    // Dictionary entries are lower-cased when they are split, so the field 
has to
+    // be folded too or an upper-case field could never match a lower-case 
entry.
+    assert(isMatched(tupleWith(dictionaryScan.toUpperCase)))
+    opExec.close()
+  }
+
+  it should "report a null field as unmatched instead of failing" in {
+    opDesc.dictionary = dictionaryScan
+    opDesc.matchingType = MatchingType.SCANBASED
+    opExec = new 
DictionaryMatcherOpExec(objectMapper.writeValueAsString(opDesc))
+    opExec.open()
+    // The dictionary lookup casts the field to String and lower-cases it, so 
a null
+    // field has to be filtered out before the lookup is reached.
+    assert(!isMatched(tupleWith(null)))
+    opExec.close()
+  }
+
+  it should "empty the tokenized dictionary on close" in {
+    opDesc.dictionary = "nice person"
+    opDesc.matchingType = MatchingType.CONJUNCTION_INDEXBASED
+    opExec = new 
DictionaryMatcherOpExec(objectMapper.writeValueAsString(opDesc))
+    opExec.open()
+    assert(opExec.tokenizedDictionaryEntries.nonEmpty)
+    opExec.close()
+    // Asserts emptiness only, never null: close() nulls the other two fields 
but
+    // merely clears this one, and this test takes no side on that asymmetry.
+    assert(opExec.tokenizedDictionaryEntries.isEmpty)
+  }
+
+  it should "fail loudly rather than silently report no match when no matching 
type is configured" in {
+    opDesc.dictionary = dictionaryScan
+    opDesc.matchingType = null
+    opExec = new 
DictionaryMatcherOpExec(objectMapper.writeValueAsString(opDesc))
+    opExec.open()
+    // A descriptor whose "Matching type" is absent from the JSON deserializes 
to a
+    // null matchingType -- Jackson does not enforce `required` on read -- so 
this is
+    // a reachable runtime state. What is pinned is only that the operator 
refuses
+    // it instead of labelling every tuple "not matched". The exception's 
identity
+    // is scalac's business (today a MatchError from the non-exhaustive match) 
and
+    // is deliberately not named, so replacing it with an explicit, clearer 
throw
+    // would not break this test.
+    intercept[RuntimeException] {
+      opExec.processTuple(tuple, 0).next()
+    }
+    opExec.close()
   }
 }
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 e683c7b4b5..0bb9a3a475 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
@@ -210,4 +210,48 @@ class FilterPredicateSpec extends AnyFlatSpec with 
Matchers {
         singleFieldTuple(AttributeType.TIMESTAMP, 
Timestamp.valueOf("2020-01-01 00:00:00"))
       ) shouldBe true
   }
+
+  "FilterPredicate.evaluate" should "pin every ordering operator on both sides 
of its boundary" in {
+    // Every other assertion in this spec places its operands on only one side 
of
+    // the operator's boundary: the two `_OR_EQUAL_TO` arms are only ever asked
+    // about EQUAL operands (where `== 0` answers the same) and the two strict 
arms
+    // only about strictly-ordered ones (where the non-strict twin answers the
+    // same). Each operator below is therefore asked all three questions -- 
field
+    // less than, equal to, and greater than the value -- so that no arm of the
+    // comparison switch can be swapped for another and stay green.
+    def evaluates(condition: ComparisonType, age: Int): Boolean =
+      new FilterPredicate("age", condition, "18").evaluate(ageTuple(age))
+
+    evaluates(ComparisonType.EQUAL_TO, 10) shouldBe false
+    evaluates(ComparisonType.EQUAL_TO, 18) shouldBe true
+    evaluates(ComparisonType.EQUAL_TO, 30) shouldBe false
+
+    evaluates(ComparisonType.NOT_EQUAL_TO, 10) shouldBe true
+    evaluates(ComparisonType.NOT_EQUAL_TO, 18) shouldBe false
+    evaluates(ComparisonType.NOT_EQUAL_TO, 30) shouldBe true
+
+    evaluates(ComparisonType.GREATER_THAN, 10) shouldBe false
+    evaluates(ComparisonType.GREATER_THAN, 18) shouldBe false
+    evaluates(ComparisonType.GREATER_THAN, 30) shouldBe true
+
+    evaluates(ComparisonType.GREATER_THAN_OR_EQUAL_TO, 10) shouldBe false
+    evaluates(ComparisonType.GREATER_THAN_OR_EQUAL_TO, 18) shouldBe true
+    evaluates(ComparisonType.GREATER_THAN_OR_EQUAL_TO, 30) shouldBe true
+
+    evaluates(ComparisonType.LESS_THAN, 10) shouldBe true
+    evaluates(ComparisonType.LESS_THAN, 18) shouldBe false
+    evaluates(ComparisonType.LESS_THAN, 30) shouldBe false
+
+    evaluates(ComparisonType.LESS_THAN_OR_EQUAL_TO, 10) shouldBe true
+    evaluates(ComparisonType.LESS_THAN_OR_EQUAL_TO, 18) shouldBe true
+    evaluates(ComparisonType.LESS_THAN_OR_EQUAL_TO, 30) shouldBe false
+  }
+
+  "FilterPredicate.equals" should "distinguish predicates that differ only in 
attribute, or only in condition" in {
+    val base = new FilterPredicate("age", ComparisonType.EQUAL_TO, "1")
+    val otherAttribute = new FilterPredicate("name", ComparisonType.EQUAL_TO, 
"1")
+    val otherCondition = new FilterPredicate("age", 
ComparisonType.GREATER_THAN, "1")
+    base should not be otherAttribute
+    base should not be otherCondition
+  }
 }

Reply via email to