|
| 1 | +import { describe, expect, it } from "@effect/vitest"; |
| 2 | +import * as Deferred from "effect/Deferred"; |
| 3 | +import * as Effect from "effect/Effect"; |
| 4 | +import * as Ref from "effect/Ref"; |
| 5 | +import * as Stream from "effect/Stream"; |
| 6 | +import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 7 | + |
| 8 | +import * as Connectivity from "./connectivity.ts"; |
| 9 | +import type { NetworkStatus } from "./model.ts"; |
| 10 | +import * as ConnectionWakeups from "./wakeups.ts"; |
| 11 | + |
| 12 | +const makeHarness = Effect.fn("TestConnectivityHarness.make")(function* (options?: { |
| 13 | + readonly status?: Effect.Effect<NetworkStatus>; |
| 14 | + readonly initialStatus?: NetworkStatus; |
| 15 | +}) { |
| 16 | + const liveStatus = yield* Ref.make<NetworkStatus>(options?.initialStatus ?? "online"); |
| 17 | + const reported = yield* SubscriptionRef.make<{ |
| 18 | + readonly sequence: number; |
| 19 | + readonly status: NetworkStatus; |
| 20 | + }>({ sequence: 0, status: options?.initialStatus ?? "online" }); |
| 21 | + const wakeups = yield* SubscriptionRef.make(0); |
| 22 | + const applied = yield* Ref.make<ReadonlyArray<NetworkStatus>>([]); |
| 23 | + |
| 24 | + const connectivity = Connectivity.Connectivity.of({ |
| 25 | + status: options?.status ?? Ref.get(liveStatus), |
| 26 | + changes: SubscriptionRef.changes(reported).pipe( |
| 27 | + Stream.drop(1), |
| 28 | + Stream.map((event) => event.status), |
| 29 | + ), |
| 30 | + }); |
| 31 | + |
| 32 | + const wakeupService = ConnectionWakeups.ConnectionWakeups.of({ |
| 33 | + changes: SubscriptionRef.changes(wakeups).pipe( |
| 34 | + Stream.drop(1), |
| 35 | + Stream.map(() => "application-active" as const), |
| 36 | + ), |
| 37 | + }); |
| 38 | + |
| 39 | + return { |
| 40 | + connectivity, |
| 41 | + wakeups: wakeupService, |
| 42 | + applied, |
| 43 | + setLiveStatus: (status: NetworkStatus) => Ref.set(liveStatus, status), |
| 44 | + // Emits a listener event, as a platform connectivity listener would. |
| 45 | + report: (status: NetworkStatus) => |
| 46 | + Ref.set(liveStatus, status).pipe( |
| 47 | + Effect.andThen( |
| 48 | + SubscriptionRef.update(reported, (event) => ({ |
| 49 | + sequence: event.sequence + 1, |
| 50 | + status, |
| 51 | + })), |
| 52 | + ), |
| 53 | + ), |
| 54 | + resume: SubscriptionRef.update(wakeups, (count) => count + 1), |
| 55 | + apply: (status: NetworkStatus) => Ref.update(applied, (statuses) => [...statuses, status]), |
| 56 | + }; |
| 57 | +}); |
| 58 | + |
| 59 | +// The forked listeners subscribe after `followNetworkStatus` returns, so repeat |
| 60 | +// the trigger until its effect is observed rather than racing the first one. |
| 61 | +const untilApplied = Effect.fn("TestConnectivityHarness.untilApplied")(function* ( |
| 62 | + trigger: Effect.Effect<unknown>, |
| 63 | + applied: Ref.Ref<ReadonlyArray<NetworkStatus>>, |
| 64 | + expected: number, |
| 65 | +) { |
| 66 | + for (let attempt = 0; attempt < 100; attempt += 1) { |
| 67 | + yield* trigger; |
| 68 | + yield* Effect.yieldNow; |
| 69 | + if ((yield* Ref.get(applied)).length >= expected) { |
| 70 | + return yield* Ref.get(applied); |
| 71 | + } |
| 72 | + } |
| 73 | + return yield* Effect.die(new Error("The expected network status was never applied.")); |
| 74 | +}); |
| 75 | + |
| 76 | +describe("followNetworkStatus", () => { |
| 77 | + it.effect("applies statuses reported by the platform listener", () => |
| 78 | + Effect.scoped( |
| 79 | + Effect.gen(function* () { |
| 80 | + const harness = yield* makeHarness(); |
| 81 | + yield* Connectivity.followNetworkStatus({ |
| 82 | + connectivity: harness.connectivity, |
| 83 | + wakeups: harness.wakeups, |
| 84 | + apply: harness.apply, |
| 85 | + }); |
| 86 | + |
| 87 | + const applied = yield* untilApplied(harness.report("offline"), harness.applied, 1); |
| 88 | + expect(applied).toEqual(["offline"]); |
| 89 | + }), |
| 90 | + ), |
| 91 | + ); |
| 92 | + |
| 93 | + it.effect("applies a resumed read when the listener missed a transition", () => |
| 94 | + Effect.scoped( |
| 95 | + Effect.gen(function* () { |
| 96 | + const harness = yield* makeHarness({ initialStatus: "offline" }); |
| 97 | + yield* Connectivity.followNetworkStatus({ |
| 98 | + connectivity: harness.connectivity, |
| 99 | + wakeups: harness.wakeups, |
| 100 | + apply: harness.apply, |
| 101 | + }); |
| 102 | + |
| 103 | + // The device came back online while suspended and the listener never |
| 104 | + // reported the transition. |
| 105 | + yield* harness.setLiveStatus("online"); |
| 106 | + const applied = yield* untilApplied(harness.resume, harness.applied, 1); |
| 107 | + expect(applied).toEqual(["online"]); |
| 108 | + }), |
| 109 | + ), |
| 110 | + ); |
| 111 | + |
| 112 | + it.effect("discards a resumed read that a repeated report raced", () => |
| 113 | + Effect.scoped( |
| 114 | + Effect.gen(function* () { |
| 115 | + const readStarted = yield* Deferred.make<void>(); |
| 116 | + const releaseRead = yield* Deferred.make<void>(); |
| 117 | + const harness = yield* makeHarness({ |
| 118 | + initialStatus: "online", |
| 119 | + // The resume samples a brief opposite state. |
| 120 | + status: Deferred.succeed(readStarted, undefined).pipe( |
| 121 | + Effect.andThen(Deferred.await(releaseRead)), |
| 122 | + Effect.as("offline" as const), |
| 123 | + ), |
| 124 | + }); |
| 125 | + yield* Connectivity.followNetworkStatus({ |
| 126 | + connectivity: harness.connectivity, |
| 127 | + wakeups: harness.wakeups, |
| 128 | + apply: harness.apply, |
| 129 | + }); |
| 130 | + |
| 131 | + // Apply "online" first so the listener's later repeat of it is genuinely |
| 132 | + // redundant rather than a new status. |
| 133 | + yield* untilApplied(harness.report("online"), harness.applied, 1); |
| 134 | + yield* harness.resume.pipe( |
| 135 | + Effect.andThen(Effect.yieldNow), |
| 136 | + Effect.repeat({ until: () => Deferred.isDone(readStarted) }), |
| 137 | + ); |
| 138 | + |
| 139 | + // The repeat carries no new status, but it proves the listener spoke |
| 140 | + // after this read began, so the read is stale even though the status it |
| 141 | + // returns differs. Applying it would strand consumers on "offline" while |
| 142 | + // the platform reports "online" — the very failure this helper exists to |
| 143 | + // prevent. |
| 144 | + yield* harness.report("online"); |
| 145 | + yield* Effect.yieldNow; |
| 146 | + yield* Deferred.succeed(releaseRead, undefined); |
| 147 | + yield* Effect.yieldNow; |
| 148 | + yield* Effect.yieldNow; |
| 149 | + |
| 150 | + expect(yield* Ref.get(harness.applied)).toEqual(["online"]); |
| 151 | + }), |
| 152 | + ), |
| 153 | + ); |
| 154 | + |
| 155 | + it.effect("still deduplicates a repeated report before it reaches consumers", () => |
| 156 | + Effect.scoped( |
| 157 | + Effect.gen(function* () { |
| 158 | + const harness = yield* makeHarness({ initialStatus: "online" }); |
| 159 | + yield* Connectivity.followNetworkStatus({ |
| 160 | + connectivity: harness.connectivity, |
| 161 | + wakeups: harness.wakeups, |
| 162 | + apply: harness.apply, |
| 163 | + }); |
| 164 | + |
| 165 | + yield* untilApplied(harness.report("offline"), harness.applied, 1); |
| 166 | + // Counting the repeat for staleness must not turn it into a redundant |
| 167 | + // consumer update. |
| 168 | + yield* harness.report("offline"); |
| 169 | + yield* Effect.yieldNow; |
| 170 | + yield* Effect.yieldNow; |
| 171 | + expect(yield* Ref.get(harness.applied)).toEqual(["offline"]); |
| 172 | + |
| 173 | + // A genuine transition after the repeat still lands. |
| 174 | + yield* untilApplied(harness.report("online"), harness.applied, 2); |
| 175 | + expect(yield* Ref.get(harness.applied)).toEqual(["offline", "online"]); |
| 176 | + }), |
| 177 | + ), |
| 178 | + ); |
| 179 | + |
| 180 | + it.effect("does not start a second status read while one is in flight", () => |
| 181 | + Effect.scoped( |
| 182 | + Effect.gen(function* () { |
| 183 | + const firstRead = yield* Deferred.make<NetworkStatus>(); |
| 184 | + const readCount = yield* Ref.make(0); |
| 185 | + const harness = yield* makeHarness({ |
| 186 | + initialStatus: "unknown", |
| 187 | + status: Ref.updateAndGet(readCount, (count) => count + 1).pipe( |
| 188 | + Effect.andThen(Deferred.await(firstRead)), |
| 189 | + ), |
| 190 | + }); |
| 191 | + yield* Connectivity.followNetworkStatus({ |
| 192 | + connectivity: harness.connectivity, |
| 193 | + wakeups: harness.wakeups, |
| 194 | + apply: harness.apply, |
| 195 | + }); |
| 196 | + |
| 197 | + yield* harness.resume.pipe( |
| 198 | + Effect.andThen(Effect.yieldNow), |
| 199 | + Effect.repeat({ |
| 200 | + until: () => Ref.get(readCount).pipe(Effect.map((count) => count >= 1)), |
| 201 | + }), |
| 202 | + ); |
| 203 | + |
| 204 | + // Resumes are consumed sequentially, so further resumes cannot start a |
| 205 | + // read that races the one already in flight. Overlapping snapshots |
| 206 | + // therefore cannot apply out of order. |
| 207 | + yield* harness.resume; |
| 208 | + yield* harness.resume; |
| 209 | + yield* Effect.yieldNow; |
| 210 | + expect(yield* Ref.get(readCount)).toBe(1); |
| 211 | + |
| 212 | + yield* Deferred.succeed(firstRead, "online"); |
| 213 | + yield* Effect.yieldNow; |
| 214 | + expect(yield* Ref.get(harness.applied)).toEqual(["online"]); |
| 215 | + }), |
| 216 | + ), |
| 217 | + ); |
| 218 | + |
| 219 | + it.effect("discards a resumed read that a newer reported change superseded", () => |
| 220 | + Effect.scoped( |
| 221 | + Effect.gen(function* () { |
| 222 | + const readStarted = yield* Deferred.make<void>(); |
| 223 | + const releaseRead = yield* Deferred.make<void>(); |
| 224 | + const harness = yield* makeHarness({ |
| 225 | + initialStatus: "offline", |
| 226 | + status: Deferred.succeed(readStarted, undefined).pipe( |
| 227 | + Effect.andThen(Deferred.await(releaseRead)), |
| 228 | + Effect.as("online" as const), |
| 229 | + ), |
| 230 | + }); |
| 231 | + yield* Connectivity.followNetworkStatus({ |
| 232 | + connectivity: harness.connectivity, |
| 233 | + wakeups: harness.wakeups, |
| 234 | + apply: harness.apply, |
| 235 | + }); |
| 236 | + |
| 237 | + // Resume, and hold the status read open so the listener can report a |
| 238 | + // newer transition while that read is still in flight. |
| 239 | + yield* harness.resume.pipe( |
| 240 | + Effect.andThen(Effect.yieldNow), |
| 241 | + Effect.repeat({ until: () => Deferred.isDone(readStarted) }), |
| 242 | + ); |
| 243 | + yield* untilApplied(harness.report("offline"), harness.applied, 1); |
| 244 | + yield* Deferred.succeed(releaseRead, undefined); |
| 245 | + yield* Effect.yieldNow; |
| 246 | + |
| 247 | + // The stale "online" snapshot must not land on top of the newer event. |
| 248 | + expect(yield* Ref.get(harness.applied)).toEqual(["offline"]); |
| 249 | + }), |
| 250 | + ), |
| 251 | + ); |
| 252 | + |
| 253 | + it.effect("keeps a reported change from interleaving with a resumed apply", () => |
| 254 | + Effect.scoped( |
| 255 | + Effect.gen(function* () { |
| 256 | + const applyStarted = yield* Deferred.make<void>(); |
| 257 | + const releaseApply = yield* Deferred.make<void>(); |
| 258 | + const harness = yield* makeHarness({ initialStatus: "offline" }); |
| 259 | + // Holds the resumed apply open once it begins, so a reported change has |
| 260 | + // a window to interleave between the guard and its apply. |
| 261 | + const gatedApply = (status: NetworkStatus) => |
| 262 | + status === "online" |
| 263 | + ? Deferred.succeed(applyStarted, undefined).pipe( |
| 264 | + Effect.andThen(Deferred.await(releaseApply)), |
| 265 | + Effect.andThen(harness.apply(status)), |
| 266 | + ) |
| 267 | + : harness.apply(status); |
| 268 | + |
| 269 | + yield* Connectivity.followNetworkStatus({ |
| 270 | + connectivity: harness.connectivity, |
| 271 | + wakeups: harness.wakeups, |
| 272 | + apply: gatedApply, |
| 273 | + }); |
| 274 | + |
| 275 | + yield* harness.setLiveStatus("online"); |
| 276 | + yield* harness.resume.pipe( |
| 277 | + Effect.andThen(Effect.yieldNow), |
| 278 | + Effect.repeat({ until: () => Deferred.isDone(applyStarted) }), |
| 279 | + ); |
| 280 | + |
| 281 | + // The listener reports a newer transition mid-apply. |
| 282 | + yield* harness.report("offline"); |
| 283 | + yield* Effect.yieldNow; |
| 284 | + yield* Deferred.succeed(releaseApply, undefined); |
| 285 | + yield* Effect.yieldNow.pipe( |
| 286 | + Effect.repeat({ |
| 287 | + until: () => |
| 288 | + Ref.get(harness.applied).pipe(Effect.map((statuses) => statuses.length >= 2)), |
| 289 | + }), |
| 290 | + ); |
| 291 | + |
| 292 | + // The reported change has to land last; applying it before the resumed |
| 293 | + // status finished would leave the stale "online" as the final word. |
| 294 | + expect(yield* Ref.get(harness.applied)).toEqual(["online", "offline"]); |
| 295 | + }), |
| 296 | + ), |
| 297 | + ); |
| 298 | +}); |
0 commit comments