Skip to content

feat: read time via package:clock, fix flush() leaving a timer armed - #8

Merged
xsahil03x merged 12 commits into
masterfrom
refactor/use-clock-package
Aug 11, 2026
Merged

feat: read time via package:clock, fix flush() leaving a timer armed#8
xsahil03x merged 12 commits into
masterfrom
refactor/use-clock-package

Conversation

@xsahil03x

@xsahil03x xsahil03x commented Aug 11, 2026

Copy link
Copy Markdown
Member

What

Debounce read the wall clock directly via DateTime.now(), so its timing behaviour couldn't be controlled from a test. It now reads through package:clock — three call sites in lib/src/debounce.dart. Throttle delegates to Debounce and benefits for free; BackOff uses Future.delayed and needs no change.

clock alone only makes the time source mockableTimer is still real. The payoff is fakeAsync, which installs both a fake Timer queue and a clock that advances with elapse(). The whole debounce/throttle suite now runs on it.

Public API is unchanged — the diff under lib/ is three time reads and one Timer.cancel().

Bug fix: flush() left its timer armed

_trailingEdge detached the timer without cancelling it. Reached through flush() — where the timer is still armed — the orphan fired later, found nothing to invoke, and rescheduled itself. Measured before the fix:

after flush: callCount=1 isPending=false
t+23ms     : callCount=1 isPending=true     ← spuriously pending again
t+223ms    : callCount=1 isPending=false

No double invocation, but isPending returned the wrong answer — and it's the getter the docs point users at (debounced.isPending ? "Pending..." : "Ready"). One line in _trailingEdge, with regression tests in both suites that fail without it.

This has been shipping since 0.1.0. It was only findable once the suite moved to a fake clock; under real time the test always finished before the orphan fired.

Versioning: 1.1.0

clock 1.1.2 declares sdk: ^3.4.0, so rate_limiter matches. That is not breaking in pub's terms — consumers on an older SDK resolve to 1.0.0 and keep building. package:clock itself made the same >=2.12.0 <3.0.0^3.4.0 move in a patch release (1.1.1 → 1.1.2).

Pinning clock below 1.1.2 to hold the old floor was considered and rejected: it needs an upper bound like >=1.1.0 <1.1.2, which causes resolution conflicts for any consumer that also depends on something wanting clock ^1.1.2.

Test suite now runs on a fake clock

Every delay() became elapse; the two busy-wait loops became elapseBlocking, which advances the clock without running timers — what a synchronous tight loop does to a real program. ~0.7s instead of ~3s, with no dependence on machine speed.

Reviewing them against a now-knowable clock turned up assertions verifying less than they appeared to:

  • Both cancel tests asserted callCount == 0 at 30ms — before the 32ms trailing call was ever due. They passed whether or not cancel() did anything. They now elapse past the deadline.
  • isNot('b') / isNotNull checks only ruled out one wrong answer; they now assert the right one.
  • Throttle counts were compared against a snapshot variable rather than stated outright.

Counts that encode loop iterations rather than documented behaviour are deliberately left as greaterThan(1).

Coverage

Check Code Coverage has failed on master since 4e1e2b0 (BackOff) added BackOffExtension.backOff() with no test:

commit coverage
4e1e2b0~1 (before BackOff) 78/78 = 100%
master 96/99 = 96.97% (CI reported 94.95%)
this branch 100/100 = 100%

Verified line-by-line, not just by total, across the fakeAsync port — no line silently traded for another.

CI

The old workflow pinned flutter-action@v1.4.0 → Flutter 2.2.1 → Dart 2.13, which cannot resolve sdk: ^3.4.0. Rebuilt on the very_good_workflows build layout, adapted:

  • dart-lang/setup-dart instead of flutter-action — pure Dart package; Flutter was only ever a vehicle for its bundled Dart SDK.
  • An SDK matrix (3.4.0, stable). It earned its keep immediately: it caught the maxWait tight-loop branch uncovered on 3.4.0 but not stable, reachable only via a wall-clock test. That finding is what motivated the suite port.
  • Coverage measured once in its own job (needs: build), matching dart-lang/coverage's layout.
  • Kept format_coverage over the test_with_coverage one-liner pub.dev recommends: it has no --base-directory, and Codecov can only map files when lcov paths are repo-relative.
  • very_good_coverage gate at 97, permissions: contents: read preserved from CI: add least-privilege permissions to GitHub Actions workflows #7.

