diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageRecordResult.java b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageRecordResult.java new file mode 100644 index 00000000..3c43392c --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageRecordResult.java @@ -0,0 +1,20 @@ +package dev.hylfrd.farmhelper.feature.usage; + +/** Observable outcome of one usage lifecycle transition or tick. */ +public enum UsageRecordResult { + STARTED, + RESUMED, + PAUSED, + STOPPED, + COUNTED, + IDLE, + NO_TIME, + ALREADY_ACTIVE, + ALREADY_PAUSED, + GAP_REJECTED, + CLOCK_REGRESSION, + INVALID_TIME, + INVALID_EVENT, + OVERFLOW, + INVALID +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageSnapshot.java b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageSnapshot.java new file mode 100644 index 00000000..f109ba3a --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageSnapshot.java @@ -0,0 +1,96 @@ +package dev.hylfrd.farmhelper.feature.usage; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** Immutable local usage view captured at one explicit monotonic reading. */ +public record UsageSnapshot( + UsageState state, + Status status, + long sessionNanos, + long last24HoursNanos, + long last7DaysNanos, + long last30DaysNanos, + long lifetimeNanos, + int retainedEventCount, + boolean eventHistoryLimited +) { + public enum Status { + VALID, + INVALID_TIME, + CLOCK_REGRESSION, + OVERFLOW + } + + public static final UsageSnapshot ZERO = new UsageSnapshot( + UsageState.STOPPED, + Status.VALID, + 0L, + 0L, + 0L, + 0L, + 0L, + 0, + false); + + public UsageSnapshot { + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(status, "status"); + if (sessionNanos < 0L + || last24HoursNanos < 0L + || last7DaysNanos < 0L + || last30DaysNanos < 0L + || lifetimeNanos < 0L) { + throw new IllegalArgumentException("usage durations must be non-negative"); + } + if (retainedEventCount < 0) { + throw new IllegalArgumentException("retainedEventCount must be non-negative"); + } + if (state == UsageState.INVALID && status == Status.VALID) { + throw new IllegalArgumentException("invalid state requires an invalid status"); + } + } + + public static UsageSnapshot invalid(UsageState state, Status status) { + if (status == Status.VALID) { + throw new IllegalArgumentException("invalid snapshot requires an invalid status"); + } + return new UsageSnapshot(state, status, 0L, 0L, 0L, 0L, 0L, 0, false); + } + + public boolean valid() { + return state != UsageState.INVALID && status == Status.VALID; + } + + public long todayNanos() { + return last24HoursNanos; + } + + public long totalNanos() { + return lifetimeNanos; + } + + public long sessionMillis() { + return nanosToMillis(sessionNanos); + } + + public long todayMillis() { + return nanosToMillis(last24HoursNanos); + } + + public long last7DaysMillis() { + return nanosToMillis(last7DaysNanos); + } + + public long last30DaysMillis() { + return nanosToMillis(last30DaysNanos); + } + + public long totalMillis() { + return nanosToMillis(lifetimeNanos); + } + + private static long nanosToMillis(long nanos) { + return nanos / TimeUnit.MILLISECONDS.toNanos(1L); + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageState.java b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageState.java new file mode 100644 index 00000000..346be769 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageState.java @@ -0,0 +1,9 @@ +package dev.hylfrd.farmhelper.feature.usage; + +/** Lifecycle state of the local usage accounting domain. */ +public enum UsageState { + STOPPED, + RUNNING, + PAUSED, + INVALID +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageStatsTracker.java b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageStatsTracker.java new file mode 100644 index 00000000..54555887 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageStatsTracker.java @@ -0,0 +1,317 @@ +package dev.hylfrd.farmhelper.feature.usage; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.concurrent.TimeUnit; + +/** + * Pure, client-thread usage accounting for explicit macro-active tick evidence. + * + *

Every accepted active interval is retained in a bounded journal for rolling views. Lifetime + * and current-session totals are aggregate counters, so journal eviction does not change them. + * A journal-capacity flag makes any incomplete rolling view visible to callers.

+ */ +public final class UsageStatsTracker { + public static final long DAY_NANOS = TimeUnit.DAYS.toNanos(1L); + public static final long LAST_24_HOURS_NANOS = DAY_NANOS; + public static final long LAST_7_DAYS_NANOS = TimeUnit.DAYS.toNanos(7L); + public static final long LAST_30_DAYS_NANOS = TimeUnit.DAYS.toNanos(30L); + public static final long DEFAULT_MAX_OBSERVATION_GAP_NANOS = TimeUnit.SECONDS.toNanos(5L); + public static final int DEFAULT_MAX_RETAINED_EVENTS = 4_096; + + private static final long MAX_WINDOW_NANOS = LAST_30_DAYS_NANOS; + private static final long UNSET = Long.MIN_VALUE; + + private final long maxObservationGapNanos; + private final int maxRetainedEvents; + private final Deque intervals = new ArrayDeque<>(); + + private UsageState state = UsageState.STOPPED; + private long lastNowNanos = UNSET; + private long sessionNanos; + private long lifetimeNanos; + private long eventHistoryLimitedUntilNanos = UNSET; + + public UsageStatsTracker() { + this(DEFAULT_MAX_OBSERVATION_GAP_NANOS, DEFAULT_MAX_RETAINED_EVENTS); + } + + public UsageStatsTracker(long maxObservationGapNanos, int maxRetainedEvents) { + if (maxObservationGapNanos <= 0L) { + throw new IllegalArgumentException("maxObservationGapNanos must be positive"); + } + if (maxRetainedEvents <= 0) { + throw new IllegalArgumentException("maxRetainedEvents must be positive"); + } + this.maxObservationGapNanos = maxObservationGapNanos; + this.maxRetainedEvents = maxRetainedEvents; + } + + public UsageState state() { + return state; + } + + public long maxObservationGapNanos() { + return maxObservationGapNanos; + } + + public int maxRetainedEvents() { + return maxRetainedEvents; + } + + public int retainedEventCount() { + return intervals.size(); + } + + /** Starts a fresh session while preserving local lifetime and rolling evidence. */ + public UsageRecordResult start(long nowNanos) { + if (nowNanos < 0L) { + return UsageRecordResult.INVALID_TIME; + } + if (state == UsageState.INVALID) { + return UsageRecordResult.INVALID; + } + if (state != UsageState.STOPPED) { + return UsageRecordResult.ALREADY_ACTIVE; + } + if (lastNowNanos != UNSET && nowNanos < lastNowNanos) { + return UsageRecordResult.CLOCK_REGRESSION; + } + state = UsageState.RUNNING; + sessionNanos = 0L; + lastNowNanos = nowNanos; + return UsageRecordResult.STARTED; + } + + /** Pauses at an explicit boundary, counting only the bounded active interval before it. */ + public UsageRecordResult pause(long nowNanos) { + if (nowNanos < 0L) { + return UsageRecordResult.INVALID_TIME; + } + if (state == UsageState.INVALID) { + return UsageRecordResult.INVALID; + } + if (state == UsageState.STOPPED) { + return UsageRecordResult.STOPPED; + } + if (state == UsageState.PAUSED) { + return UsageRecordResult.ALREADY_PAUSED; + } + if (nowNanos < lastNowNanos) { + return UsageRecordResult.CLOCK_REGRESSION; + } + + UsageRecordResult boundary = countActiveUntil(nowNanos); + if (boundary == UsageRecordResult.OVERFLOW) { + return boundary; + } + state = UsageState.PAUSED; + return boundary == UsageRecordResult.GAP_REJECTED + ? boundary + : UsageRecordResult.PAUSED; + } + + /** Resumes after excluding the entire explicit paused interval. */ + public UsageRecordResult resume(long nowNanos) { + if (nowNanos < 0L) { + return UsageRecordResult.INVALID_TIME; + } + if (state == UsageState.INVALID) { + return UsageRecordResult.INVALID; + } + if (state == UsageState.STOPPED) { + return UsageRecordResult.STOPPED; + } + if (state == UsageState.RUNNING) { + return UsageRecordResult.ALREADY_ACTIVE; + } + if (nowNanos < lastNowNanos) { + return UsageRecordResult.CLOCK_REGRESSION; + } + lastNowNanos = nowNanos; + state = UsageState.RUNNING; + return UsageRecordResult.RESUMED; + } + + /** Stops the current session; a final tick must be supplied explicitly before this call. */ + public UsageRecordResult stop() { + if (state == UsageState.INVALID) { + return UsageRecordResult.INVALID; + } + if (state == UsageState.STOPPED) { + return UsageRecordResult.STOPPED; + } + state = UsageState.STOPPED; + return UsageRecordResult.STOPPED; + } + + /** Clears all local history, aggregate totals, and lifecycle state. */ + public void reset() { + intervals.clear(); + state = UsageState.STOPPED; + lastNowNanos = UNSET; + sessionNanos = 0L; + lifetimeNanos = 0L; + eventHistoryLimitedUntilNanos = UNSET; + } + + /** Records one explicit tick without introducing any client or identity data. */ + public UsageRecordResult record(UsageTick tick) { + if (tick == null) { + return UsageRecordResult.INVALID_EVENT; + } + return record(tick.macroActive(), tick.nowNanos()); + } + + /** + * Records one tick. An inactive tick advances the evidence baseline but never adds usage; + * active gaps at least as large as {@link #maxObservationGapNanos} are discarded + * conservatively. + */ + public UsageRecordResult record(boolean macroActive, long nowNanos) { + if (nowNanos < 0L) { + return UsageRecordResult.INVALID_TIME; + } + if (state == UsageState.INVALID) { + return UsageRecordResult.INVALID; + } + if (state == UsageState.STOPPED) { + return UsageRecordResult.STOPPED; + } + if (nowNanos < lastNowNanos) { + return UsageRecordResult.CLOCK_REGRESSION; + } + if (state == UsageState.PAUSED) { + lastNowNanos = nowNanos; + return UsageRecordResult.PAUSED; + } + if (!macroActive) { + lastNowNanos = nowNanos; + return UsageRecordResult.IDLE; + } + return countActiveUntil(nowNanos); + } + + /** Captures stable rolling and aggregate values at one explicit monotonic reading. */ + public UsageSnapshot snapshot(long nowNanos) { + if (nowNanos < 0L) { + return UsageSnapshot.invalid(state, UsageSnapshot.Status.INVALID_TIME); + } + if (state == UsageState.INVALID) { + return UsageSnapshot.invalid(state, UsageSnapshot.Status.OVERFLOW); + } + if (lastNowNanos != UNSET && nowNanos < lastNowNanos) { + return UsageSnapshot.invalid(state, UsageSnapshot.Status.CLOCK_REGRESSION); + } + if (state != UsageState.STOPPED) { + lastNowNanos = nowNanos; + } + + prune(nowNanos); + WindowSum last24Hours = sumWindow(nowNanos, LAST_24_HOURS_NANOS); + WindowSum last7Days = sumWindow(nowNanos, LAST_7_DAYS_NANOS); + WindowSum last30Days = sumWindow(nowNanos, LAST_30_DAYS_NANOS); + if (last24Hours.overflowed || last7Days.overflowed || last30Days.overflowed) { + state = UsageState.INVALID; + return UsageSnapshot.invalid(state, UsageSnapshot.Status.OVERFLOW); + } + + boolean historyLimited = eventHistoryLimitedUntilNanos != UNSET + && nowNanos <= eventHistoryLimitedUntilNanos; + return new UsageSnapshot( + state, + UsageSnapshot.Status.VALID, + sessionNanos, + last24Hours.value, + last7Days.value, + last30Days.value, + lifetimeNanos, + intervals.size(), + historyLimited); + } + + private UsageRecordResult countActiveUntil(long nowNanos) { + long deltaNanos = nowNanos - lastNowNanos; + lastNowNanos = nowNanos; + if (deltaNanos == 0L) { + return UsageRecordResult.NO_TIME; + } + if (deltaNanos >= maxObservationGapNanos) { + return UsageRecordResult.GAP_REJECTED; + } + + long nextSession; + long nextLifetime; + try { + nextSession = Math.addExact(sessionNanos, deltaNanos); + nextLifetime = Math.addExact(lifetimeNanos, deltaNanos); + } catch (ArithmeticException exception) { + state = UsageState.INVALID; + return UsageRecordResult.OVERFLOW; + } + + sessionNanos = nextSession; + lifetimeNanos = nextLifetime; + prune(nowNanos); + if (intervals.size() == maxRetainedEvents) { + UsageInterval evicted = intervals.removeFirst(); + eventHistoryLimitedUntilNanos = Math.max( + eventHistoryLimitedUntilNanos, + saturatedAdd(evicted.endNanos, MAX_WINDOW_NANOS)); + } + intervals.addLast(new UsageInterval(nowNanos - deltaNanos, nowNanos)); + return UsageRecordResult.COUNTED; + } + + private void prune(long nowNanos) { + long cutoff = nowNanos > MAX_WINDOW_NANOS + ? nowNanos - MAX_WINDOW_NANOS + : 0L; + while (!intervals.isEmpty() && intervals.peekFirst().endNanos <= cutoff) { + intervals.removeFirst(); + } + if (eventHistoryLimitedUntilNanos != UNSET && nowNanos > eventHistoryLimitedUntilNanos) { + eventHistoryLimitedUntilNanos = UNSET; + } + } + + private WindowSum sumWindow(long nowNanos, long windowNanos) { + long cutoff = nowNanos > windowNanos ? nowNanos - windowNanos : 0L; + long total = 0L; + for (UsageInterval interval : intervals) { + if (interval.endNanos <= cutoff) { + continue; + } + long overlapStart = Math.max(interval.startNanos, cutoff); + long overlapEnd = Math.min(interval.endNanos, nowNanos); + if (overlapEnd <= overlapStart) { + continue; + } + try { + total = Math.addExact(total, overlapEnd - overlapStart); + } catch (ArithmeticException exception) { + return new WindowSum(0L, true); + } + } + return new WindowSum(total, false); + } + + private static long saturatedAdd(long left, long right) { + try { + return Math.addExact(left, right); + } catch (ArithmeticException exception) { + return Long.MAX_VALUE; + } + } + + private record UsageInterval(long startNanos, long endNanos) { + private UsageInterval { + if (startNanos < 0L || endNanos < startNanos) { + throw new IllegalArgumentException("usage interval must be ordered and non-negative"); + } + } + } + + private record WindowSum(long value, boolean overflowed) { + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageTick.java b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageTick.java new file mode 100644 index 00000000..8287fbac --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/usage/UsageTick.java @@ -0,0 +1,18 @@ +package dev.hylfrd.farmhelper.feature.usage; + +/** Explicit, privacy-safe tick evidence supplied by a later lifecycle adapter. */ +public record UsageTick(long nowNanos, boolean macroActive) { + public UsageTick { + if (nowNanos < 0L) { + throw new IllegalArgumentException("nowNanos must be non-negative"); + } + } + + public static UsageTick active(long nowNanos) { + return new UsageTick(nowNanos, true); + } + + public static UsageTick idle(long nowNanos) { + return new UsageTick(nowNanos, false); + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/usage/package-info.java b/src/main/java/dev/hylfrd/farmhelper/feature/usage/package-info.java new file mode 100644 index 00000000..0507f3ac --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/usage/package-info.java @@ -0,0 +1,17 @@ +/** + * Privacy-safe, local usage accounting with no client, storage, network, or identity boundary. + * + *

Upstream anchors are fixed release {@code eacb323fbde3eff94d4f2ee7baacb059d84b8e3a}, the + * tracker introduction in {@code 66d58c1499f755d270d59ccea1de926b8119dd3e}, and the usage HUD + * behavior finalized through {@code 2f0143c5a717cc178ba9d87ec58f448e368496a}. The applicable + * local behavior is macro-active elapsed time with 24-hour, 7-day, 30-day, and lifetime views. + * This package independently expresses that behavior through explicit monotonic tick evidence.

+ * + *

The upstream {@code UsageStatsTracker} also depended on {@code BanInfoWS}, wall-clock dates, + * gzip JSON files named with a player UUID, and fields such as {@code id}, {@code modVersion}, + * {@code fastBreak}, and {@code timestamp}. Those identity-bearing persistence details are + * intentionally removed. The deletion authority is the upstream-parity matrix's BanInfoWS and + * analytics-removal entries: no BanInfoWS, analytics upload, WebSocket transport, remote command, + * telemetry, settings, or client wiring is part of this domain.

+ */ +package dev.hylfrd.farmhelper.feature.usage; diff --git a/src/test/java/dev/hylfrd/farmhelper/feature/usage/UsageStatsTrackerTest.java b/src/test/java/dev/hylfrd/farmhelper/feature/usage/UsageStatsTrackerTest.java new file mode 100644 index 00000000..947e7032 --- /dev/null +++ b/src/test/java/dev/hylfrd/farmhelper/feature/usage/UsageStatsTrackerTest.java @@ -0,0 +1,166 @@ +package dev.hylfrd.farmhelper.feature.usage; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class UsageStatsTrackerTest { + private static final long SECOND = TimeUnit.SECONDS.toNanos(1L); + + @Test + void countsOnlyExplicitActiveIntervalsAndExposesStableTotals() { + UsageStatsTracker tracker = new UsageStatsTracker(5L * SECOND, 16); + + assertEquals(UsageRecordResult.STARTED, tracker.start(0L)); + assertEquals(UsageRecordResult.NO_TIME, tracker.record(UsageTick.active(0L))); + assertEquals(UsageRecordResult.COUNTED, tracker.record(UsageTick.active(SECOND))); + assertEquals(UsageRecordResult.IDLE, tracker.record(UsageTick.idle(2L * SECOND))); + assertEquals(UsageRecordResult.COUNTED, tracker.record(true, 3L * SECOND)); + + UsageSnapshot snapshot = tracker.snapshot(3L * SECOND); + assertEquals(UsageState.RUNNING, snapshot.state()); + assertEquals(UsageSnapshot.Status.VALID, snapshot.status()); + assertEquals(2L * SECOND, snapshot.sessionNanos()); + assertEquals(2L * SECOND, snapshot.last24HoursNanos()); + assertEquals(2L * SECOND, snapshot.last7DaysNanos()); + assertEquals(2L * SECOND, snapshot.last30DaysNanos()); + assertEquals(2L * SECOND, snapshot.lifetimeNanos()); + assertEquals(2, snapshot.retainedEventCount()); + assertFalse(snapshot.eventHistoryLimited()); + assertEquals(2_000L, snapshot.totalMillis()); + assertEquals(snapshot, tracker.snapshot(3L * SECOND)); + } + + @Test + void rejectsOversizedActiveGapsWithoutAddingUnknownTime() { + UsageStatsTracker tracker = new UsageStatsTracker(5L * SECOND, 16); + tracker.start(0L); + + assertEquals(UsageRecordResult.COUNTED, tracker.record(true, SECOND)); + assertEquals(UsageRecordResult.GAP_REJECTED, tracker.record(true, 6L * SECOND)); + assertEquals(UsageRecordResult.COUNTED, tracker.record(true, 7L * SECOND)); + assertEquals(UsageRecordResult.GAP_REJECTED, tracker.record(true, 13L * SECOND)); + assertEquals(UsageRecordResult.COUNTED, tracker.record(true, 14L * SECOND)); + + UsageSnapshot snapshot = tracker.snapshot(14L * SECOND); + assertEquals(3L * SECOND, snapshot.sessionNanos()); + assertEquals(3L * SECOND, snapshot.lifetimeNanos()); + assertEquals(3, snapshot.retainedEventCount()); + } + + @Test + void pauseAndResumeExcludeTheExplicitPausedInterval() { + UsageStatsTracker tracker = new UsageStatsTracker(5L * SECOND, 16); + tracker.start(0L); + tracker.record(true, SECOND); + + assertEquals(UsageRecordResult.PAUSED, tracker.pause(2L * SECOND)); + assertEquals(UsageState.PAUSED, tracker.state()); + assertEquals(UsageRecordResult.PAUSED, tracker.record(true, 100L * SECOND)); + assertEquals(UsageRecordResult.RESUMED, tracker.resume(103L * SECOND)); + assertEquals(UsageRecordResult.COUNTED, tracker.record(true, 104L * SECOND)); + + UsageSnapshot snapshot = tracker.snapshot(104L * SECOND); + assertEquals(3L * SECOND, snapshot.sessionNanos()); + assertEquals(3L * SECOND, snapshot.lifetimeNanos()); + assertEquals(3, snapshot.retainedEventCount()); + } + + @Test + void rollingWindowsRolloverFromMonotonicEvidence() { + long day = UsageStatsTracker.DAY_NANOS; + UsageStatsTracker tracker = new UsageStatsTracker(40L * day, 16); + tracker.start(0L); + + tracker.record(true, SECOND); + tracker.record(false, 2L * SECOND); + tracker.record(false, 8L * day + 2L * SECOND); + tracker.record(true, 8L * day + 3L * SECOND); + + UsageSnapshot snapshot = tracker.snapshot(8L * day + 3L * SECOND); + assertEquals(2L * SECOND, snapshot.lifetimeNanos()); + assertEquals(SECOND, snapshot.last24HoursNanos()); + assertEquals(SECOND, snapshot.last7DaysNanos()); + assertEquals(2L * SECOND, snapshot.last30DaysNanos()); + assertEquals(2, snapshot.retainedEventCount()); + } + + @Test + void eventCapacityIsObservableUntilEvictedEvidenceExpires() { + UsageStatsTracker tracker = new UsageStatsTracker(10L * SECOND, 2); + tracker.start(0L); + tracker.record(true, SECOND); + tracker.record(false, 2L * SECOND); + tracker.record(true, 3L * SECOND); + tracker.record(false, 4L * SECOND); + tracker.record(true, 5L * SECOND); + + UsageSnapshot limited = tracker.snapshot(5L * SECOND); + assertEquals(3L * SECOND, limited.lifetimeNanos()); + assertEquals(2L * SECOND, limited.last24HoursNanos()); + assertEquals(2, limited.retainedEventCount()); + assertTrue(limited.eventHistoryLimited()); + + UsageSnapshot rolledOver = tracker.snapshot( + UsageStatsTracker.LAST_30_DAYS_NANOS + 6L * SECOND); + assertEquals(0, rolledOver.retainedEventCount()); + assertFalse(rolledOver.eventHistoryLimited()); + assertEquals(3L * SECOND, rolledOver.lifetimeNanos()); + } + + @Test + void resetClearsHistoryAndStartsANewSession() { + UsageStatsTracker tracker = new UsageStatsTracker(5L * SECOND, 16); + tracker.start(0L); + tracker.record(true, SECOND); + tracker.stop(); + + assertEquals(SECOND, tracker.snapshot(SECOND).lifetimeNanos()); + tracker.reset(); + assertEquals(UsageSnapshot.ZERO, tracker.snapshot(0L)); + assertEquals(UsageRecordResult.STARTED, tracker.start(0L)); + tracker.record(true, SECOND); + assertEquals(SECOND, tracker.snapshot(SECOND).lifetimeNanos()); + } + + @Test + void invalidAndRegressingEvidenceFailsClosedWithoutMutation() { + UsageStatsTracker tracker = new UsageStatsTracker(5L * SECOND, 16); + + assertEquals(UsageRecordResult.INVALID_TIME, tracker.record(true, -1L)); + assertEquals(UsageRecordResult.INVALID_EVENT, tracker.record(null)); + assertEquals(UsageSnapshot.Status.INVALID_TIME, tracker.snapshot(-1L).status()); + assertEquals(UsageRecordResult.STOPPED, tracker.record(true, 0L)); + + tracker.start(0L); + tracker.record(true, SECOND); + assertEquals(UsageRecordResult.CLOCK_REGRESSION, tracker.record(true, 0L)); + UsageSnapshot invalid = tracker.snapshot(0L); + assertEquals(UsageSnapshot.Status.CLOCK_REGRESSION, invalid.status()); + assertFalse(invalid.valid()); + + UsageSnapshot recovered = tracker.snapshot(2L * SECOND); + assertEquals(UsageSnapshot.Status.VALID, recovered.status()); + assertEquals(SECOND, recovered.lifetimeNanos()); + } + + @Test + void invalidBoundsAndTickTimeAreRejected() { + assertThrows(IllegalArgumentException.class, () -> new UsageStatsTracker(0L, 1)); + assertThrows(IllegalArgumentException.class, () -> new UsageStatsTracker(1L, 0)); + assertThrows(IllegalArgumentException.class, () -> new UsageTick(-1L, true)); + + UsageStatsTracker tracker = new UsageStatsTracker(); + assertEquals(UsageRecordResult.INVALID_TIME, tracker.start(-1L)); + assertEquals(UsageRecordResult.STARTED, tracker.start(0L)); + assertEquals(UsageRecordResult.ALREADY_ACTIVE, tracker.start(0L)); + assertEquals(UsageRecordResult.ALREADY_ACTIVE, tracker.resume(0L)); + assertEquals(UsageRecordResult.PAUSED, tracker.pause(0L)); + assertEquals(UsageRecordResult.ALREADY_PAUSED, tracker.pause(0L)); + } +}