Files
Move/docs/execution-happy-path.md
nokeo08 d38949edb4 Add random pattern selection (-r / --pattern random)
Pick a different movement pattern every time a sweep is triggered, so the
motion varies across the day instead of repeating one shape.

- strategies.ts: add the `random` sentinel, `SELECTABLE_PATTERN_NAMES`,
  `isSelectablePattern`, and `createRandomPicker`. `random` is deliberately
  NOT a registry entry: it has no path of its own, so `STRATEGIES` stays a
  total lookup and `PATTERN_NAMES` keeps listing only real generators. The
  picker is a closure over `last`, giving a uniform draw that never returns
  the same pattern twice in a row. Building CANONICAL_PATTERNS from the
  selectable list makes both validation boundaries accept `random` (and
  loose spellings) for free, and extends the normalization-collision
  assertion to cover the sentinel.
- cli.ts: add `-r`/`--random` plus an exported `selectPattern` holding the
  conflict rule. `-r` is sugar for `--pattern random`, so the two agreeing
  is a no-op while `-r -p arc` is rejected as contradictory. The flag folds
  into `pattern`, so ConfigOverrides, resolveConfig, and move.ts are
  untouched. `parseCliArgs` now takes its argv as an optional parameter so
  the flag surface is testable without process.argv.
- keeper.ts: resolve `random` via the picker once per trigger, before the
  loop-mode branch, so a pick holds for a whole loop run rather than
  changing mid-run. runKeeper builds one picker for the process, so the
  no-repeat memory spans sweeps minutes apart. Because the pick is a real
  strategy, --verbose logs the concrete pattern name and a pick with an
  infinite loopPath still bounces edge-to-edge under --loop.
- config.ts / configFile.ts: accept the sentinel where a pattern is valid,
  and quote the selectable list in errors. No `random` boolean config key —
  the file spells it "pattern": "random".

executor.ts and move.ts needed no changes.

Tests: new tests/cli.test.ts (the file had no coverage before) covering the
flag surface and the conflict rule; picker tests pinning the no-repeat and
full-registry-coverage properties; keeper tests pinning once-per-trigger and
once-per-loop-run.
2026-08-18 14:36:29 -05:00

4.4 KiB

Execution: the happy path

This traces one full idle-triggered sweep that completes cleanly — the "happy path" where the machine is idle long enough to fire, the configured pattern runs to exhaustion, and no real user activity interrupts it.

For the module breakdown and the three seams (device / strategies / executor), see the "How it works" section of the README.

sequenceDiagram
    autonumber
    participant Entry as move.ts
    participant Keeper as runKeeper
    participant Sim as simulateActivity
    participant Strat as Strategy<br/>(e.g. line)
    participant Exec as executePath
    participant Dev as Device<br/>(nut.js)

    Note over Entry: startup (args → config)
    Entry->>Entry: parseCliArgs()
    Entry->>Entry: loadConfigFile()
    Entry->>Entry: resolveConfig(file, cli)
    Entry->>Keeper: runKeeper(config)

    Keeper->>Dev: createNutDevice()
    Note right of Dev: sets mouse.config.autoDelayMs = 0
    Keeper->>Keeper: log.info(banner)
    Keeper->>Dev: getPosition()
    Dev-->>Keeper: lastPos
    Note over Keeper: lastActivity = now

    loop every checkInterval (until idle long enough)
        Keeper->>Dev: sleep(checkInterval)
        Keeper->>Dev: getPosition()
        Dev-->>Keeper: pos
        Note over Keeper: pos == lastPos (no user movement)<br/>now - lastActivity ≥ moveInterval → fire
    end

    Keeper->>Sim: simulateActivity(config, log, dev, pickRandom)
    Sim->>Dev: width()
    Dev-->>Sim: width
    Sim->>Dev: height()
    Dev-->>Sim: height
    Sim->>Dev: getPosition()
    Dev-->>Sim: start
    Note over Sim: strategy = STRATEGIES[config.pattern]<br/>(or pickRandom() when pattern is "random")<br/>ctx = { start, width, height, rng }
    Sim->>Exec: executePath(strategy, ctx, dev, log, config)

    Exec->>Strat: path(ctx)
    Strat-->>Exec: iterable of Points

    loop for each target point (clean run)
        Exec->>Exec: resolveTarget(target) → point (reflected on-screen)
        Exec->>Dev: setPosition(point)
        Exec->>Dev: sleep(stepDelay)
        Exec->>Dev: getPosition()
        Dev-->>Exec: current
        Note over Exec: |current - point| ≤ 2px → not the user, continue
    end

    Note over Exec: path exhausted, no interruption
    Exec->>Dev: setPosition(round(start))
    Note right of Exec: restore cursor to origin
    Exec-->>Sim: "completed"
    Sim-->>Keeper: (done)

    Keeper->>Dev: getPosition()
    Dev-->>Keeper: lastPos (equals start, re-synced)
    Note over Keeper: lastActivity = now<br/>loop continues

Invariants this path relies on

  • createNutDevice() is the only nut.js touchpoint. It disables nut.js's 100ms auto-delay so executePath owns cadence via stepDelay.
  • The strategy is pure. path(ctx) yields ideal points from geometry alone (start / width / height / rng); it never touches the device, which is what makes every pattern unit-testable without a screen.
  • Every step re-reads the cursor and compares it against the commanded point (not the strategy's ideal, possibly fractional target) within a 2px tolerance. On the happy path each check passes, so the loop runs to exhaustion. A mismatch beyond tolerance is real user activity and returns "interrupted" without restoring — the branch this diagram omits.
  • Clean completion restores the cursor to round(start). That is why the follow-up getPosition() in runKeeper re-syncs lastPos to the origin as a no-op, and the next idle check sees no net movement (so the synthetic sweep is never mistaken for the user returning).
  • On-screen confinement is uniform. resolveTarget reflects any coordinate past a screen edge back inside the travel range — the sole, per-pattern-independent policy. A strategy emits ideal geometry and never bounds its own output.

Loop mode (--loop)

This diagram is the single-sweep path (config.loop === false). Under --loop, simulateActivity instead repeats the step loop until the user interrupts: a pattern with an infinite loopPath (line, diagonal) runs that one never-ending path, while the others chain their finite path cycle after cycle, re-reading the cursor as the next start each time. The restore in the final step is skipped (restore: false), so successive cycles flow from where the last left off. Everything else — reflection, pacing, and the per-step interrupt check — is identical to the sweep traced above.