New: pub_publish.yaml

Ported from super_paging, adapted: no flutter-action, dart pub get, and least-privilege permissions with id-token: write scoped to the publish job. The tag filter is v[0-9]+.[0-9]+.[0-9]+* to match what pub.dev is configured with — note releases through 1.0.0 were tagged without the v prefix, so the next tag needs to be v1.1.0.

⚠️ This goes live on merge. The next matching tag push publishes to pub.dev with --force (no confirmation). It requires automated publishing to be enabled for the package on pub.dev first; until then the job fails at the publish step.

Verification

All checks green. Locally: dart format --set-exit-if-changed ., dart analyze --fatal-infos ., 39 tests under randomized ordering, coverage 100%, dart pub publish --dry-run clean, and example/ resolves and analyzes against the new SDK floor.

Mutation checks — reverting clock.now()DateTime.now() fails 19 tests (3 before the port); removing the _trailingEdge cancel fails both new flush tests.

Known and accepted

  • clock.now() does a Zone lookup per call, now on Debounce.call()'s hot path.
  • Coverage is line-only; branch coverage is not measured.
  • CI format/analyze scope is lib, so test/ is not lint-gated. Passing . instead would gate it — an improvement rather than a restoration, since the old whole-repo check ran under a Dart 2.13 analyzer.

🤖 Generated with Claude Code

xsahil03x and others added 2 commits August 11, 2026 11:40
Debounce read the wall clock directly through DateTime.now(), which made
its timing behaviour impossible to control from a test. Reading through
package:clock means a test can install its own clock, and in particular
fakeAsync now drives both the Timer queue and the clock in lockstep.

Adds test/fake_async_test.dart covering the debounce trailing edge,
maxWait, and throttle — all of which run instantly instead of waiting on
real time. These tests fail if the clock.now() calls are reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
package:clock 1.1.2 requires sdk ^3.4.0, so rate_limiter has to match.
That is breaking for consumers on Dart 2.x, hence the 2.0.0 bump.

The old workflow pinned Flutter 2.2.1 (Dart 2.13) via flutter-action
v1.4.0, which cannot resolve the new constraint, so it moves to
setup-dart. Coverage is now produced by package:coverage, with
--base-directory so the paths in lcov.info stay repo-relative and
Codecov can map them.

The Check Code Coverage step has been failing since 4e1e2b0 added
BackOffExtension.backOff() without a test, taking coverage from 100% to
96.97% (94.95% on CI) against a 97% gate. Covered by two tests in
extension_test.dart; the first-attempt-succeeds path costs no delay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@xsahil03x xsahil03x changed the title refactor: read time via package:clock instead of DateTime.now() refactor: read time via package:clock, require Dart ^3.4.0 Aug 11, 2026
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (f6c1b6b) to head (9b48e6b).
⚠️ Report is 6 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff             @@
##           master        #8      +/-   ##
===========================================
+ Coverage   97.43%   100.00%   +2.56%     
===========================================
  Files           3         4       +1     
  Lines          78       100      +22     
===========================================
+ Hits           76       100      +24     
+ Misses          2         0       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

xsahil03x and others added 5 commits August 11, 2026 11:51
Adopts the very_good_workflows build layout, adapted to this repo:

- setup-dart rather than flutter-action, since this is a pure Dart
  package with no Flutter dependency.
- The matrix pins the package's minimum SDK (3.4.0) alongside stable, so
  the declared `environment` constraint is actually exercised.
- Triggers on master, not main.
- Actions moved to their current majors, and a concurrency group cancels
  superseded runs.

Coverage comes from package:coverage with --base-directory so the paths
in lcov.info stay repo-relative; the previous absolute paths would have
left Codecov unable to map files. The very_good_coverage gate is kept at
97, unchanged from before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 3.4.0 matrix leg reported debounce.dart:217,219,220 uncovered while
stable reported 100%. That branch was reached only by the wall-clock
"maxWait in a tight loop" test, so whether it ran at all depended on how
fast the runner was — the same fragility behind the 96/99 vs 94/99 gap
seen on master.

