|
| 1 | +classdef TestTagInvalidateBatch < matlab.unittest.TestCase |
| 2 | + %TESTTAGINVALIDATEBATCH Phase 1028 post-merge — direct unit coverage of Tag.invalidateBatch_. |
| 3 | + % |
| 4 | + % Plan 05 introduced `Tag.invalidateBatch_(tagSet)` (libs/SensorThreshold/Tag.m |
| 5 | + % lines 190-314) as the internal seam used by `LiveTagPipeline.onTick_` to |
| 6 | + % coalesce per-tag invalidation cascades at end-of-tick. The PR #114 |
| 7 | + % codecov report (deferred-items.md) flagged the method at 63.6% patch |
| 8 | + % coverage; the existing `TestListenerCoalesceOrdering` exercised the |
| 9 | + % cascade end-to-end (chain of Sensor → Monitor → Composite tags) but |
| 10 | + % did not target the dispatch logic directly. |
| 11 | + % |
| 12 | + % This suite adds direct unit coverage of the dispatch contract: |
| 13 | + % |
| 14 | + % 1. Empty cell input — `invalidateBatch_({})` is a no-op. |
| 15 | + % 2. Empty numeric input — `invalidateBatch_([])` is a no-op |
| 16 | + % via the same isempty guard. |
| 17 | + % 3. Single-tag dispatch — one tag with one listener → |
| 18 | + % listener.invalidate() called exactly once. |
| 19 | + % 4. Multi-tag dispatch — three tags each with its own listener → |
| 20 | + % each listener invalidated exactly once. |
| 21 | + % 5. Shared-listener deduplication — two tags share one listener |
| 22 | + % handle; listener invalidated exactly once (not twice). |
| 23 | + % 6. Mixed Tag-kind batch — SensorTag + StateTag in the same |
| 24 | + % tagSet; each kind's listener cell is walked correctly. |
| 25 | + % 7. Invalid input — non-cell `tagSet` raises `Tag:invalidBatchInput`. |
| 26 | + % 8. Listener-error propagation — a listener that throws |
| 27 | + % surfaces its error (no swallow); earlier listeners were |
| 28 | + % processed; later listeners are skipped (documented |
| 29 | + % non-fault-tolerance per the source-level walker contract). |
| 30 | + % |
| 31 | + % The mock listener types `CountingListener` and `ThrowingListener` |
| 32 | + % implement the minimal observer contract (ismethod(m, 'invalidate')) |
| 33 | + % required by SensorTag.addListener. |
| 34 | + % |
| 35 | + % See also Tag.invalidateBatch_, TestListenerCoalesceOrdering, |
| 36 | + % CountingListener, ThrowingListener. |
| 37 | + |
| 38 | + methods (TestClassSetup) |
| 39 | + function addPaths(testCase) %#ok<MANU> |
| 40 | + here = fileparts(mfilename('fullpath')); |
| 41 | + addpath(fullfile(here, '..', '..')); |
| 42 | + install(); |
| 43 | + end |
| 44 | + end |
| 45 | + |
| 46 | + methods (TestMethodSetup) |
| 47 | + function clearRegistry(testCase) %#ok<MANU> |
| 48 | + %CLEARREGISTRY Reset TagRegistry singleton before each test. |
| 49 | + TagRegistry.clear(); |
| 50 | + end |
| 51 | + end |
| 52 | + |
| 53 | + methods (TestMethodTeardown) |
| 54 | + function clearRegistryAfter(testCase) %#ok<MANU> |
| 55 | + %CLEARREGISTRYAFTER Reset TagRegistry singleton after each test. |
| 56 | + TagRegistry.clear(); |
| 57 | + end |
| 58 | + end |
| 59 | + |
| 60 | + methods (Test) |
| 61 | + |
| 62 | + function testEmptyTagSetIsNoOp(testCase) |
| 63 | + %TESTEMPTYTAGSETISNOOP Verify `invalidateBatch_({})` returns silently. |
| 64 | + testCase.verifyWarningFree(@() Tag.invalidateBatch_({})); |
| 65 | + end |
| 66 | + |
| 67 | + function testEmptyNumericInputIsNoOp(testCase) |
| 68 | + %TESTEMPTYNUMERICINPUTISNOOP `invalidateBatch_([])` is tolerated. |
| 69 | + % The empty-input guard (line 231) short-circuits before |
| 70 | + % the ~iscell check (line 234) so any empty value is a |
| 71 | + % safe no-op regardless of type. This protects callers |
| 72 | + % from accidental empty-numeric arrays in dynamic paths. |
| 73 | + testCase.verifyWarningFree(@() Tag.invalidateBatch_([])); |
| 74 | + end |
| 75 | + |
| 76 | + function testSingleTagSingleListenerDispatchedOnce(testCase) |
| 77 | + %TESTSINGLETAGSINGLELISTENERDISPATCHEDONCE One tag, one listener → 1 call. |
| 78 | + s = SensorTag('s_single', 'X', 1:10, 'Y', sin(1:10)); |
| 79 | + l = CountingListener(); |
| 80 | + s.addListener(l); |
| 81 | + |
| 82 | + Tag.invalidateBatch_({s}); |
| 83 | + |
| 84 | + testCase.verifyEqual(l.Count, 1, ... |
| 85 | + 'Listener.invalidate must be called exactly once for single-tag batch.'); |
| 86 | + end |
| 87 | + |
| 88 | + function testMultiTagEachListenerDispatchedOnce(testCase) |
| 89 | + %TESTMULTITAGEACHLISTENERDISPATCHEDONCE Three tags, three listeners → 1 call each. |
| 90 | + % Each tag has its own distinct listener handle. The batch |
| 91 | + % walker must visit each (tag, listener) pair exactly once. |
| 92 | + sA = SensorTag('s_multi_A', 'X', 1:5, 'Y', sin(1:5)); |
| 93 | + sB = SensorTag('s_multi_B', 'X', 1:5, 'Y', sin(1:5)); |
| 94 | + sC = SensorTag('s_multi_C', 'X', 1:5, 'Y', sin(1:5)); |
| 95 | + |
| 96 | + lA = CountingListener(); |
| 97 | + lB = CountingListener(); |
| 98 | + lC = CountingListener(); |
| 99 | + |
| 100 | + sA.addListener(lA); |
| 101 | + sB.addListener(lB); |
| 102 | + sC.addListener(lC); |
| 103 | + |
| 104 | + Tag.invalidateBatch_({sA, sB, sC}); |
| 105 | + |
| 106 | + testCase.verifyEqual(lA.Count, 1, 'lA must be invalidated exactly once.'); |
| 107 | + testCase.verifyEqual(lB.Count, 1, 'lB must be invalidated exactly once.'); |
| 108 | + testCase.verifyEqual(lC.Count, 1, 'lC must be invalidated exactly once.'); |
| 109 | + end |
| 110 | + |
| 111 | + function testSharedListenerAcrossTagsDedupedToOneCall(testCase) |
| 112 | + %TESTSHAREDLISTENERACROSSTAGSDEDUPEDTOONECALL Listener shared by two tags → 1 call. |
| 113 | + % When a single listener handle is registered with two |
| 114 | + % different tags AND both tags are in the batch, the |
| 115 | + % walker must dedupe by handle identity and invoke |
| 116 | + % invalidate() exactly once. This is the core coalescing |
| 117 | + % guarantee. |
| 118 | + % |
| 119 | + % This is the MonitorTag-with-multiple-Sensor-parents |
| 120 | + % pattern (e.g., a monitor that observes two sensors). |
| 121 | + sA = SensorTag('s_shared_A', 'X', 1:5, 'Y', sin(1:5)); |
| 122 | + sB = SensorTag('s_shared_B', 'X', 1:5, 'Y', sin(1:5)); |
| 123 | + |
| 124 | + shared = CountingListener(); |
| 125 | + sA.addListener(shared); |
| 126 | + sB.addListener(shared); |
| 127 | + |
| 128 | + Tag.invalidateBatch_({sA, sB}); |
| 129 | + |
| 130 | + testCase.verifyEqual(shared.Count, 1, ... |
| 131 | + 'Shared listener handle must be invalidated exactly once across the batch.'); |
| 132 | + end |
| 133 | + |
| 134 | + function testMixedTagKindsBatch(testCase) |
| 135 | + %TESTMIXEDTAGKINDSBATCH SensorTag + StateTag in same batch — both walked. |
| 136 | + % Tag.invalidateBatch_ must call getListeners_ on every |
| 137 | + % Tag subclass. Verify it works for at least two distinct |
| 138 | + % concrete kinds in the same call. |
| 139 | + s = SensorTag('s_mixed', 'X', 1:5, 'Y', sin(1:5)); |
| 140 | + st = StateTag('st_mixed', 'X', 1:5, 'Y', ones(1, 5)); |
| 141 | + |
| 142 | + lSensor = CountingListener(); |
| 143 | + lState = CountingListener(); |
| 144 | + s.addListener(lSensor); |
| 145 | + st.addListener(lState); |
| 146 | + |
| 147 | + Tag.invalidateBatch_({s, st}); |
| 148 | + |
| 149 | + testCase.verifyEqual(lSensor.Count, 1, 'SensorTag listener invalidated once.'); |
| 150 | + testCase.verifyEqual(lState.Count, 1, 'StateTag listener invalidated once.'); |
| 151 | + end |
| 152 | + |
| 153 | + function testNonCellInputRaisesInvalidBatchInput(testCase) |
| 154 | + %TESTNONCELLINPUTRAISESINVALIDBATCHINPUT Wrong-type input is rejected. |
| 155 | + % The walker validates `iscell(tagSet)` after the |
| 156 | + % empty-input early-return. A non-empty non-cell input |
| 157 | + % must raise `Tag:invalidBatchInput`. We pass a numeric |
| 158 | + % array (non-empty) to bypass the isempty short-circuit. |
| 159 | + testCase.verifyError(@() Tag.invalidateBatch_([1, 2, 3]), ... |
| 160 | + 'Tag:invalidBatchInput'); |
| 161 | + end |
| 162 | + |
| 163 | + function testListenerErrorPropagatesAndAborts(testCase) |
| 164 | + %TESTLISTENERERRORPROPAGATESANDABORTS Documented non-fault-tolerance. |
| 165 | + % The walker (Tag.m lines 304-313) does NOT wrap each |
| 166 | + % listener invalidate() in try/catch — it iterates and |
| 167 | + % calls. The documented contract is therefore: |
| 168 | + % - If a listener throws, the error propagates out. |
| 169 | + % - Listeners that appeared earlier in the unique-list |
| 170 | + % have already been processed. |
| 171 | + % - Listeners later in the unique-list are SKIPPED. |
| 172 | + % |
| 173 | + % This test pins that contract. If a future refactor adds |
| 174 | + % fault-tolerance (try/catch around each invalidate), this |
| 175 | + % test will fail and force a deliberate update to the |
| 176 | + % contract documentation. |
| 177 | + % |
| 178 | + % Order strategy: SensorTag stores listeners_ as a cell |
| 179 | + % (append-on-addListener). The walker preserves |
| 180 | + % listeners_ order while deduping. Listener registration |
| 181 | + % order: [counting, throwing, never] — so we expect |
| 182 | + % counting to be invalidated, throwing to fire the error, |
| 183 | + % and `never` to NOT be invoked. |
| 184 | + s = SensorTag('s_err', 'X', 1:5, 'Y', sin(1:5)); |
| 185 | + counting = CountingListener(); |
| 186 | + throwing = ThrowingListener(); |
| 187 | + never = CountingListener(); |
| 188 | + |
| 189 | + s.addListener(counting); |
| 190 | + s.addListener(throwing); |
| 191 | + s.addListener(never); |
| 192 | + |
| 193 | + % The throwing listener's error must surface. |
| 194 | + testCase.verifyError(@() Tag.invalidateBatch_({s}), ... |
| 195 | + 'ThrowingListener:intentional'); |
| 196 | + |
| 197 | + % Pre-throw listener was processed; post-throw listener was not. |
| 198 | + testCase.verifyEqual(counting.Count, 1, ... |
| 199 | + 'Listener BEFORE the throwing one must have been invalidated.'); |
| 200 | + testCase.verifyEqual(never.Count, 0, ... |
| 201 | + 'Listener AFTER the throwing one must NOT have been invalidated (loop aborts).'); |
| 202 | + end |
| 203 | + |
| 204 | + end |
| 205 | +end |
0 commit comments