From cf01e2063d1dc4ac313074f918b071e32111e99d Mon Sep 17 00:00:00 2001 From: mctituschristian Date: Sun, 30 Aug 2026 23:14:09 +0000 Subject: [PATCH] feat: three-way merge for diff.ts and warmUpMs for StandbyController (#702, #703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #703 — diff.ts three-way merge conflict detection: - Add MergeConflictError to src/errors.ts with field, baseValue, localValue, remoteValue properties - Add mergeInvoices(base, local, remote) to src/diff.ts implementing the three-way merge algorithm: - Both branches unchanged → keep base value - Only one branch changed → fast-forward to that value - Both changed to same value → no conflict, use that value - Both changed to different values → throw MergeConflictError - 17 unit tests in test/diff.test.ts covering all three merge outcomes Issue #702 — standby.ts configurable warm-up period: - Add StandbyController class to src/standby.ts with: - warmUpMs?: number option (default: 0) - inactivityMs?: number option (default: 30_000) - During warmUpMs window, inactivity does not trigger standby - After warm-up, normal inactivity detection resumes - recordActivity() resets the inactivity timer (post warm-up only) - standby getter, onStandby(listener), start(), stop() - 12 unit tests in test/standby.test.ts covering all acceptance criteria --- src/diff.ts | 75 ++++++++++++- src/errors.ts | 48 +++++++++ src/standby.ts | 131 +++++++++++++++++++++++ test/diff.test.ts | 245 +++++++++++++++++++++++++++++++++++++++++++ test/standby.test.ts | 224 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 719 insertions(+), 4 deletions(-) create mode 100644 test/diff.test.ts create mode 100644 test/standby.test.ts diff --git a/src/diff.ts b/src/diff.ts index 5eab489..9131cfc 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -1,11 +1,13 @@ /** - * Invoice diff utility — compare two invoice states. - * - * Pure function with no RPC calls or side effects. - * Returns structured diff showing only changed fields. + * Invoice diff utility — compare two invoice states and perform three-way + * merge for invoice state reconciliation. + * + * Pure functions with no RPC calls or side effects. + * Returns structured diff showing only changed fields, or a merged invoice. */ import type { Invoice } from "./types.js"; +import { MergeConflictError } from "./errors.js"; /** * A single field change in an invoice diff. @@ -189,3 +191,68 @@ export function diffInvoices(a: Invoice, b: Invoice): InvoiceDiff { export function hasDiff(a: Invoice, b: Invoice): boolean { return diffInvoices(a, b).length > 0; } + +/** + * Perform a three-way merge of invoice states. + * + * Compares `local` and `remote` each against the common `base` ancestor and + * produces a single merged invoice according to these rules: + * + * - **Neither branch modified the field** → keep the base value unchanged. + * - **Only one branch modified the field** → use that branch's value (fast-forward). + * - **Both branches modified the field to different values** → throw {@link MergeConflictError}. + * - **Both branches modified the field to the *same* value** → use that value (no conflict). + * + * @param base - The common ancestor invoice (fork point). + * @param local - The locally-modified copy of the invoice. + * @param remote - The remotely-modified copy of the invoice. + * @returns A new merged `Invoice` object. + * @throws {MergeConflictError} When both branches diverge on the same field. + * + * @example + * ```typescript + * const base = await client.getInvoice("123"); // original snapshot + * const local = { ...base, memo: "updated locally" }; + * const remote = await client.getInvoice("123"); // re-fetched after remote edit + * + * const merged = mergeInvoices(base, local, remote); + * ``` + */ +export function mergeInvoices(base: Invoice, local: Invoice, remote: Invoice): Invoice { + const merged: Invoice = { ...base }; + + for (const field of INVOICE_FIELDS) { + const baseVal = base[field]; + const localVal = local[field]; + const remoteVal = remote[field]; + + const localChanged = !valuesEqual(baseVal, localVal); + const remoteChanged = !valuesEqual(baseVal, remoteVal); + + if (!localChanged && !remoteChanged) { + // Neither branch touched this field — keep base value. + continue; + } + + if (localChanged && !remoteChanged) { + // Only local changed — take local value. + (merged as Record)[field] = localVal; + continue; + } + + if (!localChanged && remoteChanged) { + // Only remote changed — take remote value. + (merged as Record)[field] = remoteVal; + continue; + } + + // Both changed — conflict unless they converged to the same value. + if (valuesEqual(localVal, remoteVal)) { + (merged as Record)[field] = localVal; + } else { + throw new MergeConflictError(field, baseVal, localVal, remoteVal); + } + } + + return merged; +} diff --git a/src/errors.ts b/src/errors.ts index 87360b5..0dfeae4 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -2163,3 +2163,51 @@ export class SdkError extends Error { export function isSdkError(err: unknown): err is SdkError { return err instanceof SdkError; } + +// --------------------------------------------------------------------------- +// Three-way merge errors (issue #703) +// --------------------------------------------------------------------------- + +/** + * Thrown by {@link mergeInvoices} when both local and remote branches have + * modified the same field relative to the common base, producing a conflict + * that cannot be resolved automatically. + */ +export class MergeConflictError extends StellarSplitError { + /** The invoice field that caused the conflict. */ + readonly field: string; + /** The value of the field on the base (common ancestor) invoice. */ + readonly baseValue: unknown; + /** The value of the field on the local branch. */ + readonly localValue: unknown; + /** The value of the field on the remote branch. */ + readonly remoteValue: unknown; + + constructor( + field: string, + baseValue: unknown, + localValue: unknown, + remoteValue: unknown, + ) { + super( + `Merge conflict on field "${field}": both branches diverged from base`, + "MERGE_CONFLICT", + { + field, + baseValue: typeof baseValue === "bigint" ? baseValue.toString() : baseValue, + localValue: typeof localValue === "bigint" ? localValue.toString() : localValue, + remoteValue: typeof remoteValue === "bigint" ? remoteValue.toString() : remoteValue, + }, + ); + this.name = "MergeConflictError"; + this.field = field; + this.baseValue = baseValue; + this.localValue = localValue; + this.remoteValue = remoteValue; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isMergeConflictError(err: unknown): err is MergeConflictError { + return err instanceof MergeConflictError; +} diff --git a/src/standby.ts b/src/standby.ts index 685f410..bcd6a37 100644 --- a/src/standby.ts +++ b/src/standby.ts @@ -51,3 +51,134 @@ export class WarmStandby { } } } + +// --------------------------------------------------------------------------- +// StandbyController — issue #702 +// --------------------------------------------------------------------------- + +/** Options accepted by {@link StandbyController}. */ +export interface StandbyControllerOptions { + /** + * Duration in milliseconds during which standby activation is suppressed + * after {@link StandbyController.start} is called. + * + * Useful to prevent normal initialisation traffic from falsely triggering + * standby mode during startup. + * + * @default 0 + */ + warmUpMs?: number; + + /** + * Milliseconds of inactivity before standby mode is activated. + * + * @default 30_000 + */ + inactivityMs?: number; +} + +/** + * Controls standby-mode activation based on inactivity, with an optional + * warm-up window that suppresses standby during startup. + * + * @example + * ```typescript + * const ctrl = new StandbyController({ warmUpMs: 5_000, inactivityMs: 30_000 }); + * ctrl.onStandby(() => console.log("entered standby")); + * ctrl.start(); + * + * // Record activity whenever the SDK makes an RPC call: + * ctrl.recordActivity(); + * ``` + */ +export class StandbyController { + private readonly warmUpMs: number; + private readonly inactivityMs: number; + private standbyListeners: Array<() => void> = []; + private inactivityHandle: ReturnType | null = null; + private warmUpHandle: ReturnType | null = null; + private isWarmedUp = false; + private isStandby = false; + private started = false; + + constructor(options: StandbyControllerOptions = {}) { + this.warmUpMs = options.warmUpMs ?? 0; + this.inactivityMs = options.inactivityMs ?? 30_000; + } + + /** + * Register a callback invoked when standby mode activates. + */ + onStandby(listener: () => void): void { + this.standbyListeners.push(listener); + } + + /** + * Start the controller. The warm-up timer begins immediately; inactivity + * detection only arms once the warm-up period has elapsed. + */ + start(): void { + if (this.started) return; + this.started = true; + this.isStandby = false; + + if (this.warmUpMs > 0) { + // Suppress inactivity detection during the warm-up window. + this.warmUpHandle = setTimeout(() => { + this.warmUpHandle = null; + this.isWarmedUp = true; + this.scheduleStandby(); + }, this.warmUpMs); + } else { + this.isWarmedUp = true; + this.scheduleStandby(); + } + } + + /** + * Record that activity occurred. Resets the inactivity timer (but only + * after the warm-up period has ended). + */ + recordActivity(): void { + if (!this.isWarmedUp) return; + this.cancelStandby(); + this.scheduleStandby(); + } + + /** Whether the controller is currently in standby mode. */ + get standby(): boolean { + return this.isStandby; + } + + /** Stop the controller and cancel all pending timers. */ + stop(): void { + this.cancelStandby(); + if (this.warmUpHandle !== null) { + clearTimeout(this.warmUpHandle); + this.warmUpHandle = null; + } + this.started = false; + this.isWarmedUp = false; + this.isStandby = false; + } + + // ---- private helpers ---- + + private scheduleStandby(): void { + this.inactivityHandle = setTimeout(() => { + this.inactivityHandle = null; + this.isStandby = true; + for (const listener of this.standbyListeners) { + listener(); + } + }, this.inactivityMs); + } + + private cancelStandby(): void { + if (this.inactivityHandle !== null) { + clearTimeout(this.inactivityHandle); + this.inactivityHandle = null; + } + this.isStandby = false; + } +} diff --git a/test/diff.test.ts b/test/diff.test.ts new file mode 100644 index 0000000..86adbd2 --- /dev/null +++ b/test/diff.test.ts @@ -0,0 +1,245 @@ +/** + * Tests for three-way merge conflict detection in src/diff.ts — issue #703. + * + * Covers: + * - Both branches modify the same field → MergeConflict thrown with the field name + * - Only one branch modifies a field → merge succeeds with the modified value + * - Neither branch modifies a field → merge succeeds with the base value + */ + +import { describe, it, expect } from "vitest"; +import { mergeInvoices } from "../src/diff.js"; +import { MergeConflictError } from "../src/errors.js"; +import type { Invoice } from "../src/types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeInvoice(overrides: Partial = {}): Invoice { + return { + id: "inv-1", + creator: "GCREATOR000000000000000000000000000000000000000000000", + recipients: [{ address: "GRECIPIENT0000000000000000000000000000000000000000000", amount: 1_000_000n }], + token: "USDC_CONTRACT", + deadline: 2_000_000_000, + funded: 0n, + status: "Pending", + payments: [], + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Three-way merge: conflict detection +// --------------------------------------------------------------------------- + +describe("mergeInvoices — conflict detection", () => { + it("throws MergeConflictError when both branches modify the same field to different values", () => { + const base = makeInvoice({ memo: "original memo" }); + const local = makeInvoice({ memo: "local memo" }); + const remote = makeInvoice({ memo: "remote memo" }); + + expect(() => mergeInvoices(base, local, remote)).toThrow(MergeConflictError); + }); + + it("includes the conflicting field name in the thrown error", () => { + const base = makeInvoice({ memo: "original" }); + const local = makeInvoice({ memo: "local edit" }); + const remote = makeInvoice({ memo: "remote edit" }); + + let caught: unknown; + try { + mergeInvoices(base, local, remote); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(MergeConflictError); + expect((caught as MergeConflictError).field).toBe("memo"); + }); + + it("includes base, local, and remote values in the thrown error", () => { + const base = makeInvoice({ deadline: 1_000_000 }); + const local = makeInvoice({ deadline: 1_100_000 }); + const remote = makeInvoice({ deadline: 1_200_000 }); + + let caught: unknown; + try { + mergeInvoices(base, local, remote); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(MergeConflictError); + const err = caught as MergeConflictError; + expect(err.baseValue).toBe(1_000_000); + expect(err.localValue).toBe(1_100_000); + expect(err.remoteValue).toBe(1_200_000); + }); + + it("throws on the first conflicting field encountered", () => { + // Both status and memo conflict; error should fire on one of them. + const base = makeInvoice({ status: "Pending", memo: "base" }); + const local = makeInvoice({ status: "Released", memo: "local" }); + const remote = makeInvoice({ status: "Refunded", memo: "remote" }); + + expect(() => mergeInvoices(base, local, remote)).toThrow(MergeConflictError); + }); + + it("does NOT throw when both branches set the same field to the same value", () => { + // Both converge on the same memo — not a conflict. + const base = makeInvoice({ memo: "original" }); + const local = makeInvoice({ memo: "agreed value" }); + const remote = makeInvoice({ memo: "agreed value" }); + + const merged = mergeInvoices(base, local, remote); + expect(merged.memo).toBe("agreed value"); + }); + + it("correctly identifies the field name for bigint fields in conflict error", () => { + const base = makeInvoice({ funded: 0n }); + const local = makeInvoice({ funded: 500_000n }); + const remote = makeInvoice({ funded: 700_000n }); + + let caught: unknown; + try { + mergeInvoices(base, local, remote); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(MergeConflictError); + expect((caught as MergeConflictError).field).toBe("funded"); + }); +}); + +// --------------------------------------------------------------------------- +// Three-way merge: one branch modifies a field +// --------------------------------------------------------------------------- + +describe("mergeInvoices — one branch modifies a field", () => { + it("takes the local value when only local modified the field", () => { + const base = makeInvoice({ memo: "base memo" }); + const local = makeInvoice({ memo: "updated by local" }); + const remote = makeInvoice({ memo: "base memo" }); // unchanged + + const merged = mergeInvoices(base, local, remote); + + expect(merged.memo).toBe("updated by local"); + }); + + it("takes the remote value when only remote modified the field", () => { + const base = makeInvoice({ status: "Pending" }); + const local = makeInvoice({ status: "Pending" }); // unchanged + const remote = makeInvoice({ status: "Released" }); + + const merged = mergeInvoices(base, local, remote); + + expect(merged.status).toBe("Released"); + }); + + it("preserves all other unchanged fields when only one field is modified", () => { + const base = makeInvoice({ memo: "base", deadline: 1_000_000 }); + const local = makeInvoice({ memo: "updated", deadline: 1_000_000 }); + const remote = makeInvoice({ memo: "base", deadline: 1_000_000 }); + + const merged = mergeInvoices(base, local, remote); + + expect(merged.memo).toBe("updated"); + expect(merged.deadline).toBe(1_000_000); + expect(merged.id).toBe(base.id); + expect(merged.creator).toBe(base.creator); + expect(merged.status).toBe(base.status); + }); + + it("applies a local bigint change (funded amount) without conflict", () => { + const base = makeInvoice({ funded: 0n }); + const local = makeInvoice({ funded: 1_000_000n }); + const remote = makeInvoice({ funded: 0n }); // unchanged + + const merged = mergeInvoices(base, local, remote); + + expect(merged.funded).toBe(1_000_000n); + }); + + it("applies a remote bigint change without conflict", () => { + const base = makeInvoice({ funded: 0n }); + const local = makeInvoice({ funded: 0n }); // unchanged + const remote = makeInvoice({ funded: 2_000_000n }); + + const merged = mergeInvoices(base, local, remote); + + expect(merged.funded).toBe(2_000_000n); + }); + + it("handles an optional field added only by local (undefined → value)", () => { + const base = makeInvoice(); // memo is undefined + const local = makeInvoice({ memo: "new memo" }); + const remote = makeInvoice(); // still undefined + + const merged = mergeInvoices(base, local, remote); + + expect(merged.memo).toBe("new memo"); + }); + + it("handles an optional field added only by remote (undefined → value)", () => { + const base = makeInvoice(); + const local = makeInvoice(); + const remote = makeInvoice({ memo: "remote memo" }); + + const merged = mergeInvoices(base, local, remote); + + expect(merged.memo).toBe("remote memo"); + }); +}); + +// --------------------------------------------------------------------------- +// Three-way merge: neither branch modifies a field +// --------------------------------------------------------------------------- + +describe("mergeInvoices — neither branch modifies a field", () => { + it("retains the base value when neither branch changed the field", () => { + const base = makeInvoice({ memo: "base memo" }); + const local = makeInvoice({ memo: "base memo" }); // same as base + const remote = makeInvoice({ memo: "base memo" }); // same as base + + const merged = mergeInvoices(base, local, remote); + + expect(merged.memo).toBe("base memo"); + }); + + it("retains the base bigint value when neither branch changed it", () => { + const base = makeInvoice({ funded: 500n }); + const local = makeInvoice({ funded: 500n }); + const remote = makeInvoice({ funded: 500n }); + + const merged = mergeInvoices(base, local, remote); + + expect(merged.funded).toBe(500n); + }); + + it("returns a merged invoice that is structurally equal to the base when nothing changed", () => { + const base = makeInvoice({ memo: "unchanged", status: "Pending", deadline: 9_999_999 }); + const local = { ...base }; + const remote = { ...base }; + + const merged = mergeInvoices(base, local, remote); + + expect(merged.memo).toBe(base.memo); + expect(merged.status).toBe(base.status); + expect(merged.deadline).toBe(base.deadline); + expect(merged.funded).toBe(base.funded); + expect(merged.id).toBe(base.id); + }); + + it("retains an undefined optional field when neither branch set it", () => { + const base = makeInvoice(); // memo is undefined + const local = makeInvoice(); + const remote = makeInvoice(); + + const merged = mergeInvoices(base, local, remote); + + expect(merged.memo).toBeUndefined(); + }); +}); diff --git a/test/standby.test.ts b/test/standby.test.ts new file mode 100644 index 0000000..ca7dead --- /dev/null +++ b/test/standby.test.ts @@ -0,0 +1,224 @@ +/** + * Tests for StandbyController warm-up period — issue #702. + * + * Covers: + * - warmUpMs option (default: 0, i.e., no warm-up) + * - During warm-up, inactivity does NOT trigger standby + * - After warm-up, standby detection resumes normally + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { StandbyController } from "../src/standby.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeController(opts: { warmUpMs?: number; inactivityMs?: number } = {}): StandbyController { + return new StandbyController({ + inactivityMs: opts.inactivityMs ?? 1_000, + warmUpMs: opts.warmUpMs, + }); +} + +// --------------------------------------------------------------------------- +// Warm-up option acceptance +// --------------------------------------------------------------------------- + +describe("StandbyController — warmUpMs option", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("accepts warmUpMs option without error", () => { + expect(() => new StandbyController({ warmUpMs: 5_000 })).not.toThrow(); + }); + + it("defaults warmUpMs to 0 when not provided", () => { + const ctrl = makeController(); // no warmUpMs + const listener = vi.fn(); + ctrl.onStandby(listener); + ctrl.start(); + + // With warmUpMs=0, standby should fire after inactivityMs + vi.advanceTimersByTime(1_000); + expect(listener).toHaveBeenCalledOnce(); + + ctrl.stop(); + }); + + it("accepts warmUpMs: 0 explicitly (no warm-up)", () => { + const ctrl = makeController({ warmUpMs: 0 }); + const listener = vi.fn(); + ctrl.onStandby(listener); + ctrl.start(); + + vi.advanceTimersByTime(1_000); + expect(listener).toHaveBeenCalledOnce(); + + ctrl.stop(); + }); +}); + +// --------------------------------------------------------------------------- +// Inactivity suppression during warm-up +// --------------------------------------------------------------------------- + +describe("StandbyController — warm-up suppresses standby", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("does NOT trigger standby when inactivity fires inside the warm-up window", () => { + // warmUpMs (5 s) > inactivityMs (1 s) — standby should be suppressed + const ctrl = makeController({ warmUpMs: 5_000, inactivityMs: 1_000 }); + const listener = vi.fn(); + ctrl.onStandby(listener); + ctrl.start(); + + // Advance past inactivityMs but still inside warmUpMs + vi.advanceTimersByTime(1_500); + expect(listener).not.toHaveBeenCalled(); + + ctrl.stop(); + }); + + it("standby is still suppressed at the exact edge of inactivityMs during warm-up", () => { + const ctrl = makeController({ warmUpMs: 10_000, inactivityMs: 1_000 }); + const listener = vi.fn(); + ctrl.onStandby(listener); + ctrl.start(); + + vi.advanceTimersByTime(1_000); // exactly inactivityMs, still within warmUp + expect(listener).not.toHaveBeenCalled(); + + ctrl.stop(); + }); + + it("recordActivity during warm-up does not reset an inactivity timer (there is none yet)", () => { + const ctrl = makeController({ warmUpMs: 5_000, inactivityMs: 1_000 }); + const listener = vi.fn(); + ctrl.onStandby(listener); + ctrl.start(); + + // Simulate activity during warm-up + vi.advanceTimersByTime(2_000); + ctrl.recordActivity(); + vi.advanceTimersByTime(1_500); // inactivityMs after recordActivity, still in warmUp + expect(listener).not.toHaveBeenCalled(); + + ctrl.stop(); + }); +}); + +// --------------------------------------------------------------------------- +// Normal standby detection after warm-up +// --------------------------------------------------------------------------- + +describe("StandbyController — standby activates after warm-up", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("triggers standby after warm-up + inactivity have both elapsed", () => { + const ctrl = makeController({ warmUpMs: 2_000, inactivityMs: 1_000 }); + const listener = vi.fn(); + ctrl.onStandby(listener); + ctrl.start(); + + // Only warm-up elapsed — no standby yet + vi.advanceTimersByTime(2_000); + expect(listener).not.toHaveBeenCalled(); + + // inactivityMs elapses after warm-up ends + vi.advanceTimersByTime(1_000); + expect(listener).toHaveBeenCalledOnce(); + + ctrl.stop(); + }); + + it("marks controller as standby=true after warm-up + inactivity", () => { + const ctrl = makeController({ warmUpMs: 2_000, inactivityMs: 1_000 }); + ctrl.start(); + + vi.advanceTimersByTime(3_000 + 1); // warmUp + inactivity + 1ms buffer + expect(ctrl.standby).toBe(true); + + ctrl.stop(); + }); + + it("recordActivity after warm-up resets the inactivity timer", () => { + const ctrl = makeController({ warmUpMs: 1_000, inactivityMs: 2_000 }); + const listener = vi.fn(); + ctrl.onStandby(listener); + ctrl.start(); + + // Let warm-up elapse + vi.advanceTimersByTime(1_000); + + // Advance 1.5 s into inactivity (not yet triggered) + vi.advanceTimersByTime(1_500); + expect(listener).not.toHaveBeenCalled(); + + // Record activity — this should restart the inactivity timer + ctrl.recordActivity(); + + // Only 0.5 s elapses after activity — no standby + vi.advanceTimersByTime(500); + expect(listener).not.toHaveBeenCalled(); + + // Full inactivityMs elapses — standby now fires + vi.advanceTimersByTime(2_000); + expect(listener).toHaveBeenCalledOnce(); + + ctrl.stop(); + }); + + it("standby listener is called exactly once per idle period", () => { + const ctrl = makeController({ warmUpMs: 500, inactivityMs: 500 }); + const listener = vi.fn(); + ctrl.onStandby(listener); + ctrl.start(); + + vi.advanceTimersByTime(1_500); // warmUp + inactivity + extra + + expect(listener).toHaveBeenCalledOnce(); + ctrl.stop(); + }); + + it("standby does not fire after stop() is called", () => { + const ctrl = makeController({ warmUpMs: 0, inactivityMs: 1_000 }); + const listener = vi.fn(); + ctrl.onStandby(listener); + ctrl.start(); + + vi.advanceTimersByTime(500); + ctrl.stop(); + + // Advance beyond inactivity threshold after stop + vi.advanceTimersByTime(1_500); + expect(listener).not.toHaveBeenCalled(); + }); + + it("controller is NOT in standby during the warm-up period", () => { + const ctrl = makeController({ warmUpMs: 5_000, inactivityMs: 1_000 }); + ctrl.start(); + + vi.advanceTimersByTime(4_000); // still within warm-up + expect(ctrl.standby).toBe(false); + + ctrl.stop(); + }); +});