fakeAsync's elapseBlocking advances the clock without running the pending
timer, which is exactly what a synchronous tight loop does, so the branch
is now exercised without depending on real time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follows the layout dart-lang/coverage uses: the matrix job runs format,
analyze and tests across the SDK range, and a separate job gated on it
measures coverage once. Previously every matrix leg recomputed the same
numbers.

Keeping format_coverage rather than the test_with_coverage one-liner that
pub.dev recommends: test_with_coverage has no --base-directory, and
Codecov can only map files when the paths in lcov.info are relative to
the repository root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The debounce and throttle suites waited on real elapsed time: `delay()`
calls between assertions, and busy-wait loops burning wall-clock so real
Timers could fire. That made coverage depend on how fast the machine was,
which is what produced the 3.4.0 matrix failure earlier in this branch.

Every `delay()` becomes `elapse`, and the two busy-wait loops become
`elapseBlocking`, which advances the clock without running timers — what
a synchronous tight loop does to a real program. fake_async_test.dart is
folded back in, since its cases are now covered in place, and the unused
`delay()` helper is gone.

Some assertions were verifying less than they appeared to:

- Both `cancel` tests asserted callCount == 0 at 30ms, before the 32ms
  trailing call was ever due. They passed whether or not `cancel` did
  anything. They now elapse past the deadline.
- `isNot('b')` / `isNotNull` checks on returned results only ruled out one
  wrong answer. A fixed clock makes the right answer knowable, so they
  assert it.
- The throttle leading/trailing counts were compared against a snapshot
  variable rather than stated outright.

Counts that encode loop iterations rather than documented behaviour are
left as `greaterThan(1)`.

Reverting clock.now() to DateTime.now() now fails 19 tests, up from 3.
Coverage is unchanged at 99/99 lines, verified line-by-line rather than by
total, and the suite runs in ~0.7s instead of ~3s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_trailingEdge` detached the timer without cancelling it. Reached through
`flush`, where the timer is still armed, the orphan fired later, found
nothing to invoke and rescheduled itself — so `isPending` reported `true`
again roughly 23ms after a flush, and settled only once the reschedule
expired. `isPending` is the getter the docs point users at, so it was
returning the wrong answer for a documented workflow.

Release is 1.1.0 rather than 2.0.0. The public API is unchanged, and
raising the SDK floor is not breaking in pub's terms: consumers on an
older SDK resolve to 1.0.0 and keep building. package:clock made the same
2.12 -> ^3.4.0 move in a patch release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@xsahil03x xsahil03x changed the title refactor: read time via package:clock, require Dart ^3.4.0 feat: read time via package:clock, fix flush() leaving a timer armed Aug 11, 2026
xsahil03x and others added 5 commits August 11, 2026 12:21
Ported from super_paging, adapted to this repo:

- Tag filter matches this repository's unprefixed tags (1.0.0, 0.1.1).
  The original only matched a `v` prefix, which would never have fired
  here. The prefixed form is accepted too, so either style works.
- No flutter-action, and `dart pub get` rather than `flutter pub get`,
  matching the rest of the workflows for a pure Dart package.
- Least-privilege permissions, following #7: read at the top level, with
  id-token: write scoped to the publishing job.

Note this needs automated publishing enabled for the package on pub.dev,
pointed at this repository and tag pattern, before the OIDC exchange in
`dart pub publish --force` can succeed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Automated publishing on pub.dev is set to v[0-9]+.[0-9]+.[0-9]+*, and it
matches that against the OIDC token. Triggering on unprefixed tags as
well would start a run that could only fail at the publish step, so the
workflow now accepts exactly what pub.dev does.

Releases through 1.0.0 were tagged without the prefix; from here on the
tag needs to be v1.1.0 rather than 1.1.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dropped the file headers and the comments that paraphrased the assertion
below them, and condensed the rest to a single line, matching the comment
density already in the codebase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`toBool` lost its last caller when the suite moved to fakeAsync, and the
1.1.0 heading was missing the date the other entries carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@xsahil03x
xsahil03x merged commit 0bb40e4 into master Aug 11, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant