mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
Split _sinks.py and home_screen.dart under the 250-line gate
_sinks.py 341 -> 249: the pactl *parsing* moves to _sink_parsing.py. That half is pure text handling with no subprocess and no side effects, so it is testable without touching the host's audio; _sinks.py keeps command execution and the activate/restore orchestration. The parsers are re-imported into _sinks under their original names, guarded by __all__. The suite patches `wake_alarm._sinks._parse_cards` and friends, and a moved symbol makes patch() stop intercepting *silently* — the test then asserts nothing and still passes. Ruff proved the point mid-split: shortening __all__ let it delete two of the re-exports and broke an import outright. _parse_profile_line is only imported (never patched), so its test now imports from the new module instead. home_screen.dart 270 -> 249. This one was mine — the automatic-alarm change pushed it over and I claimed in TODO-push-and-gate.md that nothing this session touched was over the limit; that was wrong. Publishing moves to AlarmPublisher, which returns a status line instead of calling setState, so it is testable without a widget. _loadSavedTime became _loadSyncState: it no longer loads a *time*, because the schedule is baked in and owned by AlarmPolicy, so a stored value could only disagree with it. 465 Python at 100% coverage, 79 Dart, APK builds, and both documented CLIs still print real output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
This commit is contained in:
parent
5340cce396
commit
1c8a40d410
@ -3,8 +3,8 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:wake_alarm_sync/screens/settings_screen.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_arming.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_policy.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_publisher.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
|
||||
import 'package:wake_alarm_sync/services/firebase_backend.dart';
|
||||
@ -33,7 +33,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
AlarmSyncService? _service;
|
||||
|
||||
/// Platform bridge that arms the app's own alarm (item 1).
|
||||
final AlarmArming _arming = AlarmArming(AlarmScheduler());
|
||||
final AlarmPublisher _publisher = AlarmPublisher();
|
||||
|
||||
/// Platform bridge used to arm the fixed schedule.
|
||||
final AlarmScheduler _scheduler = AlarmScheduler();
|
||||
@ -42,13 +42,19 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_applyPolicy());
|
||||
unawaited(_loadSavedTime());
|
||||
unawaited(_loadSyncState());
|
||||
// Publishing to the PC is automatic too: it used to require the same tap
|
||||
// that armed the alarm, so a forgotten tap left the PC on its fallback.
|
||||
unawaited(_syncToPc());
|
||||
}
|
||||
|
||||
Future<void> _loadSavedTime() async {
|
||||
/// Opens the sync store and records whether a backend is connected.
|
||||
///
|
||||
/// No longer loads a *time*: the schedule is baked in and owned by
|
||||
/// [AlarmPolicy], so a stored value could only disagree with it. This exists
|
||||
/// to supply the store for publishing and to drive the "connect a backend"
|
||||
/// hint.
|
||||
Future<void> _loadSyncState() async {
|
||||
final SyncSettings settings;
|
||||
final AlarmSyncService service;
|
||||
try {
|
||||
@ -68,15 +74,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
setState(
|
||||
() => _configured = settings.isConfigured || firebaseAccount != null,
|
||||
);
|
||||
|
||||
final alarm = service.currentAlarm;
|
||||
if (alarm != null && mounted) {
|
||||
setState(() {
|
||||
_hour = alarm.hour;
|
||||
_minute = alarm.minute;
|
||||
_status = 'Loaded saved alarm';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets the user move **tomorrow's** alarm earlier, never later.
|
||||
@ -121,43 +118,25 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
Future<void> _syncToPc() async {
|
||||
final settings = await SyncSettings.load();
|
||||
final service = _service;
|
||||
// syncBackend() falls back to GitHub alone, or pushes to Firebase alone
|
||||
// via the mirror, so either credential is enough to proceed.
|
||||
final hasFirebase = await loadAccount() != null;
|
||||
if ((!settings.isConfigured && !hasFirebase) || service == null) {
|
||||
setState(() => _status = 'Connect a sync backend in Settings first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_status = 'Syncing to PC…';
|
||||
});
|
||||
try {
|
||||
await service.setLocal(_hour, _minute);
|
||||
await service.push(settings.token);
|
||||
setState(() => _status = 'Synced ✓ — arming alarm…');
|
||||
final problem = await _arming.arm(_hour, _minute);
|
||||
if (problem != null) {
|
||||
setState(() => _status = problem);
|
||||
return;
|
||||
}
|
||||
await _arming.registerWithClockApp(_hour, _minute);
|
||||
final at = _fmt(_hour, _minute);
|
||||
setState(() => _status = 'Done! Alarm armed for $at.');
|
||||
} on Exception catch (e) {
|
||||
setState(() => _status = 'Error: $e');
|
||||
} finally {
|
||||
setState(() => _busy = false);
|
||||
}
|
||||
final message = await _publisher.publish(
|
||||
service: _service,
|
||||
hour: _hour,
|
||||
minute: _minute,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_status = message;
|
||||
_busy = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// The baseline time, shown when a one-shot override is active.
|
||||
String get _baseline =>
|
||||
_fmt(AlarmPolicy.baselineHour, AlarmPolicy.baselineMinute);
|
||||
|
||||
String _fmt(int h, int m) =>
|
||||
'${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
|
||||
|
||||
@ -178,7 +157,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
context,
|
||||
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
await _loadSavedTime();
|
||||
await _loadSyncState();
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
49
phone_app/lib/services/alarm_publisher.dart
Normal file
49
phone_app/lib/services/alarm_publisher.dart
Normal file
@ -0,0 +1,49 @@
|
||||
import 'package:wake_alarm_sync/services/alarm_arming.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
|
||||
import 'package:wake_alarm_sync/services/firebase_backend.dart';
|
||||
import 'package:wake_alarm_sync/services/sync_settings.dart';
|
||||
|
||||
/// Publishes the armed alarm to the PC and arms the phone's own alarm.
|
||||
///
|
||||
/// Split out of `home_screen.dart` for the 250-line gate, and because none of
|
||||
/// this is UI: it returns a message for the screen to render rather than
|
||||
/// calling `setState` itself, which also makes it testable without a widget.
|
||||
class AlarmPublisher {
|
||||
/// Creates a publisher; [arming] is injectable for tests.
|
||||
AlarmPublisher({AlarmArming? arming})
|
||||
: arming = arming ?? AlarmArming(AlarmScheduler());
|
||||
|
||||
/// Bridge that arms the phone's own alarm.
|
||||
final AlarmArming arming;
|
||||
|
||||
/// Publishes [hour]:[minute] and arms the alarm, returning a status line.
|
||||
///
|
||||
/// Never throws: this runs unattended from the app's init seam, so a sync
|
||||
/// failure must degrade to a message rather than take the screen down. The
|
||||
/// alarm itself is already armed by `main()` regardless of what happens
|
||||
/// here — publishing is how the PC learns the time, not how the phone rings.
|
||||
Future<String> publish({
|
||||
required AlarmSyncService? service,
|
||||
required int hour,
|
||||
required int minute,
|
||||
}) async {
|
||||
final settings = await SyncSettings.load();
|
||||
// syncBackend() falls back to GitHub alone, or pushes to Firebase alone
|
||||
// via the mirror, so either credential is enough to proceed.
|
||||
final hasFirebase = await loadAccount() != null;
|
||||
if ((!settings.isConfigured && !hasFirebase) || service == null) {
|
||||
return 'Connect a sync backend in Settings first.';
|
||||
}
|
||||
try {
|
||||
await service.setLocal(hour, minute);
|
||||
await service.push(settings.token);
|
||||
final problem = await arming.arm(hour, minute);
|
||||
if (problem != null) return problem;
|
||||
await arming.registerWithClockApp(hour, minute);
|
||||
return 'Synced to PC ✓';
|
||||
} on Exception catch (e) {
|
||||
return 'Error: $e';
|
||||
}
|
||||
}
|
||||
}
|
||||
114
wake_alarm/_sink_parsing.py
Normal file
114
wake_alarm/_sink_parsing.py
Normal file
@ -0,0 +1,114 @@
|
||||
"""Parsing the text ``pactl`` prints — cards, profiles and sinks.
|
||||
|
||||
Split out of ``_sinks.py`` for the 250-line cap. This half is pure text
|
||||
handling with no subprocess and no side effects, so it is testable without
|
||||
touching the host's audio at all; ``_sinks.py`` keeps the command execution
|
||||
and the activate/restore orchestration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
|
||||
|
||||
def _parse_profile_line(line: str) -> tuple[str, int] | None:
|
||||
"""Parse one ``output:*`` profile line into ``(name, priority)``.
|
||||
|
||||
Returns None for non-output profiles and for profiles marked
|
||||
``available: no`` (an output whose sink cannot currently be produced,
|
||||
e.g. an HDMI port with nothing plugged in).
|
||||
"""
|
||||
if not line.startswith("output:"):
|
||||
return None
|
||||
name, sep, rest = line.partition(": ")
|
||||
if not sep or "available: no" in rest:
|
||||
return None
|
||||
match = re.search(r"priority:\s*(\d+)", rest)
|
||||
priority = int(match.group(1)) if match else 0
|
||||
return name, priority
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CardInfo:
|
||||
"""A card's active profile and its available ``output:*`` profiles."""
|
||||
|
||||
active_profile: str | None
|
||||
# Available output profiles, best first (stereo preferred, then priority).
|
||||
output_profiles: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CardAccum:
|
||||
"""Mutable accumulator for one card block while parsing ``list cards``."""
|
||||
|
||||
name: str | None = None
|
||||
active: str | None = None
|
||||
profiles: list[tuple[str, int]] = field(default_factory=list)
|
||||
|
||||
|
||||
def _parse_cards(text: str) -> dict[str, _CardInfo]:
|
||||
"""Parse ``pactl list cards`` (full) into ``{card_name: _CardInfo}``."""
|
||||
cards: dict[str, _CardInfo] = {}
|
||||
cur: _CardAccum | None = None
|
||||
in_profiles = False
|
||||
for raw in text.splitlines():
|
||||
if raw.startswith("Card #"):
|
||||
_store_card(cards, cur)
|
||||
cur = _CardAccum()
|
||||
in_profiles = False
|
||||
continue
|
||||
if cur is None:
|
||||
continue
|
||||
stripped = raw.strip()
|
||||
if stripped.startswith("Name:"):
|
||||
cur.name = stripped.split(":", 1)[1].strip()
|
||||
in_profiles = False
|
||||
elif stripped.startswith("Active Profile:"):
|
||||
cur.active = stripped.split(":", 1)[1].strip()
|
||||
in_profiles = False
|
||||
elif stripped == "Profiles:":
|
||||
in_profiles = True
|
||||
elif in_profiles:
|
||||
parsed = _parse_profile_line(stripped)
|
||||
if parsed is not None:
|
||||
cur.profiles.append(parsed)
|
||||
_store_card(cards, cur)
|
||||
return cards
|
||||
|
||||
|
||||
def _store_card(cards: dict[str, _CardInfo], cur: _CardAccum | None) -> None:
|
||||
"""Finalise one parsed card block and add it to *cards* (skip if unnamed)."""
|
||||
if cur is None or cur.name is None:
|
||||
return
|
||||
# Prefer a stereo profile, then the highest priority.
|
||||
ordered = sorted(
|
||||
cur.profiles, key=lambda np: (0 if "stereo" in np[0] else 1, -np[1])
|
||||
)
|
||||
cards[cur.name] = _CardInfo(
|
||||
active_profile=cur.active,
|
||||
output_profiles=[profile for profile, _ in ordered],
|
||||
)
|
||||
|
||||
|
||||
def _parse_sinks(text: str) -> tuple[dict[str, str], dict[str, bool]]:
|
||||
"""Parse ``pactl list sinks`` into ``({sink: raw_volume}, {sink: muted})``."""
|
||||
volumes: dict[str, str] = {}
|
||||
mutes: dict[str, bool] = {}
|
||||
name: str | None = None
|
||||
for raw in text.splitlines():
|
||||
if raw.startswith("Sink #"):
|
||||
name = None
|
||||
continue
|
||||
stripped = raw.strip()
|
||||
if stripped.startswith("Name:"):
|
||||
name = stripped.split(":", 1)[1].strip()
|
||||
elif name is None:
|
||||
continue
|
||||
elif stripped.startswith("Mute:"):
|
||||
mutes[name] = stripped.split(":", 1)[1].strip() == "yes"
|
||||
elif stripped.startswith("Volume:"):
|
||||
match = re.search(r"(\d+)", stripped)
|
||||
if match:
|
||||
volumes[name] = match.group(1)
|
||||
return volumes, mutes
|
||||
@ -10,15 +10,25 @@ a single chosen output.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from wake_alarm._constants import (
|
||||
ALARM_AUDIO_SINK,
|
||||
)
|
||||
from wake_alarm._sink_parsing import (
|
||||
_CardAccum,
|
||||
_CardInfo,
|
||||
_parse_cards,
|
||||
_parse_sinks,
|
||||
)
|
||||
|
||||
# Imported under their original names because the suite patches
|
||||
# `wake_alarm._sinks._parse_cards` and friends: a moved symbol makes patch()
|
||||
# stop intercepting *silently*. __all__ stops ruff deleting them as unused.
|
||||
__all__ = ["_CardAccum", "_CardInfo", "_parse_cards", "_parse_sinks"]
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@ -90,108 +100,6 @@ def _ensure_sink_audible(pactl: str, sink: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _parse_profile_line(line: str) -> tuple[str, int] | None:
|
||||
"""Parse one ``output:*`` profile line into ``(name, priority)``.
|
||||
|
||||
Returns None for non-output profiles and for profiles marked
|
||||
``available: no`` (an output whose sink cannot currently be produced,
|
||||
e.g. an HDMI port with nothing plugged in).
|
||||
"""
|
||||
if not line.startswith("output:"):
|
||||
return None
|
||||
name, sep, rest = line.partition(": ")
|
||||
if not sep or "available: no" in rest:
|
||||
return None
|
||||
match = re.search(r"priority:\s*(\d+)", rest)
|
||||
priority = int(match.group(1)) if match else 0
|
||||
return name, priority
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CardInfo:
|
||||
"""A card's active profile and its available ``output:*`` profiles."""
|
||||
|
||||
active_profile: str | None
|
||||
# Available output profiles, best first (stereo preferred, then priority).
|
||||
output_profiles: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CardAccum:
|
||||
"""Mutable accumulator for one card block while parsing ``list cards``."""
|
||||
|
||||
name: str | None = None
|
||||
active: str | None = None
|
||||
profiles: list[tuple[str, int]] = field(default_factory=list)
|
||||
|
||||
|
||||
def _parse_cards(text: str) -> dict[str, _CardInfo]:
|
||||
"""Parse ``pactl list cards`` (full) into ``{card_name: _CardInfo}``."""
|
||||
cards: dict[str, _CardInfo] = {}
|
||||
cur: _CardAccum | None = None
|
||||
in_profiles = False
|
||||
for raw in text.splitlines():
|
||||
if raw.startswith("Card #"):
|
||||
_store_card(cards, cur)
|
||||
cur = _CardAccum()
|
||||
in_profiles = False
|
||||
continue
|
||||
if cur is None:
|
||||
continue
|
||||
stripped = raw.strip()
|
||||
if stripped.startswith("Name:"):
|
||||
cur.name = stripped.split(":", 1)[1].strip()
|
||||
in_profiles = False
|
||||
elif stripped.startswith("Active Profile:"):
|
||||
cur.active = stripped.split(":", 1)[1].strip()
|
||||
in_profiles = False
|
||||
elif stripped == "Profiles:":
|
||||
in_profiles = True
|
||||
elif in_profiles:
|
||||
parsed = _parse_profile_line(stripped)
|
||||
if parsed is not None:
|
||||
cur.profiles.append(parsed)
|
||||
_store_card(cards, cur)
|
||||
return cards
|
||||
|
||||
|
||||
def _store_card(cards: dict[str, _CardInfo], cur: _CardAccum | None) -> None:
|
||||
"""Finalise one parsed card block and add it to *cards* (skip if unnamed)."""
|
||||
if cur is None or cur.name is None:
|
||||
return
|
||||
# Prefer a stereo profile, then the highest priority.
|
||||
ordered = sorted(
|
||||
cur.profiles, key=lambda np: (0 if "stereo" in np[0] else 1, -np[1])
|
||||
)
|
||||
cards[cur.name] = _CardInfo(
|
||||
active_profile=cur.active,
|
||||
output_profiles=[profile for profile, _ in ordered],
|
||||
)
|
||||
|
||||
|
||||
def _parse_sinks(text: str) -> tuple[dict[str, str], dict[str, bool]]:
|
||||
"""Parse ``pactl list sinks`` into ``({sink: raw_volume}, {sink: muted})``."""
|
||||
volumes: dict[str, str] = {}
|
||||
mutes: dict[str, bool] = {}
|
||||
name: str | None = None
|
||||
for raw in text.splitlines():
|
||||
if raw.startswith("Sink #"):
|
||||
name = None
|
||||
continue
|
||||
stripped = raw.strip()
|
||||
if stripped.startswith("Name:"):
|
||||
name = stripped.split(":", 1)[1].strip()
|
||||
elif name is None:
|
||||
continue
|
||||
elif stripped.startswith("Mute:"):
|
||||
mutes[name] = stripped.split(":", 1)[1].strip() == "yes"
|
||||
elif stripped.startswith("Volume:"):
|
||||
match = re.search(r"(\d+)", stripped)
|
||||
if match:
|
||||
volumes[name] = match.group(1)
|
||||
return volumes, mutes
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AudioState:
|
||||
"""Snapshot of audio state to restore after the alarm is dismissed."""
|
||||
|
||||
@ -6,6 +6,7 @@ import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from wake_alarm._gating import parse_args as _parse_args
|
||||
from wake_alarm._sink_parsing import _parse_profile_line
|
||||
from wake_alarm._sinks import (
|
||||
_capture_audio_state,
|
||||
_current_default_sink,
|
||||
@ -13,7 +14,6 @@ from wake_alarm._sinks import (
|
||||
_list_real_sinks,
|
||||
_pactl_text,
|
||||
_parse_cards,
|
||||
_parse_profile_line,
|
||||
_parse_sinks,
|
||||
)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user