This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/master by this push:
     new 38a32b402 UNOMI-970: Fix IT ProgressListener ETA using live suite pace 
(#834)
38a32b402 is described below

commit 38a32b402dac75891916cbe68fc61307ef347d27
Author: Serge Huber <[email protected]>
AuthorDate: Tue Jul 21 21:02:31 2026 +0200

    UNOMI-970: Fix IT ProgressListener ETA using live suite pace (#834)
---
 itests/README.md                                   |  10 +-
 .../org/apache/unomi/itests/ProgressListener.java  |  82 ++++-
 .../apache/unomi/itests/ProgressListenerTest.java  | 183 ++++++++++
 .../org/apache/unomi/itests/TestTimingCache.java   | 247 +++++++++++---
 .../apache/unomi/itests/TestTimingCacheTest.java   | 375 ++++++++++++++++++++-
 5 files changed, 818 insertions(+), 79 deletions(-)

diff --git a/itests/README.md b/itests/README.md
index 0d587d2b4..701debae0 100644
--- a/itests/README.md
+++ b/itests/README.md
@@ -311,9 +311,13 @@ the `itests` module directory (survives `mvn clean`):
 `.test-timing-cache-<provider>.properties`
 
 One file per persistence provider (`elasticsearch`, `opensearch`, 
`postgresql`, …)
-so ETAs are not mixed across backends. On later runs the listener sums 
remaining
-historical times and scales them by how fast/slow the current run is vs history
-(clamped). Safe to delete; missing/unwritable cache falls back to in-run 
averages.
+so timings are not mixed across backends. ETA is re-evaluated after every test 
from the
+**live pace of substantive completed tests** (real work, excluding 
near-instant assume/skip-like
+completions and any failed/aborted test, which are never counted towards 
pace); historical
+per-test durations are only hints that reweight remaining work when 
harder/easier tests than
+average are still ahead, and that can additionally raise the ETA if completed 
tests are
+individually running slower than their own cached history. Safe to delete; 
missing/unwritable
+cache falls back to the in-run average.
 
 ### Built-in backends
 
diff --git a/itests/src/test/java/org/apache/unomi/itests/ProgressListener.java 
b/itests/src/test/java/org/apache/unomi/itests/ProgressListener.java
index 1ecdca270..0ac12d0d0 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ProgressListener.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ProgressListener.java
@@ -28,6 +28,7 @@ import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.PriorityQueue;
@@ -46,7 +47,7 @@ import java.util.concurrent.atomic.AtomicInteger;
  *   <li>ASCII art logo display at test suite startup</li>
  *   <li>Real-time progress bar with percentage completion</li>
  *   <li>Colorized output (when ANSI is supported)</li>
- *   <li>Estimated time remaining from a per-persistence-provider historical 
timing cache</li>
+ *   <li>Estimated time remaining from live suite pace, with historical 
timings as hints</li>
  *   <li>Test success/failure counters</li>
  *   <li>Top 10 slowest tests tracking and reporting</li>
  *   <li>Motivational quotes displayed at progress milestones</li>
@@ -99,6 +100,18 @@ public class ProgressListener extends RunListener {
             "Hardships often prepare ordinary people for an extraordinary 
destiny. - C.S. Lewis"
     };
 
+    /**
+     * A single successfully-completed test's duration this run, with its 
historical cached duration
+     * when one exists. Replaces what used to be two separately-mutated 
parallel lists (durations, and
+     * observed-vs-cached pairs) with one sample per completed test, so the 
two views derived from it
+     * (see {@link #estimateRemainingTime}) can never desync from each other.
+     */
+    private record CompletedSample(long durationMs, Long cachedMs) {
+        boolean hasHistoricalMatch() {
+            return cachedMs != null && cachedMs > 0L;
+        }
+    }
+
     /**
      * Inner class representing a test execution time record.
      * Used to track individual test performance for reporting the slowest 
tests.
@@ -144,6 +157,13 @@ public class ProgressListener extends RunListener {
      * timing cache (aborted / assertion failures skew historical ETAs).
      */
     private boolean currentTestFailed;
+    /**
+     * Set in {@link #testAssumptionFailure} before {@link #testFinished}. An 
{@code Assume}-based skip
+     * is not a failure (JUnit does not count it as one — see {@link 
#testAssumptionFailure}), but its
+     * duration must be excluded from the timing cache/live pace the same way 
a hard failure's is, or a
+     * capability-check test that occasionally short-circuits via assume would 
pollute its own history.
+     */
+    private boolean currentTestAssumptionFailed;
     /** Formatter for human-readable timestamps */
     private static final DateTimeFormatter TIMESTAMP_FORMATTER = 
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 
@@ -153,10 +173,8 @@ public class ProgressListener extends RunListener {
     private final Map<String, Long> cachedTimings;
     /** Timing-cache keys for tests not yet completed in this run */
     private final Set<String> remainingTestKeys;
-    /** Durations (ms) of tests completed in this run */
-    private final List<Long> completedDurations = new CopyOnWriteArrayList<>();
-    /** Pairs of [observedMs, cachedMs] for completed tests that had a 
historical entry */
-    private final List<long[]> observedVsCached = new CopyOnWriteArrayList<>();
+    /** Samples for tests completed successfully in this run; see {@link 
CompletedSample}. */
+    private final List<CompletedSample> completedSamples = new 
CopyOnWriteArrayList<>();
 
     /**
      * Creates a new ProgressListener instance.
@@ -288,6 +306,7 @@ public class ProgressListener extends RunListener {
     @Override
     public void testStarted(Description description) {
         currentTestFailed = false;
+        currentTestAssumptionFailed = false;
         startTestTime = System.currentTimeMillis();
         // Print test start boundary with test name
         String testName = extractTestName(description);
@@ -312,7 +331,9 @@ public class ProgressListener extends RunListener {
         long endTestTime = System.currentTimeMillis();
         long testDuration = endTestTime - startTestTime;
         boolean failed = currentTestFailed;
+        boolean skippedByAssumption = currentTestAssumptionFailed;
         currentTestFailed = false;
+        currentTestAssumptionFailed = false;
 
         completedTests.incrementAndGet();
         successfulTests.incrementAndGet(); // Default to success unless a 
failure is recorded separately.
@@ -324,14 +345,11 @@ public class ProgressListener extends RunListener {
         String testKey = TestTimingCache.keyFor(description);
         remainingTestKeys.remove(testKey);
 
-        // Persist only successes: failure/abort durations pollute the 
provider cache and ETA scale.
-        // Write after every successful test (not only at suite end) so Ctrl-C 
/ CI kill keeps progress.
-        if (!failed) {
-            completedDurations.add(testDuration);
-            Long historical = cachedTimings.get(testKey);
-            if (historical != null && historical > 0L) {
-                observedVsCached.add(new long[]{testDuration, historical});
-            }
+        // Persist only substantive successes: a hard failure's or an 
assume-based skip's duration must
+        // not pollute the provider cache/ETA pace. Write after every such 
test (not only at suite end)
+        // so Ctrl-C / CI kill keeps progress.
+        if (!failed && !skippedByAssumption) {
+            completedSamples.add(new CompletedSample(testDuration, 
cachedTimings.get(testKey)));
             TestTimingCache.save(persistenceProvider, 
Collections.singletonMap(testKey, testDuration));
         }
 
@@ -360,6 +378,20 @@ public class ProgressListener extends RunListener {
         displayProgress();
     }
 
+    /**
+     * Called when a test aborts via {@code Assume.assumeTrue}/{@code 
assumeFalse} (before
+     * {@link #testFinished}). JUnit does not treat this as a failure — {@link 
Result#wasSuccessful()}
+     * is unaffected and success/failure counters here are intentionally left 
untouched — but the test's
+     * duration must still be excluded from the timing cache/live pace, or a 
capability-gated test (e.g.
+     * {@code RolloverIT}) that occasionally short-circuits via assume would 
pollute its own history.
+     *
+     * @param failure the assumption-failure information
+     */
+    @Override
+    public void testAssumptionFailure(Failure failure) {
+        currentTestAssumptionFailed = true;
+    }
+
     /**
      * Called when a test fails (before {@link #testFinished}). Marks the test 
so its duration is
      * not written to the timing cache.
@@ -493,14 +525,21 @@ public class ProgressListener extends RunListener {
     }
 
     /**
-     * Estimates remaining time using the provider-specific {@link 
TestTimingCache}, scaled by how
-     * fast/slow this run has been vs history for tests that already completed 
with a cache hit.
+     * Estimates remaining time from the live pace of substantive completed 
tests, using the
+     * provider-specific {@link TestTimingCache} as hints for how heavy the 
remaining tests are.
      *
-     * @param completed the number of tests completed so far
      * @param elapsedTime the time elapsed since the run started, in 
milliseconds
      * @return the estimated remaining time, in milliseconds
      */
-    private long estimateRemainingTime(int completed, long elapsedTime) {
+    private long estimateRemainingTime(long elapsedTime) {
+        List<Long> completedDurations = new 
ArrayList<>(completedSamples.size());
+        List<TestTimingCache.TimingSample> observedVsCached = new 
ArrayList<>();
+        for (CompletedSample sample : completedSamples) {
+            completedDurations.add(sample.durationMs());
+            if (sample.hasHistoricalMatch()) {
+                observedVsCached.add(new 
TestTimingCache.TimingSample(sample.durationMs(), sample.cachedMs()));
+            }
+        }
         return TestTimingCache.estimateRemainingMs(
                 remainingTestKeys,
                 cachedTimings,
@@ -509,6 +548,13 @@ public class ProgressListener extends RunListener {
                 elapsedTime);
     }
 
+    /**
+     * Test-support accessor for the timing-cache keys not yet completed in 
this run.
+     */
+    Set<String> remainingTestKeysSnapshot() {
+        return new HashSet<>(remainingTestKeys);
+    }
+
     /**
      * Displays the current progress of the test run including progress bar,
      * percentage completion, estimated time remaining, and success/failure 
counts.
@@ -518,7 +564,7 @@ public class ProgressListener extends RunListener {
         int completed = completedTests.get();
         long elapsedTime = System.currentTimeMillis() - startTime;
 
-        long estimatedRemainingTime = estimateRemainingTime(completed, 
elapsedTime);
+        long estimatedRemainingTime = estimateRemainingTime(elapsedTime);
         String progressBar = generateProgressBar(((double) completed / 
totalTests) * 100);
         String humanReadableTime = formatTime(estimatedRemainingTime);
 
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/ProgressListenerTest.java 
b/itests/src/test/java/org/apache/unomi/itests/ProgressListenerTest.java
new file mode 100644
index 000000000..ade7b2ce6
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/ProgressListenerTest.java
@@ -0,0 +1,183 @@
+/*
+ * 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.unomi.itests;
+
+import org.apache.unomi.itests.persistence.PersistenceITBackendResolver;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.AssumptionViolatedException;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.Description;
+import org.junit.runner.notification.Failure;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Exercises {@link ProgressListener}'s actual JUnit {@code RunListener} 
callback wiring — as opposed to
+ * {@link TestTimingCacheTest}, which only exercises {@link TestTimingCache}'s 
pure helpers directly with
+ * hand-built inputs. In particular this covers the {@code 
currentTestFailed}/{@code
+ * currentTestAssumptionFailed} flag lifecycle across {@code 
testStarted}/{@code testFailure}/{@code
+ * testAssumptionFailure}/{@code testFinished}, which decides whether a 
completed test's duration reaches
+ * {@link TestTimingCache}.
+ */
+public class ProgressListenerTest {
+
+    /** Comfortably above {@link TestTimingCache#SUBSTANTIVE_OBSERVED_MS} so 
save() doesn't filter it out. */
+    private static final long SUBSTANTIVE_SLEEP_MS = 150L;
+
+    private String previousUserDir;
+    private Path tempDir;
+
+    @Before
+    public void setUp() throws Exception {
+        previousUserDir = System.getProperty("user.dir");
+        tempDir = Files.createTempDirectory("unomi-progress-listener-test");
+        System.setProperty("user.dir", tempDir.toAbsolutePath().toString());
+    }
+
+    @After
+    public void tearDown() {
+        if (previousUserDir != null) {
+            System.setProperty("user.dir", previousUserDir);
+        }
+    }
+
+    private static Description descriptionFor(String methodName) {
+        return Description.createTestDescription(ProgressListenerTest.class, 
methodName);
+    }
+
+    private static ProgressListener newListener(String... testKeys) {
+        return new ProgressListener(testKeys.length, new AtomicInteger(0), 
Arrays.asList(testKeys));
+    }
+
+    private static String provider() {
+        return PersistenceITBackendResolver.resolveProviderId();
+    }
+
+    @Test
+    public void successfulTestPersistsDurationToTimingCache() throws Exception 
{
+        ProgressListener listener = newListener("ProgressListenerTest#ok");
+        Description description = descriptionFor("ok");
+
+        listener.testStarted(description);
+        Thread.sleep(SUBSTANTIVE_SLEEP_MS);
+        listener.testFinished(description);
+
+        Long persisted = 
TestTimingCache.load(provider()).get("ProgressListenerTest#ok");
+        Assert.assertNotNull("a successful test's duration should be persisted 
to the timing cache", persisted);
+        Assert.assertTrue(persisted > 0L);
+    }
+
+    @Test
+    public void failedTestDurationIsNotPersistedToTimingCache() throws 
Exception {
+        ProgressListener listener = 
newListener("ProgressListenerTest#failing");
+        Description description = descriptionFor("failing");
+
+        listener.testStarted(description);
+        Thread.sleep(SUBSTANTIVE_SLEEP_MS);
+        listener.testFailure(new Failure(description, new 
AssertionError("boom")));
+        listener.testFinished(description);
+
+        Assert.assertNull("a failed test's duration must not pollute the 
timing cache",
+                
TestTimingCache.load(provider()).get("ProgressListenerTest#failing"));
+    }
+
+    @Test
+    public void assumptionFailureDurationIsNotPersistedToTimingCache() throws 
Exception {
+        // Regression coverage: ProgressListener must override 
testAssumptionFailure (JUnit's callback
+        // for Assume.assumeTrue/assumeFalse-based skips, e.g. RolloverIT's 
backend-capability gating) —
+        // without it, an assume-skipped test flows through testFinished 
exactly like a success and its
+        // duration would be persisted.
+        ProgressListener listener = 
newListener("ProgressListenerTest#skipped");
+        Description description = descriptionFor("skipped");
+
+        listener.testStarted(description);
+        Thread.sleep(SUBSTANTIVE_SLEEP_MS);
+        listener.testAssumptionFailure(new Failure(description,
+                new AssumptionViolatedException("backend does not support 
this")));
+        listener.testFinished(description);
+
+        Assert.assertNull("an assume-skipped test's duration must not pollute 
the timing cache",
+                
TestTimingCache.load(provider()).get("ProgressListenerTest#skipped"));
+    }
+
+    @Test
+    public void currentTestFlagsResetBetweenTests() throws Exception {
+        // A failure on test #1 must not suppress the timing-cache write for 
test #2.
+        ProgressListener listener = newListener("ProgressListenerTest#first", 
"ProgressListenerTest#second");
+        Description first = descriptionFor("first");
+        Description second = descriptionFor("second");
+
+        listener.testStarted(first);
+        listener.testFailure(new Failure(first, new AssertionError("boom")));
+        listener.testFinished(first);
+
+        listener.testStarted(second);
+        Thread.sleep(SUBSTANTIVE_SLEEP_MS);
+        listener.testFinished(second);
+
+        
Assert.assertNull(TestTimingCache.load(provider()).get("ProgressListenerTest#first"));
+        Assert.assertNotNull("the failed flag must reset so the next test 
persists normally",
+                
TestTimingCache.load(provider()).get("ProgressListenerTest#second"));
+    }
+
+    @Test
+    public void currentTestAssumptionFlagResetsBetweenTests() throws Exception 
{
+        // Same as currentTestFlagsResetBetweenTests, but for the 
assumption-failure flag specifically.
+        ProgressListener listener = 
newListener("ProgressListenerTest#skippedFirst", "ProgressListenerTest#second");
+        Description first = descriptionFor("skippedFirst");
+        Description second = descriptionFor("second");
+
+        listener.testStarted(first);
+        listener.testAssumptionFailure(new Failure(first, new 
AssumptionViolatedException("skip")));
+        listener.testFinished(first);
+
+        listener.testStarted(second);
+        Thread.sleep(SUBSTANTIVE_SLEEP_MS);
+        listener.testFinished(second);
+
+        
Assert.assertNull(TestTimingCache.load(provider()).get("ProgressListenerTest#skippedFirst"));
+        Assert.assertNotNull("the assumption-failed flag must reset so the 
next test persists normally",
+                
TestTimingCache.load(provider()).get("ProgressListenerTest#second"));
+    }
+
+    @Test
+    public void ignoredTestIsRemovedFromRemainingKeys() {
+        ProgressListener listener = 
newListener("ProgressListenerTest#ignoredOne", "ProgressListenerTest#other");
+        listener.testIgnored(descriptionFor("ignoredOne"));
+
+        
Assert.assertFalse(listener.remainingTestKeysSnapshot().contains("ProgressListenerTest#ignoredOne"));
+        
Assert.assertTrue(listener.remainingTestKeysSnapshot().contains("ProgressListenerTest#other"));
+    }
+
+    @Test
+    public void finishedTestIsRemovedFromRemainingKeys() throws Exception {
+        ProgressListener listener = newListener("ProgressListenerTest#done", 
"ProgressListenerTest#other");
+        Description description = descriptionFor("done");
+
+        listener.testStarted(description);
+        Thread.sleep(SUBSTANTIVE_SLEEP_MS);
+        listener.testFinished(description);
+
+        
Assert.assertFalse(listener.remainingTestKeysSnapshot().contains("ProgressListenerTest#done"));
+        
Assert.assertTrue(listener.remainingTestKeysSnapshot().contains("ProgressListenerTest#other"));
+    }
+}
diff --git a/itests/src/test/java/org/apache/unomi/itests/TestTimingCache.java 
b/itests/src/test/java/org/apache/unomi/itests/TestTimingCache.java
index f6e5b7e55..127951db2 100644
--- a/itests/src/test/java/org/apache/unomi/itests/TestTimingCache.java
+++ b/itests/src/test/java/org/apache/unomi/itests/TestTimingCache.java
@@ -56,10 +56,46 @@ final class TestTimingCache {
 
     /**
      * Clamp for the live-run vs historical scale factor so a few outliers 
cannot make ETA absurd.
+     * Used by {@link #computeScale}; {@link #estimateRemainingMs} uses the 
live substantive-test
+     * average as its primary pace signal and only applies this scale as an 
additional boost when
+     * completed tests are individually slower than their cached history.
      */
     static final double MIN_SCALE = 0.25;
     static final double MAX_SCALE = 4.0;
 
+    /**
+     * Observed durations below this are treated as non-representative for 
pace/scale purposes
+     * (assumes / skipped-in-substance tests that return almost instantly).
+     */
+    static final long SUBSTANTIVE_OBSERVED_MS = 100L;
+
+    /**
+     * Ignore a pair in {@link #computeScale} when its cached entry is more 
than this many times
+     * bigger than what was actually observed. This only ever applies to pairs 
that already passed the
+     * {@link #SUBSTANTIVE_OBSERVED_MS} gate (i.e. {@code observed} is not 
itself assume/skip-like) but
+     * are still disproportionately faster than their own cached history — an 
outlier that would
+     * otherwise drag the scale toward {@link #MIN_SCALE}. This is 
intentionally one-directional: a pair
+     * where {@code observed} is much bigger than {@code cached} is a genuine 
regression signal and is
+     * not excluded, so it can still raise the ETA.
+     */
+    static final double MAX_PAIR_SKEW = 20.0;
+
+    /**
+     * Upper bound for the true-cold-start placeholder average in {@link 
#fallbackAverageMs} (no
+     * completed test and no historical cache at all). Growing with elapsed 
time keeps the ETA display
+     * from looking frozen while nothing has finished yet, but must stay 
capped — otherwise a run that
+     * stalls (e.g. several early failures with nothing successful yet) would 
balloon the placeholder,
+     * and therefore the ETA for every remaining test, to an implausibly large 
number.
+     */
+    static final long COLD_START_PLACEHOLDER_CAP_MS = 60_000L;
+
+    /**
+     * An (observed, cached) duration sample for a single completed test that 
had a historical cache
+     * entry, used by {@link #computeScale} to judge live pace vs history for 
individual tests.
+     */
+    record TimingSample(long observedMs, long cachedMs) {
+    }
+
     private TestTimingCache() {
     }
 
@@ -122,7 +158,8 @@ final class TestTimingCache {
             try {
                 timings.put(key, Long.parseLong(props.getProperty(key)));
             } catch (NumberFormatException e) {
-                // Ignore a malformed entry rather than failing the whole 
cache load
+                LOGGER.debug("Ignoring malformed test timing cache entry {}={} 
in {}: {}",
+                        key, props.getProperty(key), cacheFile, 
e.getMessage());
             }
         }
         return timings;
@@ -132,6 +169,10 @@ final class TestTimingCache {
      * Merges freshly observed durations into the persisted cache for the 
given persistence provider,
      * smoothing each updated entry with an exponential moving average so a 
single unusually slow/fast
      * run doesn't swing future ETAs too far.
+     * <p>
+     * Durations below {@link #SUBSTANTIVE_OBSERVED_MS} (assume/skip-like) are 
ignored entirely rather
+     * than blended in — a test that occasionally short-circuits via an early 
assume/skip would
+     * otherwise gradually drag its historical average down toward that 
non-representative value.
      *
      * @param persistenceProvider provider id the run just executed against
      * @param observedTimings durations (in milliseconds) observed during the 
run that just finished
@@ -143,82 +184,163 @@ final class TestTimingCache {
         Path cacheFile = cacheFile(persistenceProvider);
         try {
             Map<String, Long> merged = load(persistenceProvider);
+            boolean changed = false;
             for (Map.Entry<String, Long> entry : observedTimings.entrySet()) {
+                Long observed = entry.getValue();
+                if (observed == null || observed < SUBSTANTIVE_OBSERVED_MS) {
+                    continue;
+                }
                 Long previous = merged.get(entry.getKey());
                 long updated = previous == null
-                        ? entry.getValue()
-                        : Math.round(previous * (1 - SMOOTHING) + 
entry.getValue() * SMOOTHING);
+                        ? observed
+                        : Math.round(previous * (1 - SMOOTHING) + observed * 
SMOOTHING);
                 merged.put(entry.getKey(), updated);
+                changed = true;
+            }
+            if (!changed) {
+                return;
             }
             Properties props = new Properties();
             merged.forEach((key, value) -> props.setProperty(key, 
String.valueOf(value)));
 
             Path parent = cacheFile.toAbsolutePath().getParent();
             Path tempFile = Files.createTempFile(parent, "test-timing-cache", 
".tmp");
-            try (Writer writer = Files.newBufferedWriter(tempFile, 
StandardCharsets.UTF_8)) {
-                props.store(writer, "Apache Unomi IT test timing cache per 
persistence provider "
-                        + "(local dev aid, safe to delete)");
+            try {
+                try (Writer writer = Files.newBufferedWriter(tempFile, 
StandardCharsets.UTF_8)) {
+                    props.store(writer, "Apache Unomi IT test timing cache per 
persistence provider "
+                            + "(local dev aid, safe to delete)");
+                }
+                Files.move(tempFile, cacheFile, 
StandardCopyOption.REPLACE_EXISTING);
+            } finally {
+                // Best-effort cleanup only: after a successful move this is 
already gone, and any
+                // exception here must not mask a real failure from the 
write/move above.
+                try {
+                    Files.deleteIfExists(tempFile);
+                } catch (IOException ignored) {
+                    // Nothing more we can do; the temp file is harmless local 
dev-workspace clutter.
+                }
             }
-            Files.move(tempFile, cacheFile, 
StandardCopyOption.REPLACE_EXISTING);
-        } catch (IOException | RuntimeException e) {
+        } catch (IOException e) {
             LOGGER.debug("Unable to persist test timing cache at {} (ETAs will 
just use the in-run average next time): {}",
                     cacheFile, e.getMessage());
+        } catch (RuntimeException e) {
+            // Distinct from the expected-I/O-failure case above: an 
unexpected exception here means a
+            // real bug in the merge/blend logic, not just a 
read-only/ephemeral workspace.
+            LOGGER.warn("Unexpected error persisting test timing cache at {} 
(ETAs will just use the in-run average next time)",
+                    cacheFile, e);
         }
     }
 
     /**
      * Estimates remaining wall time for unfinished tests.
      * <p>
-     * For each remaining test with a historical entry, uses that duration 
scaled by how fast/slow
-     * <em>this</em> run has been relative to history (ratio of observed vs 
cached for completed
-     * tests that had a cache hit). Uncached remaining tests use the in-run 
average of completed
-     * durations (or the median of historical values when nothing has 
completed yet).
+     * <strong>Live pace is primary:</strong> the pace is the average duration 
of <em>substantive</em>
+     * completed tests this run (real work, excluding near-instant 
assume/skip-like completions — see
+     * {@link #SUBSTANTIVE_OBSERVED_MS}). Historical per-test durations are 
only <em>hints</em> that
+     * reweight remaining work by how each remaining test's historical 
duration compares to the suite's
+     * average historical duration — a block of historically slow tests still 
ahead raises the ETA above
+     * a flat per-test rate, and a tail of historically light tests lowers it 
below that rate.
+     * <p>
+     * Deriving pace from substantive durations only — rather than {@code 
elapsedTimeMs / completed}
+     * over every finished test — avoids two failure modes: (1) the caller 
never adds a failed or
+     * assume-aborted test's duration to {@code completedDurations} (see 
{@link ProgressListener}), so
+     * its wall time cannot inflate the pace the way a naive elapsed/count 
ratio would; (2) a run of fast
+     * assumes/skips before a block of heavy tests cannot drag the pace toward 
zero, since those
+     * near-instant completions are excluded from the average rather than 
counted as "typical" tests.
+     * <p>
+     * A global “this run is 4× faster than cache” scale is <em>not</em> 
applied to shrink remaining
+     * historical time — that is what made ETAs chronically too low after 
early assumes/skips.
+     * If the run is <em>slower</em> than cache on substantive tests, 
remaining historical time is still
+     * raised accordingly via {@link #computeScale}.
+     * <p>
+     * Cold start (no completed test yet, substantive or not) falls back to 
the historical average pace,
+     * so remaining tests are estimated at their full cached weight until real 
live data says otherwise.
      *
      * @param remainingKeys keys still expected to run
      * @param cachedTimings historical durations for this persistence provider
-     * @param observedVsCachedCompleted pairs of (observedMs, cachedMs) for 
completed tests that had history
-     * @param completedDurations all completed durations this run (for 
fallback average)
-     * @param elapsedTimeMs wall time since suite start (unused for sum; kept 
for API clarity)
+     * @param observedVsCachedCompleted samples of (observedMs, cachedMs) for 
completed tests that had history
+     * @param completedDurations successful completed durations this run (for 
live pace and fallback average)
+     * @param elapsedTimeMs wall time since suite start; only its sign is 
used, to detect the cold-start case
      * @return estimated remaining milliseconds (never negative)
      */
     static long estimateRemainingMs(Collection<String> remainingKeys,
                                     Map<String, Long> cachedTimings,
-                                    Collection<long[]> 
observedVsCachedCompleted,
+                                    Collection<TimingSample> 
observedVsCachedCompleted,
                                     Collection<Long> completedDurations,
                                     long elapsedTimeMs) {
-        double scale = computeScale(observedVsCachedCompleted);
+        int remainingCount = remainingKeys == null ? 0 : remainingKeys.size();
+        if (remainingCount == 0) {
+            return 0L;
+        }
+
+        int completedCount = completedDurations == null ? 0 : 
completedDurations.size();
         double fallbackAvg = fallbackAverageMs(completedDurations, 
cachedTimings, elapsedTimeMs);
+        // fallbackAvg is guaranteed > 0 (see fallbackAverageMs), so this is 
always positive too.
+        double globalHintAvg = averagePositive(cachedTimings != null ? 
cachedTimings.values() : null);
+        if (globalHintAvg <= 0.0) {
+            globalHintAvg = fallbackAvg;
+        }
 
-        long estimate = 0L;
+        long hintRemainingMs = 0L;
         for (String key : remainingKeys) {
-            Long cached = cachedTimings.get(key);
+            Long cached = cachedTimings != null ? cachedTimings.get(key) : 
null;
             if (cached != null && cached > 0L) {
-                estimate += Math.round(cached * scale);
+                hintRemainingMs += cached;
             } else {
-                estimate += Math.round(fallbackAvg);
+                hintRemainingMs += Math.round(fallbackAvg);
             }
         }
-        return Math.max(0L, estimate);
+
+        // Cold start: only historical hints (or placeholder average) are 
available.
+        if (completedCount <= 0 || elapsedTimeMs <= 0L) {
+            return Math.max(0L, hintRemainingMs);
+        }
+
+        // Live pace comes only from substantive completions so neither a 
batch of trivial
+        // assume/skip successes nor (by construction — see caller) any 
failed/aborted test's wall
+        // time can skew it; fall back to the historical average until we have 
such a data point.
+        double substantiveAvgMs = averageAtLeast(completedDurations, 
SUBSTANTIVE_OBSERVED_MS);
+        double avgActualMs = substantiveAvgMs > 0.0 ? substantiveAvgMs : 
globalHintAvg;
+
+        // Weight remaining work by how each remaining test's historical 
duration compares to the
+        // suite's average historical duration, then rescale that shape to 
today's live pace:
+        //   predict(r) = avgActual * (hint(r) / globalHintAvg)
+        //   sum        = hintRemaining * avgActual / globalHintAvg
+        // This is genuinely bidirectional: it raises the ETA above a flat 
live-pace rate when the
+        // remaining tests are historically heavier than average, and lowers 
it below that rate when
+        // they're historically lighter — both driven by today's real pace, 
not a historical multiplier.
+        long eta = Math.round(hintRemainingMs * (avgActualMs / globalHintAvg));
+
+        // If substantive tests are slower than history, raise remaining 
toward scaled hints.
+        double robustScale = computeScale(observedVsCachedCompleted);
+        if (robustScale > 1.0) {
+            double shrink = completedCount / (double) (completedCount + 15);
+            double softenedScale = 1.0 + shrink * (robustScale - 1.0);
+            eta = Math.max(eta, Math.round(hintRemainingMs * softenedScale));
+        }
+
+        return Math.max(0L, eta);
     }
 
     /**
      * How fast/slow this run is vs the historical cache for the same provider.
      * {@code 1.0} = on pace; {@code >1} = slower than history; {@code <1} = 
faster.
+     * <p>
+     * Pairs that look like assumes/skips (tiny observed, huge cached) are 
ignored so they do not
+     * drag the scale to {@link #MIN_SCALE}.
      */
-    static double computeScale(Collection<long[]> observedVsCachedCompleted) {
+    static double computeScale(Collection<TimingSample> 
observedVsCachedCompleted) {
         if (observedVsCachedCompleted == null || 
observedVsCachedCompleted.isEmpty()) {
             return 1.0;
         }
         long observedSum = 0L;
         long cachedSum = 0L;
-        for (long[] pair : observedVsCachedCompleted) {
-            if (pair == null || pair.length < 2) {
+        for (TimingSample sample : observedVsCachedCompleted) {
+            if (!isSubstantivePair(sample)) {
                 continue;
             }
-            if (pair[0] > 0L && pair[1] > 0L) {
-                observedSum += pair[0];
-                cachedSum += pair[1];
-            }
+            observedSum += sample.observedMs();
+            cachedSum += sample.cachedMs();
         }
         if (cachedSum <= 0L || observedSum <= 0L) {
             return 1.0;
@@ -233,29 +355,60 @@ final class TestTimingCache {
         return scale;
     }
 
+    /**
+     * {@code true} when the sample is usable for pace scaling (not an 
assume/skip vs huge cache).
+     * See {@link #MAX_PAIR_SKEW} for why this is one-directional.
+     */
+    static boolean isSubstantivePair(TimingSample sample) {
+        if (sample == null) {
+            return false;
+        }
+        long observed = sample.observedMs();
+        long cached = sample.cachedMs();
+        if (observed < SUBSTANTIVE_OBSERVED_MS || cached <= 0L) {
+            return false;
+        }
+        double skew = cached / (double) observed;
+        return skew <= MAX_PAIR_SKEW;
+    }
+
+    private static double averagePositive(Collection<Long> values) {
+        return averageAtLeast(values, 1L);
+    }
+
+    /**
+     * Average of the values that are {@code >= minValue}, ignoring everything 
else (missing,
+     * non-positive, or below the threshold). {@code 0.0} when nothing 
qualifies.
+     */
+    private static double averageAtLeast(Collection<Long> values, long 
minValue) {
+        if (values == null || values.isEmpty()) {
+            return 0.0;
+        }
+        long sum = 0L;
+        int count = 0;
+        for (Long value : values) {
+            if (value != null && value >= minValue) {
+                sum += value;
+                count++;
+            }
+        }
+        return count == 0 ? 0.0 : sum / (double) count;
+    }
+
     private static double fallbackAverageMs(Collection<Long> 
completedDurations,
                                             Map<String, Long> cachedTimings,
                                             long elapsedTimeMs) {
-        if (completedDurations != null && !completedDurations.isEmpty()) {
-            long sum = 0L;
-            for (Long d : completedDurations) {
-                if (d != null && d > 0L) {
-                    sum += d;
-                }
-            }
-            return sum / (double) completedDurations.size();
+        double avg = averagePositive(completedDurations);
+        if (avg > 0.0) {
+            return avg;
         }
-        if (cachedTimings != null && !cachedTimings.isEmpty()) {
-            long sum = 0L;
-            for (Long d : cachedTimings.values()) {
-                if (d != null && d > 0L) {
-                    sum += d;
-                }
-            }
-            return sum / (double) cachedTimings.size();
+        avg = averagePositive(cachedTimings != null ? cachedTimings.values() : 
null);
+        if (avg > 0.0) {
+            return avg;
         }
-        // Cold start: tiny placeholder so ETA is non-zero until the first 
test finishes
-        return elapsedTimeMs > 0L ? elapsedTimeMs : 30_000L;
+        // True cold start (no completions, no cache at all): a placeholder so 
ETA is non-zero and
+        // visibly grows until the first test finishes, capped so a stalled 
start can't balloon it.
+        return elapsedTimeMs > 0L ? Math.min(elapsedTimeMs, 
COLD_START_PLACEHOLDER_CAP_MS) : 30_000L;
     }
 
     static Path cacheFile(String persistenceProvider) {
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/TestTimingCacheTest.java 
b/itests/src/test/java/org/apache/unomi/itests/TestTimingCacheTest.java
index a5030fe19..11a4d48af 100644
--- a/itests/src/test/java/org/apache/unomi/itests/TestTimingCacheTest.java
+++ b/itests/src/test/java/org/apache/unomi/itests/TestTimingCacheTest.java
@@ -75,6 +75,48 @@ public class TestTimingCacheTest {
         Assert.assertTrue(TestTimingCache.load("opensearch").isEmpty());
     }
 
+    @Test
+    public void saveIgnoresSkipLikeDurationForNewKey() {
+        // A brand new key whose only observation so far is a near-instant 
assume/skip must not create
+        // a cache entry at all — persisting it would seed the history with a 
non-representative value.
+        TestTimingCache.save("elasticsearch", 
Collections.singletonMap("SkipIT#skip", 50L));
+        
Assert.assertNull(TestTimingCache.load("elasticsearch").get("SkipIT#skip"));
+    }
+
+    @Test
+    public void saveDoesNotErodeHistoryWithLaterSkipLikeDuration() {
+        // A test that's usually substantial (3000ms) but occasionally 
short-circuits via an assume/skip
+        // (50ms) must keep its real historical average — the skip observation 
must not be blended in.
+        TestTimingCache.save("elasticsearch", 
Collections.singletonMap("FlakySkipIT#test", 3_000L));
+        Assert.assertEquals(Long.valueOf(3_000L), 
TestTimingCache.load("elasticsearch").get("FlakySkipIT#test"));
+
+        TestTimingCache.save("elasticsearch", 
Collections.singletonMap("FlakySkipIT#test", 50L));
+        Assert.assertEquals(Long.valueOf(3_000L), 
TestTimingCache.load("elasticsearch").get("FlakySkipIT#test"));
+    }
+
+    @Test
+    public void saveStillSmoothsSubstantiveDurations() {
+        // Sanity check that the skip-filter didn't disable smoothing for 
genuine observations.
+        TestTimingCache.save("elasticsearch", 
Collections.singletonMap("SmoothedIT#test", 1_000L));
+        TestTimingCache.save("elasticsearch", 
Collections.singletonMap("SmoothedIT#test", 2_000L));
+
+        // updated = 1000*(1-0.3) + 2000*0.3 = 1300
+        Assert.assertEquals(Long.valueOf(1_300L), 
TestTimingCache.load("elasticsearch").get("SmoothedIT#test"));
+    }
+
+    @Test
+    public void saveOnlyPersistsSubstantiveEntriesFromMixedBatch() {
+        Map<String, Long> mixed = new HashMap<>();
+        mixed.put("HeavyIT#real", 5_000L);
+        mixed.put("SkipIT#skip", 10L);
+
+        TestTimingCache.save("elasticsearch", mixed);
+
+        Map<String, Long> loaded = TestTimingCache.load("elasticsearch");
+        Assert.assertEquals(Long.valueOf(5_000L), loaded.get("HeavyIT#real"));
+        Assert.assertNull(loaded.get("SkipIT#skip"));
+    }
+
     @Test
     public void computeScaleDefaultsToOneWithoutPairs() {
         Assert.assertEquals(1.0, 
TestTimingCache.computeScale(Collections.emptyList()), 0.0);
@@ -83,29 +125,239 @@ public class TestTimingCacheTest {
 
     @Test
     public void computeScaleUsesObservedOverCachedRatioAndClamps() {
-        List<long[]> slower = Collections.singletonList(new long[]{2_000L, 
1_000L});
+        List<TestTimingCache.TimingSample> slower =
+                Collections.singletonList(new 
TestTimingCache.TimingSample(2_000L, 1_000L));
         Assert.assertEquals(2.0, TestTimingCache.computeScale(slower), 0.0);
 
-        List<long[]> tooFast = Collections.singletonList(new long[]{10L, 
10_000L});
+        // Substantive but very fast vs cache → clamp to MIN_SCALE (not 
ignored as assume-like)
+        List<TestTimingCache.TimingSample> tooFast =
+                Collections.singletonList(new 
TestTimingCache.TimingSample(300L, 2_000L));
         Assert.assertEquals(TestTimingCache.MIN_SCALE, 
TestTimingCache.computeScale(tooFast), 0.0);
 
-        List<long[]> tooSlow = Collections.singletonList(new long[]{50_000L, 
1_000L});
+        // Substantive slowdown within skew guard → clamp to MAX_SCALE
+        List<TestTimingCache.TimingSample> tooSlow =
+                Collections.singletonList(new 
TestTimingCache.TimingSample(15_000L, 1_000L));
         Assert.assertEquals(TestTimingCache.MAX_SCALE, 
TestTimingCache.computeScale(tooSlow), 0.0);
     }
 
     @Test
-    public void estimateRemainingUsesScaledHistoryAndFallbackAverage() {
+    public void computeScaleIgnoresAssumeLikePairs() {
+        // 10ms observed vs 10s cached looks like a skip — must not drag scale 
to MIN_SCALE.
+        List<TestTimingCache.TimingSample> skipPlusNormal = Arrays.asList(
+                new TestTimingCache.TimingSample(10L, 10_000L),
+                new TestTimingCache.TimingSample(2_000L, 2_000L));
+        Assert.assertEquals(1.0, TestTimingCache.computeScale(skipPlusNormal), 
0.0);
+    }
+
+    @Test
+    public void computeScaleDoesNotIgnoreGenuineRegressions() {
+        // Historically fast (50ms) test now takes 25x longer (1_250ms): a 
real regression, not a
+        // skip/assume artifact, so it must still count towards raising the 
scale (clamped to MAX_SCALE
+        // since 25x exceeds it) rather than being filtered out by the skew 
guard.
+        List<TestTimingCache.TimingSample> regressed =
+                Collections.singletonList(new 
TestTimingCache.TimingSample(1_250L, 50L));
+        Assert.assertEquals(TestTimingCache.MAX_SCALE, 
TestTimingCache.computeScale(regressed), 0.0);
+    }
+
+    @Test
+    public void estimateRemainingUsesLivePaceWithHistoricalHints() {
         Map<String, Long> cached = new HashMap<>();
+        // Suite average historical = (1000+3000+2000)/3 = 2000
         cached.put("A#a", 1_000L);
-        cached.put("B#b", 2_000L);
+        cached.put("B#b", 3_000L);
+        cached.put("C#c", 2_000L);
 
-        Set<String> remaining = new HashSet<>(Arrays.asList("A#a", "C#c"));
-        List<long[]> observedVsCached = Collections.singletonList(new 
long[]{1_500L, 1_000L});
-        List<Long> completed = Collections.singletonList(1_500L);
+        // Completed A in 500ms wall; remaining B (heavier) and C (average).
+        Set<String> remaining = new HashSet<>(Arrays.asList("B#b", "C#c"));
+        List<TestTimingCache.TimingSample> observedVsCached =
+                Collections.singletonList(new 
TestTimingCache.TimingSample(500L, 1_000L));
+        List<Long> completed = Collections.singletonList(500L);
+        long elapsed = 500L;
 
-        // scale = 1.5 → A contributes 1500; C uncached → fallback avg 1500
-        long eta = TestTimingCache.estimateRemainingMs(remaining, cached, 
observedVsCached, completed, 0L);
-        Assert.assertEquals(3_000L, eta);
+        long eta = TestTimingCache.estimateRemainingMs(remaining, cached, 
observedVsCached, completed, elapsed);
+
+        // rateEta = 2 * 500 = 1000
+        // hintShaped = (3000+2000) * (500/2000) = 1250 → heavier remaining 
raises ETA
+        Assert.assertEquals(1_250L, eta);
+    }
+
+    @Test
+    public void 
estimateRemainingLowersBelowFlatRateWhenRemainingIsHistoricallyLighter() {
+        // Suite average historical = (3000+500+500)/3 = 1333.33; remaining B 
and C are both historically
+        // lighter than that average, so hint-shaped reweighting must lower 
the ETA below the flat
+        // live-pace rate (remainingCount * avgActual = 2 * 1000 = 2000), not 
just floor it there.
+        Map<String, Long> cached = new HashMap<>();
+        cached.put("A#a", 3_000L);
+        cached.put("B#b", 500L);
+        cached.put("C#c", 500L);
+
+        Set<String> remaining = new HashSet<>(Arrays.asList("B#b", "C#c"));
+        List<TestTimingCache.TimingSample> observedVsCached =
+                Collections.singletonList(new 
TestTimingCache.TimingSample(1_000L, 3_000L));
+        List<Long> completed = Collections.singletonList(1_000L);
+        long elapsed = 1_000L;
+
+        long eta = TestTimingCache.estimateRemainingMs(remaining, cached, 
observedVsCached, completed, elapsed);
+
+        // hintRemaining = 500+500 = 1000; eta = 1000 * (1000/1333.33) = 750, 
well below the flat-rate 2000.
+        Assert.assertEquals(750L, eta);
+        long flatRateEta = Math.round(remaining.size() * 1_000.0);
+        Assert.assertTrue("hint-shaped ETA must be able to go below the flat 
live-pace rate",
+                eta < flatRateEta);
+    }
+
+    @Test
+    public void 
estimateRemainingTracksActualDurationAverageNotElapsedOverCount() {
+        // Elapsed wall time (300s) deliberately does NOT match completedCount 
* avg duration (50*4s=200s)
+        // — e.g. time lost to setup/teardown between tests. A naive 
elapsedTimeMs/completedCount pace
+        // (300_000/50 = 6_000ms/test) would overestimate the remaining 250 
tests at 1_500_000ms; live
+        // pace must instead come from the actual completed-test durations 
(avg 4_000ms/test).
+        Map<String, Long> cached = new HashMap<>();
+        for (int i = 0; i < 50; i++) {
+            cached.put("DoneIT#t" + i, 5_000L);
+        }
+        for (int i = 0; i < 250; i++) {
+            cached.put("TodoIT#t" + i, 5_000L);
+        }
+
+        Set<String> remaining = new HashSet<>();
+        for (int i = 0; i < 250; i++) {
+            remaining.add("TodoIT#t" + i);
+        }
+
+        List<TestTimingCache.TimingSample> observedVsCached = new 
java.util.ArrayList<>();
+        List<Long> completed = new java.util.ArrayList<>();
+        for (int i = 0; i < 50; i++) {
+            observedVsCached.add(new TestTimingCache.TimingSample(4_000L, 
5_000L));
+            completed.add(4_000L);
+        }
+        long elapsed = 300_000L;
+
+        long eta = TestTimingCache.estimateRemainingMs(remaining, cached, 
observedVsCached, completed, elapsed);
+        // avgActual = 4_000 (from completedDurations, not elapsed/count); 
globalHintAvg = 5_000;
+        // hintRemaining = 250*5_000 = 1_250_000; eta = 1_250_000 * 
(4_000/5_000) = 1_000_000ms (~16.7m)
+        Assert.assertEquals(1_000_000L, eta);
+
+        // The naive elapsed/completedCount pace (6_000ms/test) would have 
produced 1_500_000ms instead.
+        long naiveElapsedOverCountEta = Math.round(250 * (elapsed / (double) 
completed.size()));
+        Assert.assertNotEquals(naiveElapsedOverCountEta, eta);
+    }
+
+    @Test
+    public void estimateRemainingUnaffectedByFailedTestWallTime() {
+        // ProgressListener never adds a failed/aborted test's duration to 
completedDurations (see
+        // testFinished), but the suite-wide elapsed clock keeps advancing 
regardless of outcome.
+        // The ETA must be driven by the one substantive success, not by 
elapsed/completedCount
+        // (which would have been 50_200 / 1 = 50_200ms/test — a ~250x 
inflation).
+        Map<String, Long> cached = new HashMap<>();
+        cached.put("FlakyIT#a", 200L);
+        cached.put("FlakyIT#b", 200L);
+
+        Set<String> remaining = new 
HashSet<>(Collections.singletonList("FlakyIT#b"));
+        List<Long> completed = Collections.singletonList(200L);
+        long elapsedIncludingFailures = 50_200L; // 10 failed tests @ 5s each 
+ the 200ms success
+
+        long eta = TestTimingCache.estimateRemainingMs(
+                remaining, cached, Collections.emptyList(), completed, 
elapsedIncludingFailures);
+
+        Assert.assertEquals(200L, eta);
+    }
+
+    @Test
+    public void estimateRemainingIgnoresAssumeDilutionInLivePace() {
+        // 50 assume-like tests complete in ~50ms each (below 
SUBSTANTIVE_OBSERVED_MS) before any of
+        // the 250 historically-heavy tests have run. The old 
elapsed/completedCount pace would have
+        // been dragged down to ~50ms/test, collapsing the ETA for the heavy 
tests still ahead.
+        Map<String, Long> cached = new HashMap<>();
+        for (int i = 0; i < 50; i++) {
+            cached.put("SkipIT#t" + i, 50L);
+        }
+        for (int i = 0; i < 250; i++) {
+            cached.put("HeavyIT#t" + i, 5_000L);
+        }
+
+        Set<String> remaining = new HashSet<>();
+        for (int i = 0; i < 250; i++) {
+            remaining.add("HeavyIT#t" + i);
+        }
+
+        List<Long> completed = new java.util.ArrayList<>();
+        for (int i = 0; i < 50; i++) {
+            completed.add(50L);
+        }
+        long elapsed = 50L * 50L;
+
+        long eta = TestTimingCache.estimateRemainingMs(
+                remaining, cached, Collections.emptyList(), completed, 
elapsed);
+
+        // No substantive completions yet → live pace falls back to the 
historical average, so the
+        // heavy remaining tests are estimated at their full cached weight 
(250 * 5_000 = 1_250_000),
+        // not diluted down toward the ~50ms/test pace of the skips seen so 
far.
+        Assert.assertEquals(1_250_000L, eta);
+    }
+
+    @Test
+    public void 
estimateRemainingAppliesSlowdownBoostWhenSubstantiveScaleExceedsOne() {
+        // A completed on the same key family as remaining R, but 4x slower 
than its own cache entry —
+        // a genuine per-test regression that should raise the ETA above the 
plain hint-shaped estimate.
+        Map<String, Long> cached = new HashMap<>();
+        cached.put("A#a", 50L);
+        cached.put("R#r", 1_000L);
+
+        Set<String> remaining = new 
HashSet<>(Collections.singletonList("R#r"));
+        List<TestTimingCache.TimingSample> observedVsCached =
+                Collections.singletonList(new 
TestTimingCache.TimingSample(200L, 50L));
+        List<Long> completed = Collections.singletonList(200L);
+        long elapsed = 200L;
+
+        long eta = TestTimingCache.estimateRemainingMs(remaining, cached, 
observedVsCached, completed, elapsed);
+
+        // Un-boosted hint-shaped estimate: 1000 * (200/525) ≈ 381
+        // robustScale = 200/50 = 4.0 (MAX_SCALE); shrink = 1/(1+15); 
softenedScale = 1 + shrink*3 = 1.1875
+        // boosted estimate: 1000 * 1.1875 = 1187.5 → 1188, which wins over 
the un-boosted 381
+        Assert.assertEquals(1_188L, eta);
+    }
+
+    @Test
+    public void 
estimateRemainingSlowdownBoostRampApproachesRobustScaleAtLargeCompletedCount() {
+        // At a small completedCount the shrink ramp 
(completedCount/(completedCount+15)) heavily damps
+        // the slowdown boost; at a large completedCount it should approach 
the raw (interior, unclamped)
+        // robustScale instead of staying suppressed — exercising the ramp 
away from the completedCount=1
+        // case covered by 
estimateRemainingAppliesSlowdownBoostWhenSubstantiveScaleExceedsOne, and away
+        // from computeScale's own MIN_SCALE/MAX_SCALE clamp boundaries.
+        Map<String, Long> cached = new HashMap<>();
+        cached.put("A#a", 100L);
+        cached.put("R#r", 1_000L);
+
+        Set<String> remaining = new 
HashSet<>(Collections.singletonList("R#r"));
+        List<TestTimingCache.TimingSample> observedVsCached = new 
java.util.ArrayList<>();
+        List<Long> completed = new java.util.ArrayList<>();
+        for (int i = 0; i < 100; i++) {
+            observedVsCached.add(new TestTimingCache.TimingSample(200L, 100L));
+            completed.add(200L);
+        }
+        long elapsed = 20_000L;
+
+        long eta = TestTimingCache.estimateRemainingMs(remaining, cached, 
observedVsCached, completed, elapsed);
+
+        // robustScale = 200/100 = 2.0 (interior, not clamped); shrink = 
100/115 ≈ 0.8696;
+        // softenedScale = 1 + 0.8696*(2.0-1.0) ≈ 1.8696; boosted = 
1000*1.8696 ≈ 1870, which wins over
+        // the un-boosted hint-shaped estimate (1000 * 200/550 ≈ 364).
+        Assert.assertEquals(1_870L, eta);
+    }
+
+    @Test
+    public void estimateRemainingCapsColdStartPlaceholderForStalledStart() {
+        // No completions yet and no historical cache at all (e.g. the very 
first-ever run stalls before
+        // its first success) — the per-remaining-test placeholder must not 
grow unbounded with elapsed
+        // time; it should cap at COLD_START_PLACEHOLDER_CAP_MS rather than 
ballooning towards hours.
+        Set<String> remaining = new HashSet<>(Arrays.asList("X#x", "Y#y", 
"Z#z"));
+        long stalledElapsed = 500_000L; // 8+ minutes with nothing completed 
and no cache
+
+        long eta = TestTimingCache.estimateRemainingMs(
+                remaining, Collections.emptyMap(), Collections.emptyList(), 
Collections.emptyList(), stalledElapsed);
+
+        Assert.assertEquals(3 * TestTimingCache.COLD_START_PLACEHOLDER_CAP_MS, 
eta);
     }
 
     @Test
@@ -120,4 +372,105 @@ public class TestTimingCacheTest {
         // avg of history = 2000
         Assert.assertEquals(2_000L, eta);
     }
+
+    @Test
+    public void estimateRemainingReturnsZeroForEmptyOrNullRemainingKeys() {
+        Map<String, Long> cached = Collections.singletonMap("A#a", 1_000L);
+        List<Long> completed = Collections.singletonList(500L);
+
+        Assert.assertEquals(0L, TestTimingCache.estimateRemainingMs(
+                Collections.emptySet(), cached, Collections.emptyList(), 
completed, 500L));
+        Assert.assertEquals(0L, TestTimingCache.estimateRemainingMs(
+                null, cached, Collections.emptyList(), completed, 500L));
+    }
+
+    @Test
+    public void estimateRemainingHandlesNullCachedTimingsGracefully() {
+        // No historical cache at all (e.g. first-ever run, or an unreadable 
cache file) — must not NPE
+        // and should fall back entirely to the in-run average.
+        Set<String> remaining = new 
HashSet<>(Collections.singletonList("X#x"));
+        List<Long> completed = Collections.singletonList(500L);
+
+        long eta = TestTimingCache.estimateRemainingMs(
+                remaining, null, Collections.emptyList(), completed, 500L);
+
+        // fallbackAvg = avg(completed) = 500; no cache to weigh against, so 
live pace and hint-shaped
+        // estimate both resolve to the plain average.
+        Assert.assertEquals(500L, eta);
+    }
+
+    @Test
+    public void estimateRemainingUsesFallbackAverageForUncachedRemainingKey() {
+        // "Cached#x" has a direct historical entry; "Uncached#y" does not and 
must fall back to the
+        // average of the historical cache (not 0, and not just re-using 
Cached#x's own value).
+        Map<String, Long> cached = new HashMap<>();
+        cached.put("Cached#x", 1_000L);
+        cached.put("Other#unrelated", 3_000L);
+
+        Set<String> remaining = new HashSet<>(Arrays.asList("Cached#x", 
"Uncached#y"));
+        long eta = TestTimingCache.estimateRemainingMs(
+                remaining, cached, Collections.emptyList(), 
Collections.emptyList(), 0L);
+
+        // Cold start (nothing completed): hintRemainingMs = 
cached("Cached#x")=1000
+        //                                                  + fallbackAvg(avg 
of cache = 2000) = 3000
+        Assert.assertEquals(3_000L, eta);
+    }
+
+    @Test
+    public void estimateRemainingTreatsNonPositiveElapsedAsColdStart() {
+        // A negative/zero elapsed reading (e.g. clock oddity) must be treated 
like cold start rather
+        // than feeding a nonsensical value into the live-pace division.
+        Map<String, Long> cached = Collections.singletonMap("X#x", 1_000L);
+        Set<String> remaining = new 
HashSet<>(Collections.singletonList("X#x"));
+        List<Long> completed = Collections.singletonList(500L);
+
+        long eta = TestTimingCache.estimateRemainingMs(remaining, cached, 
Collections.emptyList(), completed, -100L);
+        Assert.assertEquals(1_000L, eta);
+    }
+
+    @Test
+    public void 
estimateRemainingIgnoresSkipDurationWhenAveragingSubstantiveCompletions() {
+        // A mix of one assume-like completion (50ms) and one substantive 
completion (3000ms) must
+        // average pace from the substantive one only (3000), not the diluted 
blended average (1525).
+        Map<String, Long> cached = Collections.singletonMap("R#r", 1_000L);
+        Set<String> remaining = new 
HashSet<>(Collections.singletonList("R#r"));
+        List<Long> completed = Arrays.asList(50L, 3_000L);
+
+        long eta = TestTimingCache.estimateRemainingMs(
+                remaining, cached, Collections.emptyList(), completed, 3_050L);
+
+        Assert.assertEquals(3_000L, eta);
+    }
+
+    @Test
+    public void isSubstantivePairBoundaryConditions() {
+        Assert.assertFalse(TestTimingCache.isSubstantivePair(null));
+
+        // Observed below SUBSTANTIVE_OBSERVED_MS → excluded regardless of 
cached.
+        Assert.assertFalse(TestTimingCache.isSubstantivePair(new 
TestTimingCache.TimingSample(99L, 1_000L)));
+
+        // Observed exactly at the threshold → substantive (strict "<" check, 
not "<=").
+        Assert.assertTrue(TestTimingCache.isSubstantivePair(new 
TestTimingCache.TimingSample(100L, 100L)));
+
+        // Non-positive cached → excluded regardless of observed.
+        Assert.assertFalse(TestTimingCache.isSubstantivePair(new 
TestTimingCache.TimingSample(1_000L, 0L)));
+
+        // Skew exactly at MAX_PAIR_SKEW → still substantive ("<=" boundary is 
inclusive).
+        Assert.assertTrue(TestTimingCache.isSubstantivePair(new 
TestTimingCache.TimingSample(100L, 2_000L)));
+
+        // Skew just past MAX_PAIR_SKEW → excluded as assume/skip-like.
+        Assert.assertFalse(TestTimingCache.isSubstantivePair(new 
TestTimingCache.TimingSample(100L, 2_001L)));
+
+        // observed >> cached (a genuine regression, the opposite direction) 
has no upper bound and is
+        // never excluded — this is the one-directional behavior the skew 
guard is meant to have.
+        Assert.assertTrue(TestTimingCache.isSubstantivePair(new 
TestTimingCache.TimingSample(1_000_000L, 1L)));
+    }
+
+    @Test
+    public void computeScaleToleratesNullSampleInCollection() {
+        List<TestTimingCache.TimingSample> withNull = Arrays.asList(
+                null,
+                new TestTimingCache.TimingSample(1_000L, 1_000L));
+        Assert.assertEquals(1.0, TestTimingCache.computeScale(withNull), 0.0);
+    }
 }

Reply via email to