Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bac26836a | ||
|
|
88672e12bf | ||
|
|
cfd71a053b | ||
|
|
b76067104b | ||
|
|
95cca2e115 | ||
|
|
5df8a12339 | ||
|
|
e12f4a574c | ||
|
|
4f884b4b73 | ||
|
|
366c2f55b7 | ||
|
|
54cd56e4ec | ||
|
|
69e08d9d3a |
@@ -9,6 +9,30 @@ backfill releases from before this changelog.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-09-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Show local-day read and write totals with their recorded timezone, labelled as totals so far for the current day.
|
||||||
|
- Open the dashboard on a live three-hour written-volume graph with a read/write toggle and selected-point inspection.
|
||||||
|
- Browse history by day from the keyboard with `[`, `]`, `g` date entry, and `t` for today and the live view.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Collect every three minutes on systemd and runit, plot live points at their actual timestamps, and retain three-minute detail for 14 days before durable summaries.
|
||||||
|
- Withhold the endurance outlook until one full local observation day has usable evidence, then show it with categorical confidence.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Keep midnight-spanning activity once as shared boundary evidence instead of adding it to both days.
|
||||||
|
- Publish derived hour and day history consistently with each collection run.
|
||||||
|
|
||||||
|
## [0.3.7] - 2026-09-16
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Make usage-history axes, units, UTC boundaries, active 7/14/30/90-day window, gaps, partial periods, stacked write attribution, and hourly drill-down explicit; keep live graph data refreshed on the current three-minute cadence.
|
||||||
|
|
||||||
## [0.3.6] - 2026-09-16
|
## [0.3.6] - 2026-09-16
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ _Avoid_: Hourly record, hourly.jsonl entry
|
|||||||
One row per UTC day derived from hour observations; the grain at which usage-habit evidence is judged.
|
One row per UTC day derived from hour observations; the grain at which usage-habit evidence is judged.
|
||||||
_Avoid_: Daily summary, daily stats
|
_Avoid_: Daily summary, daily stats
|
||||||
|
|
||||||
|
**Usage-history window**:
|
||||||
|
An exact consecutive span of UTC calendar days ending today, shown from day aggregates; a day without trustworthy evidence remains an explicit gap rather than disappearing or being estimated.
|
||||||
|
_Avoid_: Available records, dataset range
|
||||||
|
|
||||||
|
**Unallocated write evidence**:
|
||||||
|
Writes known to belong to a UTC day but which cannot be assigned honestly to a particular hour; they contribute to that day's total but are never distributed across hourly bars.
|
||||||
|
_Avoid_: Missing writes, estimated hourly writes
|
||||||
|
|
||||||
**Controller segment**:
|
**Controller segment**:
|
||||||
A span of observation history within which the drive's controller identity is unchanged and counters are monotonic; write deltas are never computed across a segment boundary.
|
A span of observation history within which the drive's controller identity is unchanged and counters are monotonic; write deltas are never computed across a segment boundary.
|
||||||
_Avoid_: Counter reset handling, drive swap detection
|
_Avoid_: Counter reset handling, drive swap detection
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`)
|
|||||||
|
|
||||||
## Cadence drop-ins
|
## Cadence drop-ins
|
||||||
|
|
||||||
The default collection cadence is **5 minutes** (`OnUnitInactiveSec=5min` in the timer unit). To change it, place a systemd drop-in:
|
The default collection cadence is **3 minutes** (`OnUnitInactiveSec=3min` in the timer unit). To change it, place a systemd drop-in:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl edit fenris-collect.timer
|
sudo systemctl edit fenris-collect.timer
|
||||||
@@ -223,7 +223,7 @@ sudo systemctl edit fenris-collect.timer
|
|||||||
No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concern, not a Fenris configuration key.
|
No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concern, not a Fenris configuration key.
|
||||||
|
|
||||||
On Void, Fenris uses its native runit service instead: its initial collection
|
On Void, Fenris uses its native runit service instead: its initial collection
|
||||||
is delayed by two minutes and later collections run five minutes after the
|
is delayed by two minutes and later collections run three minutes after the
|
||||||
previous run finishes. Inspect its state and diagnostics with:
|
previous run finishes. Inspect its state and diagnostics with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Fenris's current single process combines daemonization, a PID file, an HTTP dash
|
|||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
1. **Units.** Two system units only: `fenris-collect.timer` (`WantedBy=timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`; no listener, no UI code). The TUI and CLI are ordinary unprivileged processes and never units. There is no `/run/fenris` coordination surface: systemd serializes runs, the observation store holds state, and failures go to the journal per [ADR 0001](0001-observation-store-sqlite.md).
|
1. **Units.** Two system units only: `fenris-collect.timer` (`WantedBy=timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`; no listener, no UI code). The TUI and CLI are ordinary unprivileged processes and never units. There is no `/run/fenris` coordination surface: systemd serializes runs, the observation store holds state, and failures go to the journal per [ADR 0001](0001-observation-store-sqlite.md).
|
||||||
2. **Cadence.** Default five minutes: `OnBootSec=2min`, `OnUnitInactiveSec=5min` (measured from run completion; drift accepted because hours are the evidence grain), `AccuracySec=30s`, `Persistent=no`, no suspend catch-up (absent hours classify through power-on-hours evidence), `TimeoutStartSec=90s` so a hung interrogation fails visibly. Cadence changes are documented drop-ins on the timer unit (`systemctl edit` + daemon-reload); no interval key exists in configuration.
|
2. **Cadence.** Default three minutes: `OnBootSec=2min`, `OnUnitInactiveSec=3min` (measured from run completion; drift accepted because hours are the evidence grain), `AccuracySec=30s`, `Persistent=no`, no suspend catch-up (absent hours classify through power-on-hours evidence), `TimeoutStartSec=90s` so a hung interrogation fails visibly. Cadence changes are documented drop-ins on the timer unit (`systemctl edit` + daemon-reload); no interval key exists in configuration.
|
||||||
3. **Configuration.** `/etc/fenris/fenris.conf` holds exactly one key: the device selector, a stable `/dev/disk/by-id/…` path (raw nodes accepted with an instability warning), validated at collection time. The oneshot re-reads it every run, so there is no reload path to design. An invalid selector is a bounded failed run — journal plus failed unit result, retried next interval; `status` and the TUI also read the world-readable file directly and surface a `configuration error: <reason>` fact.
|
3. **Configuration.** `/etc/fenris/fenris.conf` holds exactly one key: the device selector, a stable `/dev/disk/by-id/…` path (raw nodes accepted with an instability warning), validated at collection time. The oneshot re-reads it every run, so there is no reload path to design. An invalid selector is a bounded failed run — journal plus failed unit result, retried next interval; `status` and the TUI also read the world-readable file directly and surface a `configuration error: <reason>` fact.
|
||||||
4. **Entry points.** Two privileged binaries: `/usr/libexec/fenris/fenris-collect` (device interrogation and store writes; the unit's `ExecStart`) and `/usr/libexec/fenris/fenris-monitor` (fixed operations `enable` and `disable` with optional `--now`, plus the collect trigger, monitoring-period bookkeeping, and `baseline set`/`baseline clear` persistence for the CLI-validated endurance baseline; the only binary the polkit policy authorizes). One unprivileged `fenris` for humans: no arguments opens the TUI; subcommands (`status`, `sample`, `monitor pause`, `monitor resume`) are the CLI.
|
4. **Entry points.** Two privileged binaries: `/usr/libexec/fenris/fenris-collect` (device interrogation and store writes; the unit's `ExecStart`) and `/usr/libexec/fenris/fenris-monitor` (fixed operations `enable` and `disable` with optional `--now`, plus the collect trigger, monitoring-period bookkeeping, and `baseline set`/`baseline clear` persistence for the CLI-validated endurance baseline; the only binary the polkit policy authorizes). One unprivileged `fenris` for humans: no arguments opens the TUI; subcommands (`status`, `sample`, `monitor pause`, `monitor resume`) are the CLI.
|
||||||
5. **Sanctioned toggle.** Pause = `disable --now`; Resume = `enable --now`; both executed by `fenris-monitor`, which performs the systemctl operation and the monitoring-period bookkeeping in one step, under polkit action `com.bongbetic.fenris.monitor` (`auth_admin`, covering the collect trigger too). Root invokes the helpers directly; where no polkit agent exists the operation fails cleanly and prints the root equivalent. This amends the research's direct-systemctl toggle: a period boundary cannot be recorded by systemctl, so the toggle must be Fenris's own fixed operation.
|
5. **Sanctioned toggle.** Pause = `disable --now`; Resume = `enable --now`; both executed by `fenris-monitor`, which performs the systemctl operation and the monitoring-period bookkeeping in one step, under polkit action `com.bongbetic.fenris.monitor` (`auth_admin`, covering the collect trigger too). Root invokes the helpers directly; where no polkit agent exists the operation fails cleanly and prints the root equivalent. This amends the research's direct-systemctl toggle: a period boundary cannot be recorded by systemctl, so the toggle must be Fenris's own fixed operation.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
|
|||||||
## Collector lifecycle and privilege boundaries (ADR 0003)
|
## Collector lifecycle and privilege boundaries (ADR 0003)
|
||||||
|
|
||||||
- **LC-1** (P) Exactly two system units exist — `fenris-collect.timer` (`timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`); the TUI and CLI are ordinary unprivileged processes and never units.
|
- **LC-1** (P) Exactly two system units exist — `fenris-collect.timer` (`timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`); the TUI and CLI are ordinary unprivileged processes and never units.
|
||||||
- **LC-2** (P) Timer defaults ship as `OnBootSec=2min`, `OnUnitInactiveSec=5min`, `AccuracySec=30s`, `Persistent=no`, `TimeoutStartSec=90s`; cadence changes are documented drop-ins and no interval key exists in configuration.
|
- **LC-2** (P) Timer defaults ship as `OnBootSec=2min`, `OnUnitInactiveSec=3min`, `AccuracySec=30s`, `Persistent=no`, `TimeoutStartSec=90s`; cadence changes are documented drop-ins and no interval key exists in configuration.
|
||||||
- **LC-3** (P) A hung device interrogation fails visibly within `TimeoutStartSec=90s` as a bounded failed run retried next interval.
|
- **LC-3** (P) A hung device interrogation fails visibly within `TimeoutStartSec=90s` as a bounded failed run retried next interval.
|
||||||
- **LC-4** (A/P) `/etc/fenris/fenris.conf` holds exactly the device selector (stable `/dev/disk/by-id/…` path; raw nodes warned), re-read every run; an invalid selector is a bounded failed run surfaced as `configuration error: <reason>` in `status` and the TUI.
|
- **LC-4** (A/P) `/etc/fenris/fenris.conf` holds exactly the device selector (stable `/dev/disk/by-id/…` path; raw nodes warned), re-read every run; an invalid selector is a bounded failed run surfaced as `configuration error: <reason>` in `status` and the TUI.
|
||||||
- **LC-5** (P) Two privileged binaries ship at `/usr/libexec/fenris/fenris-collect` and `/usr/libexec/fenris/fenris-monitor`; the unprivileged `fenris` wrapper opens the TUI with no arguments.
|
- **LC-5** (P) Two privileged binaries ship at `/usr/libexec/fenris/fenris-collect` and `/usr/libexec/fenris/fenris-monitor`; the unprivileged `fenris` wrapper opens the TUI with no arguments.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Every constant is defined once, in the section named below; other sections cite,
|
|||||||
|
|
||||||
| Constant | Value | Defined in |
|
| Constant | Value | Defined in |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Collection cadence (default) | 5 min (`OnUnitInactiveSec`) | §8.2 |
|
| Collection cadence (default) | 3 min (`OnUnitInactiveSec`) | §8.2 |
|
||||||
| First-boot delay | 2 min (`OnBootSec`) | §8.2 |
|
| First-boot delay | 2 min (`OnBootSec`) | §8.2 |
|
||||||
| Timer accuracy window | 30 s (`AccuracySec`) | §8.2 |
|
| Timer accuracy window | 30 s (`AccuracySec`) | §8.2 |
|
||||||
| Collection-run timeout | 90 s (`TimeoutStartSec`) | §8.2 |
|
| Collection-run timeout | 90 s (`TimeoutStartSec`) | §8.2 |
|
||||||
@@ -479,7 +479,7 @@ Two privileged binaries — `/usr/libexec/fenris/fenris-collect` (device interro
|
|||||||
|
|
||||||
Constants defined once, consumed by TUI and CLI alike; the grade derives from the **newest sample timestamp**, never a stored flag:
|
Constants defined once, consumed by TUI and CLI alike; the grade derives from the **newest sample timestamp**, never a stored flag:
|
||||||
|
|
||||||
- **fresh** — newest sample within 2 × cadence + `AccuracySec` + 60 s (11.5 min at default cadence);
|
- **fresh** — newest sample within 2 × cadence + `AccuracySec` + 60 s (7.5 min at default cadence);
|
||||||
- **missed** — between that and 48 h (a contributing fact);
|
- **missed** — between that and 48 h (a contributing fact);
|
||||||
- **stale** — ≥ 48 h, matching the §6.7 evidence gate;
|
- **stale** — ≥ 48 h, matching the §6.7 evidence gate;
|
||||||
- **empty store** — *"no observations yet"* with an enable hint.
|
- **empty store** — *"no observations yet"* with an enable hint.
|
||||||
|
|||||||
@@ -130,9 +130,9 @@ Users add the repository to `/etc/xbps.d/xbps.conf`:
|
|||||||
repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64
|
repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64
|
||||||
```
|
```
|
||||||
|
|
||||||
Or install directly:
|
Or make a one-off request without writing the configuration file:
|
||||||
```sh
|
```sh
|
||||||
sudo xbps-install -S https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64
|
sudo xbps-install -R https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64 -M -S fenris
|
||||||
```
|
```
|
||||||
|
|
||||||
## Publication Mechanism
|
## Publication Mechanism
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ sudo xbps-install fenris # Void Linux
|
|||||||
For Void Linux, configure the XBPS repository first:
|
For Void Linux, configure the XBPS repository first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo xbps-install -S https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64
|
sudo install -d -m 0755 /etc/xbps.d
|
||||||
|
echo 'repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64' \
|
||||||
|
| sudo tee /etc/xbps.d/fenris.conf
|
||||||
|
sudo xbps-install -M -S fenris
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verify downloads
|
## Verify downloads
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fenris"
|
name = "fenris"
|
||||||
version = "0.3.6"
|
version = "0.4.0"
|
||||||
description = "NVMe wear monitor with persistent TUI"
|
description = "NVMe wear monitor with persistent TUI"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
|
|||||||
@@ -201,7 +201,8 @@ echo " Branch: ${GITEA_BRANCH}"
|
|||||||
echo " Repository URL: https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}"
|
echo " Repository URL: https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Client installation:"
|
echo "Client installation:"
|
||||||
echo " sudo xbps-install -S https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}"
|
echo " echo 'repository=https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}' | sudo tee /etc/xbps.d/fenris.conf"
|
||||||
|
echo " sudo xbps-install -M -S fenris"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Key ceremony: delete the private key after publication."
|
echo "Key ceremony: delete the private key after publication."
|
||||||
echo " See docs/install/signing-key-ceremony.md"
|
echo " See docs/install/signing-key-ceremony.md"
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
"""Fenris: NVMe wear monitor with persistent TUI."""
|
"""Fenris: NVMe wear monitor with persistent TUI."""
|
||||||
__version__ = "0.3.6"
|
__version__ = "0.4.0"
|
||||||
|
|||||||
+21
-1
@@ -325,7 +325,7 @@ def run_collection(
|
|||||||
# Find the sample we just wrote
|
# Find the sample we just wrote
|
||||||
cursor = conn.execute("SELECT id FROM samples ORDER BY id DESC LIMIT 1")
|
cursor = conn.execute("SELECT id FROM samples ORDER BY id DESC LIMIT 1")
|
||||||
current_id = cursor.fetchone()[0]
|
current_id = cursor.fetchone()[0]
|
||||||
|
|
||||||
prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id)
|
prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id)
|
||||||
if prev is not None:
|
if prev is not None:
|
||||||
# Build current sample dict for derivation
|
# Build current sample dict for derivation
|
||||||
@@ -340,6 +340,26 @@ def run_collection(
|
|||||||
"data_units_read": sample["data_units_read"],
|
"data_units_read": sample["data_units_read"],
|
||||||
}
|
}
|
||||||
derive_hours_from_interval(conn, prev, current)
|
derive_hours_from_interval(conn, prev, current)
|
||||||
|
|
||||||
|
# Rebuild day aggregates from hour observations
|
||||||
|
from .day_aggregate import derive_all_days, persist_day_aggregate
|
||||||
|
for agg in derive_all_days(conn):
|
||||||
|
persist_day_aggregate(conn, agg)
|
||||||
|
|
||||||
|
# Derive local-day summary using system timezone (issue #90)
|
||||||
|
try:
|
||||||
|
from .tz_util import detect_system_tz
|
||||||
|
from .local_day import derive_local_day_summary, persist_local_day
|
||||||
|
tz_name = detect_system_tz()
|
||||||
|
clock_now = clock.utcnow()
|
||||||
|
local_summary = derive_local_day_summary(conn, tz_name, clock_now)
|
||||||
|
if local_summary is not None:
|
||||||
|
persist_local_day(conn, local_summary)
|
||||||
|
except Exception:
|
||||||
|
# Local-day derivation failure must not prevent publication
|
||||||
|
pass
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
# Derivation failure must not prevent sample persistence (issue #73 AC6)
|
# Derivation failure must not prevent sample persistence (issue #73 AC6)
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -163,3 +163,41 @@ def derive_all_days(conn: sqlite3.Connection) -> list[DayAggregate]:
|
|||||||
if agg is not None:
|
if agg is not None:
|
||||||
results.append(agg)
|
results.append(agg)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def persist_day_aggregate(conn: sqlite3.Connection, agg: DayAggregate) -> None:
|
||||||
|
"""Upsert a derived day aggregate into the day_aggregates table.
|
||||||
|
|
||||||
|
Merges attributed bytes from hour observations with any existing
|
||||||
|
unattributed cross-hour evidence already stored for this day.
|
||||||
|
Caller must manage transactions and commits.
|
||||||
|
"""
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM day_aggregates WHERE day = ?",
|
||||||
|
(agg.day,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO day_aggregates "
|
||||||
|
"(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
|
||||||
|
" bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(agg.day, agg.seconds_active, agg.seconds_idle,
|
||||||
|
agg.seconds_powered_off, agg.seconds_unknown,
|
||||||
|
agg.bytes_written_delta, agg.bytes_read_delta,
|
||||||
|
agg.sample_count, agg.coverage),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE day_aggregates "
|
||||||
|
"SET active_seconds = ?, idle_seconds = ?, powered_off_seconds = ?, "
|
||||||
|
" unknown_seconds = ?, bytes_written_delta = ?, bytes_read_delta = ?, "
|
||||||
|
" sample_count = ?, coverage = ? "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(agg.seconds_active, agg.seconds_idle,
|
||||||
|
agg.seconds_powered_off, agg.seconds_unknown,
|
||||||
|
agg.bytes_written_delta, agg.bytes_read_delta,
|
||||||
|
agg.sample_count, agg.coverage,
|
||||||
|
existing[0]),
|
||||||
|
)
|
||||||
|
|||||||
+31
-24
@@ -175,7 +175,8 @@ def _upsert_hour_observation(
|
|||||||
"""Insert or update an hour observation."""
|
"""Insert or update an hour observation."""
|
||||||
# Check if hour exists
|
# Check if hour exists
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT id, bytes_written_delta, sample_count FROM hour_observations WHERE hour = ?",
|
"SELECT id, bytes_written_delta, bytes_read_delta, sample_count "
|
||||||
|
"FROM hour_observations WHERE hour = ?",
|
||||||
(hour_key,),
|
(hour_key,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
@@ -198,10 +199,13 @@ def _upsert_hour_observation(
|
|||||||
else:
|
else:
|
||||||
# Merge: accumulate bytes and sample count
|
# Merge: accumulate bytes and sample count
|
||||||
new_bw = existing[1] + bw_delta
|
new_bw = existing[1] + bw_delta
|
||||||
new_samples = existing[2] + sample_count
|
new_br = existing[2] + br_delta
|
||||||
|
new_samples = existing[3] + sample_count
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE hour_observations SET bytes_written_delta = ?, sample_count = ? WHERE id = ?",
|
"UPDATE hour_observations "
|
||||||
(new_bw, new_samples, existing[0]),
|
"SET bytes_written_delta = ?, bytes_read_delta = ?, sample_count = ? "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(new_bw, new_br, new_samples, existing[0]),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
@@ -213,25 +217,28 @@ def _add_unattributed_bytes(
|
|||||||
bw_delta: int,
|
bw_delta: int,
|
||||||
br_delta: int,
|
br_delta: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add unattributed byte deltas to day aggregates for each day touched."""
|
"""Store unattributed byte deltas once as shared boundary evidence.
|
||||||
prev_day = prev_ts.strftime("%Y-%m-%d")
|
|
||||||
next_day = next_ts.strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
days = {prev_day, next_day}
|
A cross-midnight interval's delta is preserved on the day where it
|
||||||
for day in days:
|
STARTS (the earlier day). It is not duplicated into both days;
|
||||||
existing = conn.execute(
|
the spec requires preserving the measured volume once as shared
|
||||||
"SELECT id FROM day_aggregates WHERE day = ?", (day,)
|
unallocated boundary evidence (issue #88).
|
||||||
).fetchone()
|
"""
|
||||||
if existing is None:
|
day = prev_ts.strftime("%Y-%m-%d")
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO day_aggregates (day, unattributed_bytes_written, unattributed_bytes_read) "
|
existing = conn.execute(
|
||||||
"VALUES (?, ?, ?)",
|
"SELECT id FROM day_aggregates WHERE day = ?", (day,)
|
||||||
(day, bw_delta, br_delta),
|
).fetchone()
|
||||||
)
|
if existing is None:
|
||||||
else:
|
conn.execute(
|
||||||
conn.execute(
|
"INSERT INTO day_aggregates (day, unattributed_bytes_written, unattributed_bytes_read) "
|
||||||
"UPDATE day_aggregates SET unattributed_bytes_written = unattributed_bytes_written + ?, "
|
"VALUES (?, ?, ?)",
|
||||||
"unattributed_bytes_read = unattributed_bytes_read + ? WHERE day = ?",
|
(day, bw_delta, br_delta),
|
||||||
(bw_delta, br_delta, day),
|
)
|
||||||
)
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE day_aggregates SET unattributed_bytes_written = unattributed_bytes_written + ?, "
|
||||||
|
"unattributed_bytes_read = unattributed_bytes_read + ? WHERE day = ?",
|
||||||
|
(bw_delta, br_delta, day),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ responsibility shared by the privileged control path and read-only status
|
|||||||
composition.
|
composition.
|
||||||
|
|
||||||
Runit service layout:
|
Runit service layout:
|
||||||
/etc/sv/fenris-collect/run — scheduler (sleep 120; loop { collect; sleep 300 })
|
/etc/sv/fenris-collect/run — scheduler (sleep 120; loop { collect; sleep 180 })
|
||||||
/etc/sv/fenris-collect/log/run — logger to /var/log/fenris-collect/
|
/etc/sv/fenris-collect/log/run — logger to /var/log/fenris-collect/
|
||||||
/var/service/fenris-collect — symlink to enable
|
/var/service/fenris-collect — symlink to enable
|
||||||
/etc/sv/fenris-collect/down — marker for dormant install
|
/etc/sv/fenris-collect/down — marker for dormant install
|
||||||
|
|
||||||
Runit guarantees:
|
Runit guarantees:
|
||||||
- Completion-relative 5-minute cadence (sleep 300 after each collect)
|
- Completion-relative 3-minute cadence (sleep 180 after each collect)
|
||||||
- Initial 2-minute boot delay (sleep 120 before first collect)
|
- Initial 2-minute boot delay (sleep 120 before first collect)
|
||||||
- Bounded execution (90s timeout via timeout(1))
|
- Bounded execution (90s timeout via timeout(1))
|
||||||
- No catch-up (service sleeps fixed interval, no Persistent= flag)
|
- No catch-up (service sleeps fixed interval, no Persistent= flag)
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"""Local-day activity derivation from UTC hour observations.
|
||||||
|
|
||||||
|
Computes durable local-day read/write summaries using the actual local
|
||||||
|
midnight boundaries, retaining UTC hour/day aggregates for endurance
|
||||||
|
projections. The collector owns this derivation, preserving the
|
||||||
|
existing read-only TUI boundary (ADR 0010).
|
||||||
|
|
||||||
|
Key contracts:
|
||||||
|
- An interval wholly attributable to a local day contributes its volume once
|
||||||
|
- Midnight-spanning intervals are retained once as shared/unallocated evidence
|
||||||
|
- UTC hour/day aggregates are never modified or deleted
|
||||||
|
- Migration cannot manufacture local precision from historical UTC data
|
||||||
|
|
||||||
|
Retention policy (issue #93):
|
||||||
|
- Raw three-minute samples are pruned after 14 days (spec §3.4, ST-5).
|
||||||
|
- Hour observations and day aggregates are retained indefinitely.
|
||||||
|
- Local-day summaries are persisted at collection time and retained
|
||||||
|
indefinitely. They survive raw-sample pruning because they depend on
|
||||||
|
hour observations, not on raw samples.
|
||||||
|
- After detail expires, aged local summaries remain queryable with their
|
||||||
|
recorded timezone and UTC boundaries. The evidence-availability flag
|
||||||
|
on each history entry indicates whether the underlying raw detail is
|
||||||
|
still present or has been pruned.
|
||||||
|
- A later timezone change does not rewrite historical day boundaries.
|
||||||
|
Each summary retains the timezone and offsets recorded at collection.
|
||||||
|
- No fabricated evidence replaces missing precision. Incomplete or
|
||||||
|
unavailable dates are labelled as such in the history readout.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LocalDaySummary:
|
||||||
|
"""One local day's aggregated read/write volumes."""
|
||||||
|
local_date: str # e.g. "2026-09-01"
|
||||||
|
tz_name: str # e.g. "Asia/Kolkata"
|
||||||
|
tz_offset: str # e.g. "+05:30"
|
||||||
|
utc_start: str # ISO 8601 UTC: the local midnight that starts this day
|
||||||
|
utc_end: str # ISO 8601 UTC: the local midnight that ends this day
|
||||||
|
bytes_written: int
|
||||||
|
bytes_read: int
|
||||||
|
coverage: float # known / (known + unknown) in UTC hours
|
||||||
|
sample_count: int
|
||||||
|
complete: bool # day's UTC range fully covered by hour observations
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LocalDayHistoryEntry:
|
||||||
|
"""A local-day summary with evidence-limit metadata for history readout.
|
||||||
|
|
||||||
|
Includes the summary data plus flags that indicate whether the
|
||||||
|
underlying raw three-minute detail is still available or has been
|
||||||
|
pruned, and whether the summary was derived from surviving evidence
|
||||||
|
or is a legacy UTC-only aggregate.
|
||||||
|
"""
|
||||||
|
local_date: str
|
||||||
|
tz_name: str
|
||||||
|
tz_offset: str
|
||||||
|
utc_start: str
|
||||||
|
utc_end: str
|
||||||
|
bytes_written: int
|
||||||
|
bytes_read: int
|
||||||
|
coverage: float
|
||||||
|
sample_count: int
|
||||||
|
complete: bool
|
||||||
|
detail_available: bool # True if raw samples for this day are within 14-day retention
|
||||||
|
derived_from_surviving: bool # True if derived from hour observations, not raw samples
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_summary(
|
||||||
|
cls,
|
||||||
|
summary: dict,
|
||||||
|
detail_available: bool,
|
||||||
|
derived_from_surviving: bool = True,
|
||||||
|
) -> "LocalDayHistoryEntry":
|
||||||
|
"""Create a history entry from a stored summary dict."""
|
||||||
|
return cls(
|
||||||
|
local_date=summary["local_date"],
|
||||||
|
tz_name=summary["tz_name"],
|
||||||
|
tz_offset=summary["tz_offset"],
|
||||||
|
utc_start=summary["utc_start"],
|
||||||
|
utc_end=summary["utc_end"],
|
||||||
|
bytes_written=summary["bytes_written"],
|
||||||
|
bytes_read=summary["bytes_read"],
|
||||||
|
coverage=summary["coverage"],
|
||||||
|
sample_count=summary["sample_count"],
|
||||||
|
complete=summary["complete"],
|
||||||
|
detail_available=detail_available,
|
||||||
|
derived_from_surviving=derived_from_surviving,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _local_midnight_utc(dt: datetime, tz_name: str) -> datetime:
|
||||||
|
"""Compute the UTC time of the local midnight that contains *dt*.
|
||||||
|
|
||||||
|
Returns the most recent local midnight in UTC. For example, if *dt*
|
||||||
|
is 2026-09-01T17:35:00+05:30 (i.e. 12:05 UTC) this returns
|
||||||
|
2026-08-31T18:30:00+00:00 (2026-09-01 00:00 in +05:30).
|
||||||
|
"""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
local_tz = ZoneInfo(tz_name)
|
||||||
|
local_dt = dt.astimezone(local_tz)
|
||||||
|
local_midnight = local_dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
return local_midnight.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_local_day_summary(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
tz_name: str,
|
||||||
|
clock_now: datetime,
|
||||||
|
) -> LocalDaySummary | None:
|
||||||
|
"""Derive a local-day summary from UTC hour observations.
|
||||||
|
|
||||||
|
Computes the UTC boundaries of the current local day, queries the
|
||||||
|
UTC hours overlapping that range, and aggregates read/write volumes.
|
||||||
|
Midnight-spanning hours are retained once as shared evidence.
|
||||||
|
|
||||||
|
Returns None if no hours exist for the local day.
|
||||||
|
"""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from .tz_util import get_tz_offset_str
|
||||||
|
|
||||||
|
local_tz = ZoneInfo(tz_name)
|
||||||
|
local_dt = clock_now.astimezone(local_tz)
|
||||||
|
local_date = local_dt.strftime("%Y-%m-%d")
|
||||||
|
tz_offset_str = get_tz_offset_str(clock_now, tz_name)
|
||||||
|
|
||||||
|
# UTC boundaries of this local day
|
||||||
|
utc_start = _local_midnight_utc(clock_now, tz_name)
|
||||||
|
next_local = local_dt + timedelta(days=1)
|
||||||
|
utc_end = next_local.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
|
||||||
|
|
||||||
|
utc_start_iso = utc_start.isoformat()
|
||||||
|
utc_end_iso = utc_end.isoformat()
|
||||||
|
|
||||||
|
# Query UTC hours overlapping the local day
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT hour, bytes_written_delta, bytes_read_delta, sample_count, "
|
||||||
|
" active_seconds, idle_seconds, powered_off_seconds, unknown_seconds "
|
||||||
|
"FROM hour_observations "
|
||||||
|
"WHERE hour >= ? AND hour < ? "
|
||||||
|
"ORDER BY hour",
|
||||||
|
(utc_start_iso, utc_end_iso),
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# Check for a midnight-spanning UTC hour before utc_start.
|
||||||
|
# When the local midnight falls inside a UTC hour (e.g. UTC+5:30
|
||||||
|
# where local midnight is 18:30 UTC), the hour 18:00 straddles the
|
||||||
|
# boundary. The main query (hour >= utc_start) excludes it because
|
||||||
|
# 18:00 < 18:30, so we must include it separately. This does NOT
|
||||||
|
# double-count: the hour falls outside the query range by
|
||||||
|
# construction (issue #93).
|
||||||
|
midnight_hour = utc_start.replace(minute=0, second=0, microsecond=0)
|
||||||
|
midnight_hour_iso = midnight_hour.strftime("%Y-%m-%dT%H:00:00+00:00")
|
||||||
|
prev_row = conn.execute(
|
||||||
|
"SELECT hour, bytes_written_delta, bytes_read_delta, sample_count "
|
||||||
|
"FROM hour_observations WHERE hour = ?",
|
||||||
|
(midnight_hour_iso,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
total_bw = 0
|
||||||
|
total_br = 0
|
||||||
|
total_samples = 0
|
||||||
|
known_seconds = 0
|
||||||
|
unknown_seconds = 0
|
||||||
|
hour_count = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
total_bw += row[1] or 0
|
||||||
|
total_br += row[2] or 0
|
||||||
|
total_samples += row[3] or 0
|
||||||
|
known_seconds += (row[4] or 0) + (row[5] or 0) + (row[6] or 0)
|
||||||
|
unknown_seconds += row[7] or 0
|
||||||
|
hour_count += 1
|
||||||
|
|
||||||
|
# Include midnight-spanning hour if it exists and is not already
|
||||||
|
# in the main query results (it won't be, since hour < utc_start).
|
||||||
|
if prev_row is not None:
|
||||||
|
# Verify this hour is NOT already counted in the main query
|
||||||
|
prev_hour_iso = prev_row[0]
|
||||||
|
already_counted = any(r[0] == prev_hour_iso for r in rows)
|
||||||
|
if not already_counted:
|
||||||
|
total_bw += prev_row[1] or 0
|
||||||
|
total_br += prev_row[2] or 0
|
||||||
|
total_samples += prev_row[3] or 0
|
||||||
|
hour_count += 1
|
||||||
|
|
||||||
|
total_evidenced = known_seconds + unknown_seconds
|
||||||
|
# Coverage is known seconds as a share of the full local day,
|
||||||
|
# not just the hours present — gaps reduce coverage
|
||||||
|
day_seconds = int((utc_end - utc_start).total_seconds())
|
||||||
|
coverage = known_seconds / day_seconds if day_seconds > 0 else 0.0
|
||||||
|
|
||||||
|
# Day is complete only if known (usable) seconds cover the full local day.
|
||||||
|
# Unknown seconds represent gaps without usable observation evidence.
|
||||||
|
complete = known_seconds >= day_seconds and hour_count > 0
|
||||||
|
|
||||||
|
# Return None if no hours exist for this local day
|
||||||
|
if hour_count == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return LocalDaySummary(
|
||||||
|
local_date=local_date,
|
||||||
|
tz_name=tz_name,
|
||||||
|
tz_offset=tz_offset_str,
|
||||||
|
utc_start=utc_start_iso,
|
||||||
|
utc_end=utc_end_iso,
|
||||||
|
bytes_written=total_bw,
|
||||||
|
bytes_read=total_br,
|
||||||
|
coverage=coverage,
|
||||||
|
sample_count=total_samples,
|
||||||
|
complete=complete,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def persist_local_day(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
summary: LocalDaySummary,
|
||||||
|
) -> bool:
|
||||||
|
"""Upsert a local-day summary. Returns True if newly created."""
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM local_days WHERE local_date = ? AND tz_name = ?",
|
||||||
|
(summary.local_date, summary.tz_name),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_days "
|
||||||
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
|
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(summary.local_date, summary.tz_name, summary.tz_offset,
|
||||||
|
summary.utc_start, summary.utc_end,
|
||||||
|
summary.bytes_written, summary.bytes_read,
|
||||||
|
summary.coverage, summary.sample_count, summary.complete),
|
||||||
|
)
|
||||||
|
created = True
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE local_days "
|
||||||
|
"SET tz_offset = ?, utc_start = ?, utc_end = ?, "
|
||||||
|
" bytes_written = ?, bytes_read = ?, "
|
||||||
|
" coverage = ?, sample_count = ?, complete = ? "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(summary.tz_offset, summary.utc_start, summary.utc_end,
|
||||||
|
summary.bytes_written, summary.bytes_read,
|
||||||
|
summary.coverage, summary.sample_count, summary.complete,
|
||||||
|
existing[0]),
|
||||||
|
)
|
||||||
|
created = False
|
||||||
|
# Caller manages transactions (collector commits once after all derivation)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def query_local_day_summary(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
local_date: str,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Query a stored local-day summary by date.
|
||||||
|
|
||||||
|
Returns a dict with the summary fields, or None if no entry exists.
|
||||||
|
"""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
|
" bytes_written, bytes_read, coverage, sample_count, complete "
|
||||||
|
"FROM local_days WHERE local_date = ? "
|
||||||
|
"ORDER BY id DESC LIMIT 1",
|
||||||
|
(local_date,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"local_date": row[0],
|
||||||
|
"tz_name": row[1],
|
||||||
|
"tz_offset": row[2],
|
||||||
|
"utc_start": row[3],
|
||||||
|
"utc_end": row[4],
|
||||||
|
"bytes_written": row[5],
|
||||||
|
"bytes_read": row[6],
|
||||||
|
"coverage": row[7],
|
||||||
|
"sample_count": row[8],
|
||||||
|
"complete": bool(row[9]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def query_current_local_day(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
clock_now: datetime,
|
||||||
|
tz_name: str,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Query the local-day summary for the current local date."""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
local_tz = ZoneInfo(tz_name)
|
||||||
|
local_dt = clock_now.astimezone(local_tz)
|
||||||
|
local_date = local_dt.strftime("%Y-%m-%d")
|
||||||
|
return query_local_day_summary(conn, local_date)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_detail_available(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
utc_start: str,
|
||||||
|
utc_end: str,
|
||||||
|
now: datetime,
|
||||||
|
retention_days: int = 14,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if raw samples covering the local-day range are still retained.
|
||||||
|
|
||||||
|
Returns True if at least one sample within [utc_start, utc_end] is
|
||||||
|
younger than retention_days. This is a conservative check; the actual
|
||||||
|
pruning boundary depends on boundary-anchor logic.
|
||||||
|
"""
|
||||||
|
cutoff = now - timedelta(days=retention_days)
|
||||||
|
cutoff_iso = cutoff.isoformat()
|
||||||
|
|
||||||
|
# If any sample in the range is newer than cutoff, detail is available
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT 1 FROM samples WHERE ts >= ? AND ts < ? AND ts >= ? LIMIT 1",
|
||||||
|
(utc_start, utc_end, cutoff_iso),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def query_local_day_history(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
now: datetime,
|
||||||
|
) -> list[LocalDayHistoryEntry]:
|
||||||
|
"""Query local-day summaries for a date range with evidence-limit metadata.
|
||||||
|
|
||||||
|
Returns history entries sorted by local_date, each annotated with
|
||||||
|
whether the underlying raw detail is still available (within the
|
||||||
|
14-day retention window) or has been pruned. Timezone information
|
||||||
|
and UTC boundaries are always present, even after detail expires.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: Connection to the observation store.
|
||||||
|
start_date: Inclusive start date (e.g. "2026-09-01").
|
||||||
|
end_date: Inclusive end date (e.g. "2026-09-14").
|
||||||
|
now: Current UTC time for retention boundary check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of LocalDayHistoryEntry sorted by local_date.
|
||||||
|
"""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
|
" bytes_written, bytes_read, coverage, sample_count, complete "
|
||||||
|
"FROM local_days "
|
||||||
|
"WHERE local_date >= ? AND local_date <= ? "
|
||||||
|
"ORDER BY local_date",
|
||||||
|
(start_date, end_date),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for row in rows:
|
||||||
|
summary = {
|
||||||
|
"local_date": row[0],
|
||||||
|
"tz_name": row[1],
|
||||||
|
"tz_offset": row[2],
|
||||||
|
"utc_start": row[3],
|
||||||
|
"utc_end": row[4],
|
||||||
|
"bytes_written": row[5],
|
||||||
|
"bytes_read": row[6],
|
||||||
|
"coverage": row[7],
|
||||||
|
"sample_count": row[8],
|
||||||
|
"complete": row[9],
|
||||||
|
}
|
||||||
|
detail_available = _is_detail_available(conn, row[3], row[4], now)
|
||||||
|
entries.append(
|
||||||
|
LocalDayHistoryEntry.from_summary(
|
||||||
|
summary,
|
||||||
|
detail_available=detail_available,
|
||||||
|
derived_from_surviving=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entries
|
||||||
@@ -483,6 +483,31 @@ def _build_pu_context_line(conn, rate, days, clock_now):
|
|||||||
return "Percentage Used: %d%%" % pu
|
return "Percentage Used: %d%%" % pu
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Complete observation day gate (issue #94)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _has_complete_local_day(conn):
|
||||||
|
"""Check if at least one complete local observation day exists.
|
||||||
|
|
||||||
|
A complete local day is a full midnight-to-midnight calendar day
|
||||||
|
within a monitoring period that has usable observation evidence.
|
||||||
|
This is the prerequisite for showing an endurance outlook.
|
||||||
|
"""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT 1 FROM local_days WHERE complete = 1 LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _count_complete_local_days(conn):
|
||||||
|
"""Count the number of complete local observation days."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM local_days WHERE complete = 1"
|
||||||
|
).fetchone()
|
||||||
|
return row[0] if row else 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Main projection function
|
# Main projection function
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -499,6 +524,30 @@ def compute_projection(conn, clock_now):
|
|||||||
tier, baseline, baseline_label, baseline_facts = _resolve_baseline(conn, current_segment)
|
tier, baseline, baseline_label, baseline_facts = _resolve_baseline(conn, current_segment)
|
||||||
facts.extend(baseline_facts)
|
facts.extend(baseline_facts)
|
||||||
|
|
||||||
|
# --- Complete observation day gate (issue #94) ---
|
||||||
|
# An endurance outlook requires at least one complete local
|
||||||
|
# midnight-to-midnight calendar day with usable observation evidence.
|
||||||
|
has_complete_day = _has_complete_local_day(conn)
|
||||||
|
if not has_complete_day:
|
||||||
|
facts.append("waiting for a full local observation day")
|
||||||
|
return ProjectionResult(
|
||||||
|
confidence_state=ConfidenceState.UNSUPPORTED,
|
||||||
|
contributing_facts=facts,
|
||||||
|
headline_remaining_seconds=None,
|
||||||
|
scenario_range=None,
|
||||||
|
pu_context_line="Percentage Used: unknown" if _get_latest_pu(conn) is None else "Percentage Used: %d%%" % (_get_latest_pu(conn) or 0),
|
||||||
|
disclosure_text=list(DISCLOSURES),
|
||||||
|
baseline_tier=tier,
|
||||||
|
baseline_label=baseline_label,
|
||||||
|
regime_days=None,
|
||||||
|
habit_change_fact=None,
|
||||||
|
warming_fact=None,
|
||||||
|
staleness_fact=None,
|
||||||
|
degraded_identity_fact=None,
|
||||||
|
zero_rate_fact=None,
|
||||||
|
qualifying_days_progress=None,
|
||||||
|
)
|
||||||
|
|
||||||
segment_days = _get_days_in_segment(conn, current_segment["opened_at"]) if current_segment else _get_all_days(conn)
|
segment_days = _get_days_in_segment(conn, current_segment["opened_at"]) if current_segment else _get_all_days(conn)
|
||||||
all_days = _get_all_days(conn)
|
all_days = _get_all_days(conn)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
Raw samples are pruned opportunistically to 14 days.
|
Raw samples are pruned opportunistically to 14 days.
|
||||||
Hour observations and day aggregates are retained indefinitely.
|
Hour observations and day aggregates are retained indefinitely.
|
||||||
Boundary anchors required for successor evidence are retained.
|
Boundary anchors required for successor evidence are retained.
|
||||||
|
|
||||||
|
Local-day summaries (local_days table) are never touched by pruning.
|
||||||
|
They are persisted at collection time from hour observations and survive
|
||||||
|
raw-sample pruning because they depend on hour observations, not on raw
|
||||||
|
samples. This is the mechanism that keeps local-day history trustworthy
|
||||||
|
after detail expires (issue #93).
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ from .init_system import (
|
|||||||
# Freshness constants (§8.9, §8.2)
|
# Freshness constants (§8.9, §8.2)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
CADENCE_DEFAULT_S = 300 # 5 min
|
CADENCE_DEFAULT_S = 180 # 3 min
|
||||||
ACCURACY_SEC = 30
|
ACCURACY_SEC = 30
|
||||||
FRESH_THRESHOLD_S = 2 * CADENCE_DEFAULT_S + ACCURACY_SEC + 60 # 690 s
|
FRESH_THRESHOLD_S = 2 * CADENCE_DEFAULT_S + ACCURACY_SEC + 60 # 450 s
|
||||||
STALENESS_THRESHOLD_S = 48 * 3600 # 48 h
|
STALENESS_THRESHOLD_S = 48 * 3600 # 48 h
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+46
-1
@@ -12,7 +12,7 @@ from typing import Optional
|
|||||||
|
|
||||||
|
|
||||||
# Schema version - increment on each migration
|
# Schema version - increment on each migration
|
||||||
SCHEMA_VERSION = 2
|
SCHEMA_VERSION = 3
|
||||||
|
|
||||||
|
|
||||||
# Packaged default placement (spec §8.3). The config may override it, but a
|
# Packaged default placement (spec §8.3). The config may override it, but a
|
||||||
@@ -193,6 +193,26 @@ def _create_schema(conn: sqlite3.Connection):
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
# Local-day activity summaries derived from UTC hour observations.
|
||||||
|
# Each row retains its recorded timezone and UTC boundaries so that
|
||||||
|
# historical summaries survive a system-timezone change (ADR 0010).
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS local_days (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
local_date TEXT NOT NULL, -- e.g. "2026-09-01" in the recorded tz
|
||||||
|
tz_name TEXT NOT NULL, -- POSIX tz name, e.g. "Asia/Kolkata"
|
||||||
|
tz_offset TEXT NOT NULL, -- e.g. "+05:30"
|
||||||
|
utc_start TEXT NOT NULL, -- ISO 8601 UTC: local midnight start
|
||||||
|
utc_end TEXT NOT NULL, -- ISO 8601 UTC: local midnight end
|
||||||
|
bytes_written INTEGER DEFAULT 0,
|
||||||
|
bytes_read INTEGER DEFAULT 0,
|
||||||
|
coverage REAL DEFAULT 0.0,
|
||||||
|
sample_count INTEGER DEFAULT 0,
|
||||||
|
complete BOOLEAN DEFAULT 0,
|
||||||
|
UNIQUE(local_date, tz_name)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
# Metadata table for store state (e.g., legacy import marker)
|
# Metadata table for store state (e.g., legacy import marker)
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS store_metadata (
|
CREATE TABLE IF NOT EXISTS store_metadata (
|
||||||
@@ -230,6 +250,31 @@ def _apply_migrations(conn: sqlite3.Connection, current_version: int):
|
|||||||
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0")
|
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0")
|
||||||
current_version = 2
|
current_version = 2
|
||||||
|
|
||||||
|
# Migration 2→3: add local_days table for local-day activity totals
|
||||||
|
# (issue #90, ADR 0010). Pure addition — no existing rows touched.
|
||||||
|
if current_version < 3:
|
||||||
|
tables = {row[0] for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()}
|
||||||
|
if "local_days" not in tables:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS local_days (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
local_date TEXT NOT NULL,
|
||||||
|
tz_name TEXT NOT NULL,
|
||||||
|
tz_offset TEXT NOT NULL,
|
||||||
|
utc_start TEXT NOT NULL,
|
||||||
|
utc_end TEXT NOT NULL,
|
||||||
|
bytes_written INTEGER DEFAULT 0,
|
||||||
|
bytes_read INTEGER DEFAULT 0,
|
||||||
|
coverage REAL DEFAULT 0.0,
|
||||||
|
sample_count INTEGER DEFAULT 0,
|
||||||
|
complete BOOLEAN DEFAULT 0,
|
||||||
|
UNIQUE(local_date, tz_name)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
current_version = 3
|
||||||
|
|
||||||
|
|
||||||
def migrate_to_latest(store_path: Path) -> int:
|
def migrate_to_latest(store_path: Path) -> int:
|
||||||
"""Apply forward-only migrations to bring the store to SCHEMA_VERSION.
|
"""Apply forward-only migrations to bring the store to SCHEMA_VERSION.
|
||||||
|
|||||||
+936
-151
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
"""System timezone detection for local-day activity totals.
|
||||||
|
|
||||||
|
Provides timezone detection (TZ env, /etc/localtime) and offset
|
||||||
|
computation used by local-day derivation. All functions are
|
||||||
|
stateless and safe to call from the collector and TUI reader.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def detect_system_tz() -> str:
|
||||||
|
"""Detect the system timezone name.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. ``TZ`` environment variable (may be empty or ":UTC")
|
||||||
|
2. Symlink target of ``/etc/localtime``
|
||||||
|
3. Falls back to ``"UTC"``
|
||||||
|
|
||||||
|
Returns a POSIX timezone name like ``"Asia/Kolkata"`` or ``"UTC"``.
|
||||||
|
"""
|
||||||
|
tz = os.environ.get("TZ", "").strip()
|
||||||
|
if tz:
|
||||||
|
return tz.lstrip(":")
|
||||||
|
|
||||||
|
localtime = Path("/etc/localtime")
|
||||||
|
if localtime.is_symlink():
|
||||||
|
target = os.readlink(str(localtime))
|
||||||
|
# Strip common prefixes: /usr/share/zoneinfo/, /usr/lib/zoneinfo/
|
||||||
|
for prefix in ("/usr/share/zoneinfo/", "/usr/lib/zoneinfo/"):
|
||||||
|
if target.startswith(prefix):
|
||||||
|
return target[len(prefix):]
|
||||||
|
return target
|
||||||
|
|
||||||
|
return "UTC"
|
||||||
|
|
||||||
|
|
||||||
|
def get_tz_offset_str(dt: datetime, tz_name: str) -> str:
|
||||||
|
"""UTC offset as ``+HH:MM`` or ``-HH:MM`` string for *dt* in *tz_name*."""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
local_tz = ZoneInfo(tz_name)
|
||||||
|
local_dt = dt.astimezone(local_tz)
|
||||||
|
offset = local_dt.utcoffset()
|
||||||
|
total_seconds = int(offset.total_seconds())
|
||||||
|
sign = "+" if total_seconds >= 0 else "-"
|
||||||
|
total_seconds = abs(total_seconds)
|
||||||
|
hours = total_seconds // 3600
|
||||||
|
minutes = (total_seconds % 3600) // 60
|
||||||
|
return "%s%02d:%02d" % (sign, hours, minutes)
|
||||||
@@ -109,12 +109,40 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
|||||||
ensure_period_open(conn, datetime.fromisoformat(start))
|
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start=None, utc_end=None, bw=1024*1024*100,
|
||||||
|
br=0, coverage=0.95, samples=24, complete=True):
|
||||||
|
"""Insert a local_days row (issue #94 gate prerequisite)."""
|
||||||
|
if utc_start is None:
|
||||||
|
utc_start = local_date + "T00:00:00+00:00"
|
||||||
|
if utc_end is None:
|
||||||
|
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
|
||||||
|
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_days "
|
||||||
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
|
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||||
|
bw, br, coverage, samples, complete),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
|
||||||
|
"""Insert multiple complete local days to satisfy the issue #94 gate."""
|
||||||
|
for i in range(count):
|
||||||
|
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_local_day(conn, d, bw=bw)
|
||||||
|
|
||||||
|
|
||||||
def _setup_full_store(conn, *, baseline=True, segment=True, days=30,
|
def _setup_full_store(conn, *, baseline=True, segment=True, days=30,
|
||||||
bw=1024*1024*100, coverage=0.95, samples_per_day=24,
|
bw=1024*1024*100, coverage=0.95, samples_per_day=24,
|
||||||
sample_ts="2026-09-30T10:00:00+00:00",
|
sample_ts="2026-09-30T10:00:00+00:00",
|
||||||
period_start="2026-09-01T00:00:00+00:00",
|
period_start="2026-09-01T00:00:00+00:00",
|
||||||
segment_opened="2026-09-01T00:00:00+00:00",
|
segment_opened="2026-09-01T00:00:00+00:00",
|
||||||
baseline_kw=None, segment_kw=None):
|
baseline_kw=None, segment_kw=None,
|
||||||
|
local_days=True):
|
||||||
if baseline:
|
if baseline:
|
||||||
_insert_baseline(conn, **(baseline_kw or {}))
|
_insert_baseline(conn, **(baseline_kw or {}))
|
||||||
if segment:
|
if segment:
|
||||||
@@ -125,6 +153,9 @@ def _setup_full_store(conn, *, baseline=True, segment=True, days=30,
|
|||||||
_insert_day(conn, d, bw=bw, coverage=coverage, samples=samples_per_day)
|
_insert_day(conn, d, bw=bw, coverage=coverage, samples=samples_per_day)
|
||||||
if sample_ts:
|
if sample_ts:
|
||||||
_insert_sample(conn, sample_ts)
|
_insert_sample(conn, sample_ts)
|
||||||
|
# Issue #94: satisfy the complete-observation-day gate
|
||||||
|
if local_days and days > 0:
|
||||||
|
_insert_complete_local_days(conn, "2026-09-29", 1, bw=bw)
|
||||||
|
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
@@ -217,6 +248,7 @@ class TestCI1StateMatrix:
|
|||||||
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(conn, d, bw=1024*1024*100)
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||||
proj = compute_projection(conn, _clock())
|
proj = compute_projection(conn, _clock())
|
||||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -230,6 +262,7 @@ class TestCI1StateMatrix:
|
|||||||
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||||
proj = compute_projection(conn, _clock())
|
proj = compute_projection(conn, _clock())
|
||||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||||
assert proj.warming_fact is not None
|
assert proj.warming_fact is not None
|
||||||
@@ -245,6 +278,7 @@ class TestCI1StateMatrix:
|
|||||||
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||||||
stale_ts = (_clock() - timedelta(days=5)).isoformat()
|
stale_ts = (_clock() - timedelta(days=5)).isoformat()
|
||||||
_insert_sample(conn, stale_ts)
|
_insert_sample(conn, stale_ts)
|
||||||
|
_insert_complete_local_days(conn, "2026-08-30", 1)
|
||||||
proj = compute_projection(conn, _clock())
|
proj = compute_projection(conn, _clock())
|
||||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||||
assert proj.staleness_fact is not None
|
assert proj.staleness_fact is not None
|
||||||
@@ -259,6 +293,7 @@ class TestCI1StateMatrix:
|
|||||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||||
proj = compute_projection(conn, _clock())
|
proj = compute_projection(conn, _clock())
|
||||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||||
assert proj.degraded_identity_fact is not None
|
assert proj.degraded_identity_fact is not None
|
||||||
@@ -273,6 +308,7 @@ class TestCI1StateMatrix:
|
|||||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(conn, d, bw=0)
|
_insert_day(conn, d, bw=0)
|
||||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||||
proj = compute_projection(conn, _clock())
|
proj = compute_projection(conn, _clock())
|
||||||
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
|
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
assert proj.zero_rate_fact is not None
|
assert proj.zero_rate_fact is not None
|
||||||
|
|||||||
@@ -79,6 +79,20 @@ def test_release_footer_verifies_the_clearsigned_checksum_asset():
|
|||||||
assert "sha256sum -c SHA256SUMS" in footer
|
assert "sha256sum -c SHA256SUMS" in footer
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_footer_configures_the_xbps_url_as_a_repository():
|
||||||
|
footer = (REPO_ROOT / "packaging" / "release-footer.md").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
repository = (
|
||||||
|
"repository=https://git.bongbetic.com/xavierk/Fenris-xbps/"
|
||||||
|
"raw/branch/stable/x86_64"
|
||||||
|
)
|
||||||
|
assert repository in footer
|
||||||
|
assert "sudo xbps-install -M -S fenris" in footer
|
||||||
|
assert "sudo xbps-install -S https://git.bongbetic.com" not in footer
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("changelog", "expected_error"),
|
("changelog", "expected_error"),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ class TestSchemaMigration:
|
|||||||
|
|
||||||
# Migrate
|
# Migrate
|
||||||
steps = migrate_to_latest(db)
|
steps = migrate_to_latest(db)
|
||||||
assert steps == 1
|
assert steps == 2 # v1→v2→v3
|
||||||
|
|
||||||
# Verify data preserved
|
# Verify data preserved
|
||||||
conn = sqlite3.connect(str(db))
|
conn = sqlite3.connect(str(db))
|
||||||
|
|||||||
@@ -244,10 +244,11 @@ def test_store_initialization(config_fixture: Dict[str, Any]):
|
|||||||
"controller_segments",
|
"controller_segments",
|
||||||
"endurance_baseline",
|
"endurance_baseline",
|
||||||
}
|
}
|
||||||
|
|
||||||
# sqlite_sequence is a system table created by AUTOINCREMENT
|
# sqlite_sequence is a system table created by AUTOINCREMENT
|
||||||
expected_tables.add("sqlite_sequence")
|
expected_tables.add("sqlite_sequence")
|
||||||
expected_tables.add("store_metadata")
|
expected_tables.add("store_metadata")
|
||||||
|
expected_tables.add("local_days")
|
||||||
assert expected_tables == tables
|
assert expected_tables == tables
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""Complete observation day gate tests (issue #94).
|
||||||
|
|
||||||
|
Verifies that the endurance projection is withheld until at least one
|
||||||
|
complete local calendar day has been observed within a monitoring period.
|
||||||
|
|
||||||
|
Seams:
|
||||||
|
- compute_projection() → gate check via local_days table
|
||||||
|
- ProjectionResult.contributing_facts → "waiting for a full local observation day"
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
- Gate-1: No complete local day → UNSUPPORTED with waiting fact
|
||||||
|
- Gate-2: One complete local day → Limited confidence (if other conditions met)
|
||||||
|
- Gate-3: Partial days don't satisfy the gate
|
||||||
|
- Gate-4: CLI and TUI share the same gate via compute_projection()
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.store import init_store
|
||||||
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
|
from fenris.projection import (
|
||||||
|
compute_projection, ConfidenceState, BaselineTier,
|
||||||
|
WARMING_COVERAGE_FLOOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(tmp_path):
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
yield conn
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _clock(year=2026, month=9, day=30, hour=12):
|
||||||
|
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_baseline(conn, tbw_tb=1.0, verified=True):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO endurance_baseline "
|
||||||
|
"(tbw_terabytes, source_url, document_revision, entry_date, model_string, "
|
||||||
|
" nominal_capacity_bytes, validated_by, verified, created_at, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(tbw_tb, "https://example.com/spec", "v1.0", "2026-01-01",
|
||||||
|
"Samsung SSD 970 EVO Plus 1TB", 1024000000000,
|
||||||
|
"machine_match" if verified else None, verified,
|
||||||
|
"2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00"):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO controller_segments "
|
||||||
|
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(opened_at, "nqn.test", False, "nqn.test", "SN123",
|
||||||
|
"Samsung SSD 970 EVO Plus 1TB", "FW1", "0x144d", "0x144d", "pcie"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO day_aggregates (day, active_seconds, idle_seconds, powered_off_seconds, "
|
||||||
|
"unknown_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(day, 3600, 0, 0, 0, bw, 0, samples, coverage),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_sample(conn, ts, pu=5):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||||
|
"percentage_used, bytes_written, bytes_read, power_on_hours) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(ts, "/dev/nvme0n1", 1000000, 500000, pu, 512000000000, 256000000000, 8765),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start=None, utc_end=None, bw=1024*1024*100,
|
||||||
|
br=0, coverage=0.95, samples=24, complete=True):
|
||||||
|
"""Insert a local_days row for testing the gate."""
|
||||||
|
if utc_start is None:
|
||||||
|
utc_start = local_date + "T00:00:00+00:00"
|
||||||
|
if utc_end is None:
|
||||||
|
# Next day
|
||||||
|
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
|
||||||
|
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_days "
|
||||||
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
|
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||||
|
bw, br, coverage, samples, complete),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||||
|
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gate-1: No complete local day → UNSUPPORTED with waiting fact
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGateNoCompleteDay:
|
||||||
|
"""Projection is unavailable before any complete local observation day."""
|
||||||
|
|
||||||
|
def test_no_local_days_unsupported(self, store):
|
||||||
|
"""With no local_days entries, projection is UNSUPPORTED."""
|
||||||
|
_insert_baseline(store)
|
||||||
|
_insert_segment(store)
|
||||||
|
_open_period(store)
|
||||||
|
# 14 days of UTC data — enough for normal projection, but no local_days
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
|
assert any("full local observation day" in f for f in result.contributing_facts)
|
||||||
|
assert result.headline_remaining_seconds is None
|
||||||
|
|
||||||
|
def test_only_partial_local_days_unsupported(self, store):
|
||||||
|
"""Partial (incomplete) local days don't satisfy the gate."""
|
||||||
|
_insert_baseline(store)
|
||||||
|
_insert_segment(store)
|
||||||
|
_open_period(store)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
# Insert only incomplete local days
|
||||||
|
for i in range(5):
|
||||||
|
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_local_day(store, d, complete=False, coverage=0.3)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
|
assert any("full local observation day" in f for f in result.contributing_facts)
|
||||||
|
|
||||||
|
def test_gate_before_warming_check(self, store):
|
||||||
|
"""Gate fires even when warming would also block — gate has precedence."""
|
||||||
|
_insert_baseline(store)
|
||||||
|
_insert_segment(store)
|
||||||
|
_open_period(store)
|
||||||
|
# Only 3 days of data (below warming threshold)
|
||||||
|
for i in range(3):
|
||||||
|
d = (datetime(2026, 9, 27) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
# Insert one complete local day — but still below warming
|
||||||
|
_insert_local_day(store, "2026-09-29", complete=True)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
# Gate is satisfied (one complete day), but warming blocks Supported
|
||||||
|
# The key assertion: gate message should NOT appear when gate IS met
|
||||||
|
assert not any("full local observation day" in f for f in result.contributing_facts)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gate-2: One complete local day → Limited confidence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGateOneCompleteDay:
|
||||||
|
"""After one complete local day, projection can proceed with Limited confidence."""
|
||||||
|
|
||||||
|
def test_one_complete_day_allows_projection(self, store):
|
||||||
|
"""With one complete local day and valid baseline/rate, projection is Limited."""
|
||||||
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
||||||
|
_insert_segment(store)
|
||||||
|
_open_period(store)
|
||||||
|
# 14 days of UTC data
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
# One complete local day
|
||||||
|
_insert_local_day(store, "2026-09-29", complete=True)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
# Gate satisfied — no "waiting" fact
|
||||||
|
assert not any("full local observation day" in f for f in result.contributing_facts)
|
||||||
|
# With only 14 days and other Limited factors, should be Limited or Supported
|
||||||
|
assert result.confidence_state in (ConfidenceState.LIMITED, ConfidenceState.SUPPORTED)
|
||||||
|
# Headline should exist (rate > 0, baseline exists)
|
||||||
|
assert result.headline_remaining_seconds is not None
|
||||||
|
|
||||||
|
def test_gate_fact_absent_when_satisfied(self, store):
|
||||||
|
"""The 'waiting for full day' fact does not appear when gate is met."""
|
||||||
|
_insert_baseline(store)
|
||||||
|
_insert_segment(store)
|
||||||
|
_open_period(store)
|
||||||
|
for i in range(20):
|
||||||
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_local_day(store, "2026-09-29", complete=True)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
assert not any("full local observation day" in f for f in result.contributing_facts)
|
||||||
|
assert result.confidence_state != ConfidenceState.UNSUPPORTED
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gate-3: Partial first day doesn't satisfy the gate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGatePartialFirstDay:
|
||||||
|
"""Starting monitoring at noon means the partial first day doesn't count."""
|
||||||
|
|
||||||
|
def test_partial_first_day_not_enough(self, store):
|
||||||
|
"""A single incomplete local day (started at noon) doesn't open the gate."""
|
||||||
|
_insert_baseline(store)
|
||||||
|
_insert_segment(store, opened_at="2026-09-29T12:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-29T12:00:00+00:00")
|
||||||
|
# Only Sep 29 (partial) and Sep 30 (today, partial)
|
||||||
|
_insert_day(store, "2026-09-29", bw=1024*1024*100, coverage=0.5, samples=12)
|
||||||
|
_insert_day(store, "2026-09-30", bw=1024*1024*100, coverage=0.5, samples=12)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
# Only partial local days
|
||||||
|
_insert_local_day(store, "2026-09-29", complete=False, coverage=0.5)
|
||||||
|
_insert_local_day(store, "2026-09-30", complete=False, coverage=0.5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
|
assert any("full local observation day" in f for f in result.contributing_facts)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gate-4: Multiple complete days also satisfy the gate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGateMultipleCompleteDays:
|
||||||
|
"""Multiple complete local days satisfy the gate."""
|
||||||
|
|
||||||
|
def test_multiple_complete_days_satisfy_gate(self, store):
|
||||||
|
"""Several complete local days open the gate."""
|
||||||
|
_insert_baseline(store)
|
||||||
|
_insert_segment(store)
|
||||||
|
_open_period(store)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
# 7 complete local days
|
||||||
|
for i in range(7):
|
||||||
|
d = (datetime(2026, 9, 23) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_local_day(store, d, complete=True)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
assert not any("full local observation day" in f for f in result.contributing_facts)
|
||||||
|
assert result.headline_remaining_seconds is not None
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Cross-day interval unattributed-byte preservation (issue #88).
|
||||||
|
|
||||||
|
Verifies that a cross-hour interval spanning midnight is stored ONCE as
|
||||||
|
shared boundary evidence, not duplicated into both days.
|
||||||
|
|
||||||
|
Seam: derive._add_unattributed_bytes() → day_aggregates.unattributed_bytes_*
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.collector import run_collection
|
||||||
|
from fenris.store import init_store
|
||||||
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
|
|
||||||
|
|
||||||
|
def _make_smartctl(duw: int, dur: int):
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155",
|
||||||
|
"build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw,
|
||||||
|
"data_units_read": dur, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sysfs_tree(tmp_path: Path) -> Path:
|
||||||
|
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
ctrl_dir.mkdir(parents=True)
|
||||||
|
(ctrl_dir / "subsysnqn").write_text(
|
||||||
|
"nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n"
|
||||||
|
)
|
||||||
|
(ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n")
|
||||||
|
(ctrl_dir / "serial").write_text("S4EWNX0N123456\n")
|
||||||
|
(ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n")
|
||||||
|
transport_dir = ctrl_dir / "transport"
|
||||||
|
transport_dir.mkdir()
|
||||||
|
(transport_dir / "address").write_text("0000:03:00.0")
|
||||||
|
(transport_dir / "trstring").write_text("pcie")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
def __init__(self, initial):
|
||||||
|
self.now = initial
|
||||||
|
def utcnow(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrossDayUnattributedNoDuplication:
|
||||||
|
"""Unattributed bytes from a midnight-spanning interval must be stored
|
||||||
|
once, not duplicated into both days (issue #88)."""
|
||||||
|
|
||||||
|
def test_midnight_spanning_interval_not_duplicated(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
"""Two samples spanning midnight: 23:55 UTC day1 → 00:05 UTC day2.
|
||||||
|
|
||||||
|
The unattributed bytes should appear once, not in both days.
|
||||||
|
"""
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
t1 = datetime(2026, 9, 1, 23, 55, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 2, 0, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# +100 DUW, +60 DUR across midnight
|
||||||
|
r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
assert r1["ok"]
|
||||||
|
r2 = run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
assert r2["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
|
||||||
|
# Get unattributed bytes for each day
|
||||||
|
unattr_w_day1 = conn.execute(
|
||||||
|
"SELECT unattributed_bytes_written FROM day_aggregates WHERE day = ?",
|
||||||
|
("2026-09-01",)
|
||||||
|
).fetchone()
|
||||||
|
unattr_w_day2 = conn.execute(
|
||||||
|
"SELECT unattributed_bytes_written FROM day_aggregates WHERE day = ?",
|
||||||
|
("2026-09-02",)
|
||||||
|
).fetchone()
|
||||||
|
unattr_r_day1 = conn.execute(
|
||||||
|
"SELECT unattributed_bytes_read FROM day_aggregates WHERE day = ?",
|
||||||
|
("2026-09-01",)
|
||||||
|
).fetchone()
|
||||||
|
unattr_r_day2 = conn.execute(
|
||||||
|
"SELECT unattributed_bytes_read FROM day_aggregates WHERE day = ?",
|
||||||
|
("2026-09-02",)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
expected_bw = 100 * 512000 # 51200000
|
||||||
|
expected_br = 60 * 512000 # 30720000
|
||||||
|
|
||||||
|
# BUG: Current code adds the SAME bytes to BOTH days.
|
||||||
|
# After fix: only ONE day should have the unattributed bytes.
|
||||||
|
# The spec says: preserve once as shared boundary evidence.
|
||||||
|
|
||||||
|
# At least one day must have the unattributed bytes
|
||||||
|
total_unattr_w = (unattr_w_day1[0] if unattr_w_day1 else 0) + (unattr_w_day2[0] if unattr_w_day2 else 0)
|
||||||
|
total_unattr_r = (unattr_r_day1[0] if unattr_r_day1 else 0) + (unattr_r_day2[0] if unattr_r_day2 else 0)
|
||||||
|
|
||||||
|
# The total unattributed bytes across both days must equal
|
||||||
|
# the actual delta (not double)
|
||||||
|
assert total_unattr_w == expected_bw, (
|
||||||
|
f"Unattributed writes across both days should be {expected_bw}, "
|
||||||
|
f"got {total_unattr_w} (duplication detected)"
|
||||||
|
)
|
||||||
|
assert total_unattr_r == expected_br, (
|
||||||
|
f"Unattributed reads across both days should be {expected_br}, "
|
||||||
|
f"got {total_unattr_r} (duplication detected)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Neither day should have MORE than the actual delta
|
||||||
|
for day_label, uw, ur in [
|
||||||
|
("day1", unattr_w_day1, unattr_r_day1),
|
||||||
|
("day2", unattr_w_day2, unattr_r_day2),
|
||||||
|
]:
|
||||||
|
if uw is not None:
|
||||||
|
assert uw[0] <= expected_bw, (
|
||||||
|
f"{day_label} unattributed writes {uw[0]} exceeds delta {expected_bw}"
|
||||||
|
)
|
||||||
|
if ur is not None:
|
||||||
|
assert ur[0] <= expected_br, (
|
||||||
|
f"{day_label} unattributed reads {ur[0]} exceeds delta {expected_br}"
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
"""Edge-case tests for issue #72 remaining acceptance criteria.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- TPH-#34-36: Pause-crossing intervals
|
||||||
|
- TPH-#59: Staleness frozen estimate wording
|
||||||
|
- TPH-#62: Zero-delta evidence in live graph
|
||||||
|
- TPH-#63: Horizon anchoring at evidence endpoint
|
||||||
|
- TPH-#64: Burst/habit checks with unknown daily shares
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.store import init_store
|
||||||
|
from fenris.monitoring_periods import ensure_period_open, close_period
|
||||||
|
from fenris.derive import derive_hours_from_interval
|
||||||
|
from fenris.day_aggregate import derive_day
|
||||||
|
from fenris.projection import compute_projection, ConfidenceState, STALENESS_HOURS
|
||||||
|
from fenris.tui import _query_live_graph_data, _query_daily_graph_data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _clock(year=2026, month=10, day=1, hour=12):
|
||||||
|
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_sample(conn, ts, bw=512000000000, br=256000000000, segment_id=1):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||||
|
"percentage_used, bytes_written, bytes_read, power_on_hours, segment_id) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(ts, "/dev/nvme0n1", 1000000, 500000, 5, bw, br, 8765, segment_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
|
||||||
|
identity_key="nqn.test", degraded=False):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO controller_segments "
|
||||||
|
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(opened_at, identity_key, degraded, "nqn.test", "SN123",
|
||||||
|
"Samsung SSD 970 EVO Plus 1TB", "FW1", "0x144d", "0x144d", "pcie"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_baseline(conn, tbw_tb=1.0, verified=True):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO endurance_baseline "
|
||||||
|
"(tbw_terabytes, source_url, document_revision, entry_date, model_string, "
|
||||||
|
" nominal_capacity_bytes, validated_by, verified, created_at, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(tbw_tb, "https://example.com/spec", "v1.0", "2026-01-01",
|
||||||
|
"Samsung SSD 970 EVO Plus 1TB", 1024000000000,
|
||||||
|
"machine_match" if verified else None, verified,
|
||||||
|
"2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_day(conn, day, bw=10*1024*1024*1024, br=0, coverage=0.95, samples=24):
|
||||||
|
"""Insert a day aggregate with ~10GB writes (realistic for projection)."""
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO day_aggregates (day, active_seconds, idle_seconds, powered_off_seconds, "
|
||||||
|
"unknown_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(day, 3600, 0, 0, 0, bw, br, samples, coverage),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start=None, utc_end=None, bw=10*1024*1024*1024,
|
||||||
|
br=0, coverage=0.95, samples=24, complete=True):
|
||||||
|
"""Insert a local_days row for testing the observation day gate."""
|
||||||
|
if utc_start is None:
|
||||||
|
utc_start = local_date + "T00:00:00+00:00"
|
||||||
|
if utc_end is None:
|
||||||
|
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
|
||||||
|
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_days "
|
||||||
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
|
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||||
|
bw, br, coverage, samples, complete),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_hour(conn, hour_iso, bw=0, br=0, sample_count=1,
|
||||||
|
active=3600, idle=0, powered_off=0, unknown=0):
|
||||||
|
known = active + idle + powered_off
|
||||||
|
coverage = known / 3600.0
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO hour_observations "
|
||||||
|
"(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
|
||||||
|
" bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(hour_iso, active, idle, powered_off, unknown, bw, br, sample_count, coverage),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TPH-#34-36: Pause-crossing intervals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPauseCrossingIntervals:
|
||||||
|
"""Intervals crossing a pause boundary preserve evidence correctly."""
|
||||||
|
|
||||||
|
def test_interval_crossing_pause_preserves_total(self, tmp_path):
|
||||||
|
"""An interval that spans a pause boundary keeps the full delta
|
||||||
|
in hour observations; the monitoring-period exclusion happens at
|
||||||
|
the day-aggregate coverage level."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
|
||||||
|
t1 = datetime(2026, 10, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 10, 1, 11, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t3 = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
ensure_period_open(conn, t1)
|
||||||
|
_insert_sample(conn, t1.isoformat(), bw=100000000000)
|
||||||
|
_insert_sample(conn, t2.isoformat(), bw=200000000000)
|
||||||
|
|
||||||
|
prev = {"id": 1, "ts": t1.isoformat(), "bytes_written": 100000000000,
|
||||||
|
"bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35,
|
||||||
|
"data_units_written": 1000000, "data_units_read": 0}
|
||||||
|
curr = {"id": 2, "ts": t2.isoformat(), "bytes_written": 200000000000,
|
||||||
|
"bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35,
|
||||||
|
"data_units_written": 2000000, "data_units_read": 0}
|
||||||
|
derive_hours_from_interval(conn, prev, curr)
|
||||||
|
|
||||||
|
close_period(conn, t2, "user_disabled")
|
||||||
|
ensure_period_open(conn, t3)
|
||||||
|
_insert_sample(conn, t3.isoformat(), bw=300000000000)
|
||||||
|
|
||||||
|
prev2 = {"id": 2, "ts": t2.isoformat(), "bytes_written": 200000000000,
|
||||||
|
"bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35,
|
||||||
|
"data_units_written": 2000000, "data_units_read": 0}
|
||||||
|
curr2 = {"id": 3, "ts": t3.isoformat(), "bytes_written": 300000000000,
|
||||||
|
"bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35,
|
||||||
|
"data_units_written": 3000000, "data_units_read": 0}
|
||||||
|
derive_hours_from_interval(conn, prev2, curr2)
|
||||||
|
|
||||||
|
hour11 = conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations WHERE hour = ?",
|
||||||
|
("2026-10-01T11:00:00+00:00",)
|
||||||
|
).fetchone()
|
||||||
|
assert hour11 is not None
|
||||||
|
assert hour11[0] == 100000000000
|
||||||
|
|
||||||
|
agg = derive_day(conn, "2026-10-01")
|
||||||
|
assert agg is not None
|
||||||
|
assert agg.bytes_written_delta == 100000000000
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_deliberate_pause_vs_raw_stop(self, tmp_path):
|
||||||
|
"""Deliberate pause closes period with user_disabled;
|
||||||
|
raw service stop leaves period open (interrupted)."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
|
||||||
|
t1 = datetime(2026, 10, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||||||
|
ensure_period_open(conn, t1)
|
||||||
|
_insert_sample(conn, t1.isoformat())
|
||||||
|
|
||||||
|
close_period(conn, t1 + timedelta(hours=1), "user_disabled")
|
||||||
|
|
||||||
|
period = conn.execute(
|
||||||
|
"SELECT ended_at, end_cause FROM monitoring_periods WHERE ended_at IS NOT NULL"
|
||||||
|
).fetchone()
|
||||||
|
assert period is not None
|
||||||
|
assert period[1] == "user_disabled"
|
||||||
|
|
||||||
|
t2 = datetime(2026, 10, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||||
|
ensure_period_open(conn, t2)
|
||||||
|
_insert_sample(conn, t2.isoformat())
|
||||||
|
|
||||||
|
open_periods = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM monitoring_periods WHERE ended_at IS NULL"
|
||||||
|
).fetchone()
|
||||||
|
assert open_periods[0] == 1
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TPH-#59: Staleness frozen estimate wording
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestStalenessFact:
|
||||||
|
"""Projection includes staleness fact when evidence is old."""
|
||||||
|
|
||||||
|
def test_staleness_fact_present_when_old(self, tmp_path):
|
||||||
|
"""When newest data is ≥48h old, projection includes staleness fact."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_insert_baseline(conn)
|
||||||
|
|
||||||
|
base = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
for i in range(14):
|
||||||
|
day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, day, bw=10*1024*1024*1024)
|
||||||
|
_insert_local_day(conn, day, complete=True)
|
||||||
|
|
||||||
|
clock = datetime(2026, 10, 2, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
proj = compute_projection(conn, clock)
|
||||||
|
|
||||||
|
assert proj.staleness_fact is not None
|
||||||
|
assert "48" in proj.staleness_fact or "old" in proj.staleness_fact
|
||||||
|
|
||||||
|
staleness_in_facts = any("old" in f or "48" in f for f in proj.contributing_facts)
|
||||||
|
assert staleness_in_facts
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_no_staleness_fact_when_fresh(self, tmp_path):
|
||||||
|
"""When newest data is recent, no staleness fact."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_insert_baseline(conn)
|
||||||
|
|
||||||
|
base = datetime(2026, 9, 18, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
for i in range(14):
|
||||||
|
day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, day, bw=10*1024*1024*1024)
|
||||||
|
_insert_local_day(conn, day, complete=True)
|
||||||
|
|
||||||
|
clock = datetime(2026, 10, 2, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
proj = compute_projection(conn, clock)
|
||||||
|
|
||||||
|
assert proj.staleness_fact is None
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TPH-#62: Zero-delta evidence in live graph
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestZeroDeltaEvidence:
|
||||||
|
"""Zero-delta intervals are distinguished from missing evidence."""
|
||||||
|
|
||||||
|
def test_zero_delta_shown_as_zero(self, tmp_path):
|
||||||
|
"""Two identical readings produce a zero-delta interval."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
|
||||||
|
_insert_sample(conn, (now - timedelta(minutes=6)).isoformat(), bw=100000000000)
|
||||||
|
_insert_sample(conn, (now - timedelta(minutes=3)).isoformat(), bw=100000000000)
|
||||||
|
|
||||||
|
data = _query_live_graph_data(conn, now)
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["bytes_written"] == 0
|
||||||
|
assert data[0]["is_zero"] is True
|
||||||
|
assert data[0]["is_gap"] is False
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_gap_distinguished_from_zero(self, tmp_path):
|
||||||
|
"""A gap (missed collection) with non-zero delta is distinct from measured zero."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
|
||||||
|
_insert_sample(conn, (now - timedelta(hours=2)).isoformat(), bw=100000000000)
|
||||||
|
_insert_sample(conn, (now - timedelta(minutes=3)).isoformat(), bw=200000000000)
|
||||||
|
|
||||||
|
data = _query_live_graph_data(conn, now)
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["is_gap"] is True
|
||||||
|
assert data[0]["is_zero"] is False
|
||||||
|
assert data[0]["bytes_written"] == 100000000000
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TPH-#63: Horizon anchoring at evidence endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestHorizonAnchoring:
|
||||||
|
"""Scenario windows end at the latest evidence endpoint T."""
|
||||||
|
|
||||||
|
def test_horizon_rate_uses_evidence_endpoint(self, tmp_path):
|
||||||
|
"""Horizon rates are computed from the latest evidence endpoint,
|
||||||
|
not from clock_now."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_insert_baseline(conn)
|
||||||
|
ensure_period_open(conn, datetime(2026, 9, 10, 0, 0, 0, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
# Insert 28 days of data ending 5 days ago with realistic writes
|
||||||
|
base = datetime(2026, 9, 10, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
for i in range(28):
|
||||||
|
day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, day, bw=10*1024*1024*1024) # ~10GB/day
|
||||||
|
_insert_local_day(conn, day, complete=True)
|
||||||
|
|
||||||
|
clock = datetime(2026, 10, 13, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
proj = compute_projection(conn, clock)
|
||||||
|
|
||||||
|
# Scenario range should exist with rates
|
||||||
|
assert proj.scenario_range is not None
|
||||||
|
assert len(proj.scenario_range.rates) > 0
|
||||||
|
|
||||||
|
for horizon in [7, 28]:
|
||||||
|
if horizon in proj.scenario_range.rates:
|
||||||
|
rate = proj.scenario_range.rates[horizon]
|
||||||
|
assert rate > 0, f"Horizon {horizon} should have positive rate"
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TPH-#64: Burst/habit checks with unknown daily shares
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBurstChecks:
|
||||||
|
"""Burst and habit checks handle unknown daily shares correctly."""
|
||||||
|
|
||||||
|
def test_habit_change_detected(self, tmp_path):
|
||||||
|
"""Habit change detection works with clear write rate change."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn, opened_at="2026-08-01T00:00:00+00:00")
|
||||||
|
_insert_baseline(conn, tbw_tb=10.0)
|
||||||
|
ensure_period_open(conn, datetime(2026, 8, 1, 0, 0, 0, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
# 28 days of normal usage (100 MiB/day)
|
||||||
|
bw_normal = 100 * 1024 * 1024
|
||||||
|
for i in range(28):
|
||||||
|
day = (datetime(2026, 8, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, day, bw=bw_normal)
|
||||||
|
_insert_local_day(conn, day, complete=True, bw=bw_normal)
|
||||||
|
|
||||||
|
# 10 days of high usage (300 MiB/day = 3x, triggers habit change)
|
||||||
|
bw_high = 300 * 1024 * 1024
|
||||||
|
for i in range(10):
|
||||||
|
day = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, day, bw=bw_high)
|
||||||
|
_insert_local_day(conn, day, complete=True, bw=bw_high)
|
||||||
|
|
||||||
|
clock = datetime(2026, 9, 8, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
proj = compute_projection(conn, clock)
|
||||||
|
|
||||||
|
# Should have a habit change fact (3× increase for 10 days)
|
||||||
|
assert proj.habit_change_fact is not None
|
||||||
|
assert "usage habit changed" in proj.habit_change_fact
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_zero_delta_not_treated_as_unknown(self, tmp_path):
|
||||||
|
"""A measured zero-delta interval is not treated as missing evidence."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
|
||||||
|
_insert_sample(conn, (now - timedelta(minutes=6)).isoformat(), bw=100000000000)
|
||||||
|
_insert_sample(conn, (now - timedelta(minutes=3)).isoformat(), bw=100000000000)
|
||||||
|
|
||||||
|
data = _query_live_graph_data(conn, now)
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["is_zero"] is True
|
||||||
|
assert data[0]["is_gap"] is False
|
||||||
|
assert data[0]["bytes_written"] == 0
|
||||||
|
|
||||||
|
conn.close()
|
||||||
@@ -86,6 +86,33 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start=None, utc_end=None, bw=1024*1024*100,
|
||||||
|
br=0, coverage=0.95, samples=24, complete=True):
|
||||||
|
"""Insert a local_days row (issue #94 gate prerequisite)."""
|
||||||
|
if utc_start is None:
|
||||||
|
utc_start = local_date + "T00:00:00+00:00"
|
||||||
|
if utc_end is None:
|
||||||
|
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
|
||||||
|
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_days "
|
||||||
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
|
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||||
|
bw, br, coverage, samples, complete),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
|
||||||
|
"""Insert multiple complete local days to satisfy the issue #94 gate."""
|
||||||
|
for i in range(count):
|
||||||
|
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_local_day(conn, d, bw=bw)
|
||||||
|
|
||||||
|
|
||||||
class TestHonestQualifyingProgress:
|
class TestHonestQualifyingProgress:
|
||||||
"""Issue #77: Show honest qualifying-day progress and confidence."""
|
"""Issue #77: Show honest qualifying-day progress and confidence."""
|
||||||
|
|
||||||
@@ -103,6 +130,7 @@ class TestHonestQualifyingProgress:
|
|||||||
_insert_day(store, d, bw=bw, coverage=cov)
|
_insert_day(store, d, bw=bw, coverage=cov)
|
||||||
|
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
# After warming, qualifying_days_progress shows honest count
|
# After warming, qualifying_days_progress shows honest count
|
||||||
@@ -125,6 +153,7 @@ class TestHonestQualifyingProgress:
|
|||||||
_insert_day(store, d, bw=bw, samples=samples)
|
_insert_day(store, d, bw=bw, samples=samples)
|
||||||
|
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
# After warming, qualifying_days_progress shows honest count
|
# After warming, qualifying_days_progress shows honest count
|
||||||
@@ -146,6 +175,7 @@ class TestHonestQualifyingProgress:
|
|||||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
|
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
# Should be warming
|
# Should be warming
|
||||||
@@ -164,6 +194,7 @@ class TestHonestQualifyingProgress:
|
|||||||
_insert_day(store, d, bw=0, coverage=0.95)
|
_insert_day(store, d, bw=0, coverage=0.95)
|
||||||
|
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
# Zero rate should be UNSUPPORTED
|
# Zero rate should be UNSUPPORTED
|
||||||
@@ -185,6 +216,7 @@ class TestHonestQualifyingProgress:
|
|||||||
_insert_day(store, d, bw=bw, coverage=cov)
|
_insert_day(store, d, bw=bw, coverage=cov)
|
||||||
|
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
# Should still be warming (3 days below coverage > WARMING_MAX_LOW_COVERAGE=2)
|
# Should still be warming (3 days below coverage > WARMING_MAX_LOW_COVERAGE=2)
|
||||||
@@ -205,6 +237,7 @@ class TestHonestQualifyingProgress:
|
|||||||
_insert_day(store, d, bw=bw, samples=samples)
|
_insert_day(store, d, bw=bw, samples=samples)
|
||||||
|
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
# Should still be warming
|
# Should still be warming
|
||||||
@@ -225,6 +258,7 @@ class TestHonestQualifyingProgress:
|
|||||||
_insert_day(store, d, bw=bw, coverage=cov)
|
_insert_day(store, d, bw=bw, coverage=cov)
|
||||||
|
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
# Should not be warming (14 total, 2 below coverage <= WARMING_MAX_LOW_COVERAGE)
|
# Should not be warming (14 total, 2 below coverage <= WARMING_MAX_LOW_COVERAGE)
|
||||||
@@ -247,6 +281,7 @@ class TestHonestQualifyingProgress:
|
|||||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
|
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
# Should not be warming
|
# Should not be warming
|
||||||
|
|||||||
+18
-11
@@ -171,8 +171,8 @@ class TestKeyBindings:
|
|||||||
assert "m" in binding_keys
|
assert "m" in binding_keys
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_t_cycles_through_themes(self, tmp_path):
|
async def test_t_goes_to_today(self, tmp_path):
|
||||||
"""Pressing t cycles through the three presets."""
|
"""Pressing t returns to today's live view (issue #91)."""
|
||||||
from fenris.tui import FenrisTuiApp
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
config_home = _make_prefs_dir(tmp_path)
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
@@ -182,12 +182,17 @@ class TestKeyBindings:
|
|||||||
# Start at amber
|
# Start at amber
|
||||||
assert app.theme == "fenris-amber"
|
assert app.theme == "fenris-amber"
|
||||||
|
|
||||||
# Press t to cycle
|
# Enter drill mode on the graph
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
graph.view_mode = "hourly"
|
||||||
|
graph.drill_day = "2026-09-30"
|
||||||
|
|
||||||
|
# Press t to go back to today
|
||||||
await pilot.press("t")
|
await pilot.press("t")
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
# Should be nord or high_contrast now
|
# Should be back in daily mode
|
||||||
assert app.theme in ("fenris-nord", "fenris-high-contrast")
|
assert graph.view_mode == "daily"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_m_toggles_reduced_motion(self, tmp_path):
|
async def test_m_toggles_reduced_motion(self, tmp_path):
|
||||||
@@ -226,21 +231,23 @@ class TestPersistence:
|
|||||||
async def test_theme_survives_restart(self, tmp_path):
|
async def test_theme_survives_restart(self, tmp_path):
|
||||||
"""Theme preference persists across TUI restart."""
|
"""Theme preference persists across TUI restart."""
|
||||||
from fenris.tui import FenrisTuiApp
|
from fenris.tui import FenrisTuiApp
|
||||||
|
from fenris.preferences import save_preferences
|
||||||
|
|
||||||
config_home = _make_prefs_dir(tmp_path)
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
# First run: change theme
|
# First run: change theme via preferences API
|
||||||
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app1.run_test() as pilot:
|
async with app1.run_test() as pilot:
|
||||||
await pilot.press("t")
|
save_preferences(theme="nord", reduced_motion=False)
|
||||||
await pilot.pause()
|
app1._current_theme_name = "nord"
|
||||||
theme_after_t = app1.theme
|
app1.theme = "fenris-nord"
|
||||||
assert theme_after_t != "fenris-amber"
|
theme_after = app1.theme
|
||||||
|
assert theme_after == "fenris-nord"
|
||||||
|
|
||||||
# Second run: theme should persist
|
# Second run: theme should persist
|
||||||
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app2.run_test() as pilot:
|
async with app2.run_test() as pilot:
|
||||||
assert app2.theme == theme_after_t
|
assert app2.theme == theme_after
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reduced_motion_survives_restart(self, tmp_path):
|
async def test_reduced_motion_survives_restart(self, tmp_path):
|
||||||
|
|||||||
@@ -0,0 +1,592 @@
|
|||||||
|
"""Tests for issue #92: Browse activity dates from the keyboard.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- AC92-1: [ and ] select previous/next day, g opens date entry, t returns to today/live
|
||||||
|
- AC92-2: Date entry accepts valid dates, reports errors, returns on cancel
|
||||||
|
- AC92-3: Selecting a date obtains correct local-day evidence
|
||||||
|
- AC92-4: Historical date, measurement, and context remain stable across refresh
|
||||||
|
- AC92-5: t resolves to today/live, themes through discoverable control
|
||||||
|
- AC92-6: Drill-down shows available data with distinguishable states
|
||||||
|
- AC92-7: Keyboard actions verified from normal launch with headless driver
|
||||||
|
- AC92-8: Equivalent clickable actions at 80x24 and constrained widths
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.store import init_store
|
||||||
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
|
from fenris.local_day import (
|
||||||
|
LocalDaySummary,
|
||||||
|
persist_local_day,
|
||||||
|
query_local_day_summary,
|
||||||
|
)
|
||||||
|
from fenris.tui import (
|
||||||
|
FenrisTuiApp,
|
||||||
|
DailyBarGraph,
|
||||||
|
_RANGE_OPTIONS,
|
||||||
|
_RANGE_DEFAULT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _clock(year=2026, month=9, day=30, hour=12):
|
||||||
|
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
|
||||||
|
identity_key="nqn.test"):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO controller_segments "
|
||||||
|
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(opened_at, identity_key, False, "nqn.test", "SN123",
|
||||||
|
"Samsung SSD 970 EVO Plus 1TB", "FW1", "0x144d", "0x144d", "pcie"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO day_aggregates (day, active_seconds, idle_seconds, powered_off_seconds, "
|
||||||
|
"unknown_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(day, 3600, 0, 0, 0, bw, 0, samples, coverage),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||||
|
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||||
|
bw=300, br=130, complete=True):
|
||||||
|
summary = LocalDaySummary(
|
||||||
|
local_date=local_date,
|
||||||
|
tz_name=tz_name,
|
||||||
|
tz_offset=tz_offset,
|
||||||
|
utc_start=f"{local_date}T00:00:00+00:00",
|
||||||
|
utc_end=f"{(datetime.fromisoformat(local_date) + timedelta(days=1)).strftime('%Y-%m-%d')}T00:00:00+00:00",
|
||||||
|
bytes_written=bw,
|
||||||
|
bytes_read=br,
|
||||||
|
coverage=0.95,
|
||||||
|
sample_count=24,
|
||||||
|
complete=complete,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, summary)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_store(tmp_path, n_days=30, today=None):
|
||||||
|
"""Create a store with n_days of data and return (conn, clock)."""
|
||||||
|
if today is None:
|
||||||
|
today = _clock()
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(n_days):
|
||||||
|
d = (today - timedelta(days=n_days - 1 - i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d)
|
||||||
|
return conn, today
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app(tmp_path, clock=None):
|
||||||
|
"""Create a FenrisTuiApp with the clock mocked to *clock*.
|
||||||
|
|
||||||
|
Returns (app, patcher) - caller must keep patcher alive during the test.
|
||||||
|
"""
|
||||||
|
if clock is None:
|
||||||
|
clock = _clock()
|
||||||
|
from unittest.mock import patch
|
||||||
|
import fenris.tui as tui_mod
|
||||||
|
from datetime import datetime as _real_datetime
|
||||||
|
|
||||||
|
mock_dt = type("MockDatetime", (), {
|
||||||
|
"now": staticmethod(lambda tz=None: clock),
|
||||||
|
"strptime": staticmethod(lambda *a, **kw: _real_datetime.strptime(*a, **kw)),
|
||||||
|
"date": staticmethod(lambda: clock.date()),
|
||||||
|
})()
|
||||||
|
|
||||||
|
patcher = patch.object(tui_mod, "datetime", mock_dt)
|
||||||
|
patcher.start()
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
return app, patcher
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-1: Keyboard bindings exist
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBindingExistence:
|
||||||
|
def test_open_square_bracket_binding(self, tmp_path):
|
||||||
|
"""The [ binding exists for previous day navigation."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
binding_keys = {b.key for b in app.BINDINGS}
|
||||||
|
assert "left_square_bracket" in binding_keys
|
||||||
|
|
||||||
|
def test_close_square_bracket_binding(self, tmp_path):
|
||||||
|
"""The ] binding exists for next day navigation."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
binding_keys = {b.key for b in app.BINDINGS}
|
||||||
|
assert "right_square_bracket" in binding_keys
|
||||||
|
|
||||||
|
def test_g_binding_exists(self, tmp_path):
|
||||||
|
"""The g binding exists for date entry."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
binding_keys = {b.key for b in app.BINDINGS}
|
||||||
|
assert "g" in binding_keys
|
||||||
|
|
||||||
|
def test_t_binding_is_today(self, tmp_path):
|
||||||
|
"""The t binding maps to action_today, not a theme action."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
t_binding = next(b for b in app.BINDINGS if b.key == "t")
|
||||||
|
assert "today" in t_binding.action.lower()
|
||||||
|
|
||||||
|
def test_pause_resume_quit_bindings_exist(self, tmp_path):
|
||||||
|
"""Existing bindings are preserved."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
binding_keys = {b.key for b in app.BINDINGS}
|
||||||
|
assert "p" in binding_keys # pause
|
||||||
|
assert "r" in binding_keys # resume
|
||||||
|
assert "q" in binding_keys # quit
|
||||||
|
assert "d" in binding_keys # disclosures
|
||||||
|
assert "m" in binding_keys # motion toggle
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-1: Bracket navigation from normal launch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBracketNavigation:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_right_bracket_advances_day(self, tmp_path):
|
||||||
|
"""The ] key moves to the next (newer) day when not at the end."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
# Move to an older day first so ] can advance
|
||||||
|
for _ in range(3):
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
day_before = graph._day_data[graph.selected_index].get("day")
|
||||||
|
|
||||||
|
await pilot.press("right_square_bracket")
|
||||||
|
day_after = graph._day_data[graph.selected_index].get("day")
|
||||||
|
# ] should move to a newer day (closer to today)
|
||||||
|
assert day_after > day_before
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_left_bracket_goes_to_previous_day(self, tmp_path):
|
||||||
|
"""The [ key moves to the previous (older) day."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
day_before = graph._day_data[graph.selected_index].get("day")
|
||||||
|
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
day_after = graph._day_data[graph.selected_index].get("day")
|
||||||
|
# [ should move to an older day (further from today)
|
||||||
|
assert day_after < day_before
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bracket_navigates_outside_visible_range(self, tmp_path):
|
||||||
|
"""[ beyond the current range shifts the visible window."""
|
||||||
|
app_clock = _clock()
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=30, today=app_clock)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app, patcher = _make_app(tmp_path, clock=app_clock)
|
||||||
|
try:
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
assert graph.range_days == _RANGE_DEFAULT
|
||||||
|
|
||||||
|
# Navigate to oldest day by pressing [ many times
|
||||||
|
for _ in range(29):
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
|
||||||
|
# Graph should now show a range that includes the oldest day
|
||||||
|
assert graph.selected_index >= 0
|
||||||
|
selected_day = graph._day_data[graph.selected_index].get("day")
|
||||||
|
oldest_day = (app_clock - timedelta(days=29)).strftime("%Y-%m-%d")
|
||||||
|
assert selected_day == oldest_day
|
||||||
|
finally:
|
||||||
|
patcher.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bracket_at_boundary_does_not_move(self, tmp_path):
|
||||||
|
"""] at the last day does not move the selection."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
initial_idx = graph.selected_index
|
||||||
|
|
||||||
|
await pilot.press("right_square_bracket")
|
||||||
|
assert graph.selected_index == initial_idx
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-1: Arrow keys inspect the selected view
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestArrowInspection:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_arrow_keys_still_work_in_daily_view(self, tmp_path):
|
||||||
|
"""Arrow keys move the selection within the visible daily view."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
# Focus the graph widget explicitly
|
||||||
|
graph.focus()
|
||||||
|
await pilot.pause()
|
||||||
|
day_before = graph._day_data[graph.selected_index].get("day")
|
||||||
|
|
||||||
|
await pilot.press("left")
|
||||||
|
day_after = graph._day_data[graph.selected_index].get("day")
|
||||||
|
assert day_after != day_before
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_arrow_keys_inspect_hours_in_drill(self, tmp_path):
|
||||||
|
"""Arrow keys navigate hours when in hourly drill-down."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
await pilot.click("#usage-history")
|
||||||
|
|
||||||
|
# Enter drill-down
|
||||||
|
await pilot.press("enter")
|
||||||
|
assert graph.view_mode == "hourly"
|
||||||
|
|
||||||
|
# Arrow keys navigate hours
|
||||||
|
await pilot.press("left")
|
||||||
|
assert graph._hourly_selected >= 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-1: t returns to today/live
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestTodayBinding:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_t_returns_to_today_after_browsing(self, tmp_path):
|
||||||
|
"""The t key returns to today's live view after browsing."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
|
||||||
|
# Browse to an older day using [
|
||||||
|
for _ in range(5):
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
browsed_day = graph._day_data[graph.selected_index].get("day")
|
||||||
|
today = clock.strftime("%Y-%m-%d")
|
||||||
|
assert browsed_day != today
|
||||||
|
|
||||||
|
# Press t to return to today
|
||||||
|
await pilot.press("t")
|
||||||
|
assert graph.selected_index == len(graph._day_data) - 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_t_exits_hourly_drill(self, tmp_path):
|
||||||
|
"""The t key exits hourly drill-down back to daily view."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
await pilot.click("#usage-history")
|
||||||
|
|
||||||
|
# Enter drill
|
||||||
|
await pilot.press("enter")
|
||||||
|
assert graph.view_mode == "hourly"
|
||||||
|
|
||||||
|
# t returns to daily
|
||||||
|
await pilot.press("t")
|
||||||
|
assert graph.view_mode == "daily"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-2: Date entry screen
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDateEntry:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_g_opens_date_entry(self, tmp_path):
|
||||||
|
"""The g key opens the date entry screen."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("g")
|
||||||
|
await pilot.pause()
|
||||||
|
# Date picker should be pushed as a screen
|
||||||
|
assert len(app.screen_stack) > 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_valid_date_navigates(self, tmp_path):
|
||||||
|
"""Entering a valid date navigates to that day."""
|
||||||
|
app_clock = _clock()
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=30, today=app_clock)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app, patcher = _make_app(tmp_path, clock=app_clock)
|
||||||
|
try:
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
target_date = (app_clock - timedelta(days=5)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Simulate the date picker callback directly
|
||||||
|
app._go_to_date_callback(target_date)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
selected_day = graph._day_data[graph.selected_index].get("day")
|
||||||
|
assert selected_day == target_date
|
||||||
|
finally:
|
||||||
|
patcher.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_date_shows_error(self, tmp_path):
|
||||||
|
"""Entering an invalid date shows an error message."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("g")
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("n", "o", "t", " ", "a", " ", "d", "a", "t", "e")
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause()
|
||||||
|
# Should remain on date picker (error shown, not dismissed)
|
||||||
|
assert len(app.screen_stack) > 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancel_returns_to_prior(self, tmp_path):
|
||||||
|
"""Pressing escape in date entry returns to the prior view."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
prior_idx = graph.selected_index
|
||||||
|
|
||||||
|
await pilot.press("g")
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Should be back to the graph with the same selection
|
||||||
|
assert graph.selected_index == prior_idx
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-3: Selecting a date obtains correct local-day evidence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLocalDayEvidence:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bracket_shows_local_day_for_selected_date(self, tmp_path):
|
||||||
|
"""Navigating with brackets shows the local-day summary for that date."""
|
||||||
|
app_clock = _clock()
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14, today=app_clock)
|
||||||
|
# Insert local-day data for a day within the 14-day range
|
||||||
|
target = (app_clock - timedelta(days=4)).strftime("%Y-%m-%d")
|
||||||
|
_insert_local_day(conn, target, bw=500, br=200)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app, patcher = _make_app(tmp_path, clock=app_clock)
|
||||||
|
try:
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
|
||||||
|
# Navigate to the target day
|
||||||
|
for _ in range(4):
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
local_day = app.query_one("#local-day")
|
||||||
|
text = str(local_day.render())
|
||||||
|
assert target in text
|
||||||
|
finally:
|
||||||
|
patcher.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_date_picker_shows_local_day(self, tmp_path):
|
||||||
|
"""Picking a date via the date picker shows its local-day summary."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
target = (clock - timedelta(days=10)).strftime("%Y-%m-%d")
|
||||||
|
_insert_local_day(conn, target, bw=100, br=50)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
# Simulate the date picker callback directly
|
||||||
|
app._go_to_date_callback(target)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
local_day = app.query_one("#local-day")
|
||||||
|
text = str(local_day.render())
|
||||||
|
assert target in text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-4: Browsing stability across background refresh
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBrowsingStability:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_selection_stable_across_refresh(self, tmp_path):
|
||||||
|
"""Browsing selection is preserved when background refresh fires."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=0.1)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
|
||||||
|
# Browse to a specific day
|
||||||
|
for _ in range(3):
|
||||||
|
await pilot.press("right_square_bracket")
|
||||||
|
browsed_day = graph._day_data[graph.selected_index].get("day")
|
||||||
|
|
||||||
|
# Wait for at least one refresh tick
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Selection should be preserved
|
||||||
|
assert graph.selected_index >= 0
|
||||||
|
assert graph._day_data[graph.selected_index].get("day") == browsed_day
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_navigate_away_does_not_jump_on_refresh(self, tmp_path):
|
||||||
|
"""After navigating away from today, refresh does not jump back."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=0.1)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
|
||||||
|
# Browse to older day using [
|
||||||
|
for _ in range(5):
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
|
||||||
|
browsed_day = graph._day_data[graph.selected_index].get("day")
|
||||||
|
today = clock.strftime("%Y-%m-%d")
|
||||||
|
assert browsed_day != today
|
||||||
|
|
||||||
|
# Wait for refresh
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Should NOT jump back to today
|
||||||
|
selected_day = graph._day_data[graph.selected_index].get("day")
|
||||||
|
assert selected_day != today
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-6: Drill-down shows available data
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDrillDownData:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_drill_down_shows_loading_then_data(self, tmp_path):
|
||||||
|
"""Drill-down transitions from loading to actual data."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
await pilot.click("#usage-history")
|
||||||
|
|
||||||
|
# Enter drill-down
|
||||||
|
await pilot.press("enter")
|
||||||
|
assert graph.view_mode == "hourly"
|
||||||
|
|
||||||
|
# After drill callback, data should be populated
|
||||||
|
# (empty since we have no hour_observations)
|
||||||
|
assert graph.view_mode == "hourly"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC92-8: Constrained width behavior
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestConstrainedWidth:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bracket_navigation_works_at_80x24(self, tmp_path):
|
||||||
|
"""Date navigation works at minimum 80x24 terminal size."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
day_before = graph._day_data[graph.selected_index].get("day")
|
||||||
|
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
day_after = graph._day_data[graph.selected_index].get("day")
|
||||||
|
assert day_after < day_before
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_g_opens_date_entry_at_80x24(self, tmp_path):
|
||||||
|
"""Date entry opens at minimum terminal size."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("g")
|
||||||
|
await pilot.pause()
|
||||||
|
assert len(app.screen_stack) > 1
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
"""Lifecycle tests for local-day history after detail expires (issue #93).
|
||||||
|
|
||||||
|
Verifies the full lifecycle: raw samples collected, local-day summaries
|
||||||
|
persisted, raw samples pruned after 14 days, and aged local-day summaries
|
||||||
|
remain queryable with correct timezone, boundaries, and evidence-limit
|
||||||
|
metadata.
|
||||||
|
|
||||||
|
Seams:
|
||||||
|
- Write side: collector.run_collection() → local_day derivation → persist
|
||||||
|
- Pruning: prune_old_samples() removes raw samples but preserves local_days
|
||||||
|
- Read side: query_local_day_history() returns entries with evidence flags
|
||||||
|
- Repair: repair_derivation() does not touch existing local_days
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.store import init_store, SCHEMA_VERSION
|
||||||
|
from fenris.local_day import (
|
||||||
|
derive_local_day_summary,
|
||||||
|
persist_local_day,
|
||||||
|
query_local_day_summary,
|
||||||
|
query_local_day_history,
|
||||||
|
LocalDaySummary,
|
||||||
|
LocalDayHistoryEntry,
|
||||||
|
_is_detail_available,
|
||||||
|
)
|
||||||
|
from fenris.pruning import prune_old_samples, RAW_SAMPLE_RETENTION_DAYS
|
||||||
|
from fenris.repair import repair_derivation
|
||||||
|
from fenris.monitoring_periods import ensure_period_open, close_period
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_smartctl(duw: int, dur: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155",
|
||||||
|
"build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw,
|
||||||
|
"data_units_read": dur, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sysfs_tree(tmp_path: Path) -> Path:
|
||||||
|
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
ctrl_dir.mkdir(parents=True)
|
||||||
|
(ctrl_dir / "subsysnqn").write_text(
|
||||||
|
"nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n"
|
||||||
|
)
|
||||||
|
(ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n")
|
||||||
|
(ctrl_dir / "serial").write_text("S4EWNX0N123456\n")
|
||||||
|
(ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n")
|
||||||
|
transport_dir = ctrl_dir / "transport"
|
||||||
|
transport_dir.mkdir()
|
||||||
|
(transport_dir / "address").write_text("0000:03:00.0")
|
||||||
|
(transport_dir / "trstring").write_text("pcie")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
def __init__(self, initial: datetime):
|
||||||
|
self.now = initial
|
||||||
|
def utcnow(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_hour(conn, hour_iso, bw=0, br=0, sample_count=1,
|
||||||
|
active=3600, idle=0, powered_off=0, unknown=0):
|
||||||
|
"""Insert a UTC hour observation."""
|
||||||
|
known = active + idle + powered_off
|
||||||
|
coverage = known / 3600.0
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO hour_observations "
|
||||||
|
"(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
|
||||||
|
" bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(hour_iso, active, idle, powered_off, unknown, bw, br, sample_count, coverage),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_sample(conn, ts_iso, bw=0, br=0, device="/dev/nvme0"):
|
||||||
|
"""Insert a raw sample."""
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||||
|
" bytes_written, bytes_read, percentage_used) VALUES (?, ?, 0, 0, ?, ?, 0)",
|
||||||
|
(ts_iso, device, bw, br),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC1: Advance through 14-day boundary, detail expires, summaries remain
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDetailExpiresSummariesRemain:
|
||||||
|
"""AC1: After 14-day pruning, local-day summaries survive."""
|
||||||
|
|
||||||
|
def test_local_days_survive_sample_pruning(self, tmp_path):
|
||||||
|
"""Local-day summaries persist after raw samples are pruned."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Create local-day summaries for dates spanning the 14-day boundary
|
||||||
|
# Day 1: 15 days ago (detail should be pruned)
|
||||||
|
old_summary = LocalDaySummary(
|
||||||
|
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start="2026-09-16T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-17T00:00:00+00:00",
|
||||||
|
bytes_written=1000, bytes_read=500,
|
||||||
|
coverage=0.8, sample_count=10, complete=True,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, old_summary)
|
||||||
|
|
||||||
|
# Day 2: 10 days ago (detail should be retained)
|
||||||
|
recent_summary = LocalDaySummary(
|
||||||
|
local_date="2026-09-21", tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start="2026-09-21T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-22T00:00:00+00:00",
|
||||||
|
bytes_written=2000, bytes_read=800,
|
||||||
|
coverage=0.9, sample_count=15, complete=True,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, recent_summary)
|
||||||
|
|
||||||
|
# Insert raw samples (old and recent)
|
||||||
|
_insert_sample(conn, "2026-09-16T10:00:00+00:00", bw=500, br=200)
|
||||||
|
_insert_sample(conn, "2026-09-16T10:05:00+00:00", bw=1000, br=500)
|
||||||
|
_insert_sample(conn, "2026-09-21T10:00:00+00:00", bw=1000, br=400)
|
||||||
|
_insert_sample(conn, "2026-09-21T10:05:00+00:00", bw=2000, br=800)
|
||||||
|
|
||||||
|
# Run pruning
|
||||||
|
pruned = prune_old_samples(conn, now, retention_days=14)
|
||||||
|
|
||||||
|
# Old samples should be pruned, recent ones retained
|
||||||
|
assert pruned >= 1
|
||||||
|
|
||||||
|
# Local-day summaries must still be queryable
|
||||||
|
old_result = query_local_day_summary(conn, "2026-09-16")
|
||||||
|
assert old_result is not None
|
||||||
|
assert old_result["bytes_written"] == 1000
|
||||||
|
assert old_result["bytes_read"] == 500
|
||||||
|
|
||||||
|
recent_result = query_local_day_summary(conn, "2026-09-21")
|
||||||
|
assert recent_result is not None
|
||||||
|
assert recent_result["bytes_written"] == 2000
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_history_query_shows_evidence_limits(self, tmp_path):
|
||||||
|
"""History entries annotate detail-availability correctly."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Old summary (detail pruned)
|
||||||
|
old = LocalDaySummary(
|
||||||
|
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start="2026-09-16T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-17T00:00:00+00:00",
|
||||||
|
bytes_written=1000, bytes_read=500,
|
||||||
|
coverage=0.8, sample_count=10, complete=True,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, old)
|
||||||
|
|
||||||
|
# Recent summary (detail available)
|
||||||
|
recent = LocalDaySummary(
|
||||||
|
local_date="2026-09-21", tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start="2026-09-21T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-22T00:00:00+00:00",
|
||||||
|
bytes_written=2000, bytes_read=800,
|
||||||
|
coverage=0.9, sample_count=15, complete=True,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, recent)
|
||||||
|
|
||||||
|
# Insert a recent sample (no old samples → old detail not available)
|
||||||
|
_insert_sample(conn, "2026-09-21T10:00:00+00:00", bw=1000, br=400)
|
||||||
|
|
||||||
|
# Query history
|
||||||
|
entries = query_local_day_history(conn, "2026-09-16", "2026-09-21", now)
|
||||||
|
assert len(entries) == 2
|
||||||
|
|
||||||
|
old_entry = next(e for e in entries if e.local_date == "2026-09-16")
|
||||||
|
assert old_entry.detail_available is False
|
||||||
|
assert old_entry.bytes_written == 1000
|
||||||
|
|
||||||
|
recent_entry = next(e for e in entries if e.local_date == "2026-09-21")
|
||||||
|
assert recent_entry.detail_available is True
|
||||||
|
assert recent_entry.bytes_written == 2000
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC2: Preserve summaries and boundary evidence before pruning
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBoundaryPreservation:
|
||||||
|
"""AC2: Summaries and boundary evidence preserved before pruning."""
|
||||||
|
|
||||||
|
def test_boundary_anchor_not_pruned_when_needed(self, tmp_path):
|
||||||
|
"""Raw samples needed as boundary anchors are retained."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Old sample before cutoff, recent sample after cutoff
|
||||||
|
# The old sample is a boundary anchor
|
||||||
|
_insert_sample(conn, "2026-09-16T23:55:00+00:00", bw=100)
|
||||||
|
_insert_sample(conn, "2026-09-17T12:30:00+00:00", bw=200)
|
||||||
|
|
||||||
|
pruned = prune_old_samples(conn, now, retention_days=14)
|
||||||
|
|
||||||
|
# Boundary anchor should be retained
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-16T23:55:00+00:00'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 1
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC3: Aged summaries retain timezone after system-timezone change
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestTimezonePreservation:
|
||||||
|
"""AC3: Historical summaries retain recorded timezone after TZ change."""
|
||||||
|
|
||||||
|
def test_timezone_survives_pruning(self, tmp_path):
|
||||||
|
"""Old summary in Asia/Kolkata retains its timezone after pruning."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Old summary in Asia/Kolkata
|
||||||
|
old = LocalDaySummary(
|
||||||
|
local_date="2026-09-16", tz_name="Asia/Kolkata", tz_offset="+05:30",
|
||||||
|
utc_start="2026-09-15T18:30:00+00:00",
|
||||||
|
utc_end="2026-09-16T18:30:00+00:00",
|
||||||
|
bytes_written=3000, bytes_read=1200,
|
||||||
|
coverage=0.7, sample_count=8, complete=False,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, old)
|
||||||
|
|
||||||
|
# Prune
|
||||||
|
prune_old_samples(conn, now, retention_days=14)
|
||||||
|
|
||||||
|
# Summary must retain its timezone
|
||||||
|
result = query_local_day_summary(conn, "2026-09-16")
|
||||||
|
assert result is not None
|
||||||
|
assert result["tz_name"] == "Asia/Kolkata"
|
||||||
|
assert result["tz_offset"] == "+05:30"
|
||||||
|
# UTC boundaries are preserved (not rewritten)
|
||||||
|
assert result["utc_start"] == "2026-09-15T18:30:00+00:00"
|
||||||
|
assert result["utc_end"] == "2026-09-16T18:30:00+00:00"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_history_entry_shows_tz_after_pruning(self, tmp_path):
|
||||||
|
"""History entry for aged summary shows correct timezone info."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
old = LocalDaySummary(
|
||||||
|
local_date="2026-09-16", tz_name="US/Eastern", tz_offset="-04:00",
|
||||||
|
utc_start="2026-09-16T04:00:00+00:00",
|
||||||
|
utc_end="2026-09-17T04:00:00+00:00",
|
||||||
|
bytes_written=5000, bytes_read=2000,
|
||||||
|
coverage=0.6, sample_count=5, complete=False,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, old)
|
||||||
|
|
||||||
|
entries = query_local_day_history(conn, "2026-09-16", "2026-09-16", now)
|
||||||
|
assert len(entries) == 1
|
||||||
|
entry = entries[0]
|
||||||
|
assert entry.tz_name == "US/Eastern"
|
||||||
|
assert entry.tz_offset == "-04:00"
|
||||||
|
assert entry.utc_start == "2026-09-16T04:00:00+00:00"
|
||||||
|
assert entry.detail_available is False
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC4: Legacy UTC summaries without local-day total remain incomplete
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLegacyDataHandling:
|
||||||
|
"""AC4: Legacy UTC summaries that can't establish local-day total."""
|
||||||
|
|
||||||
|
def test_incomplete_legacy_labelled_correctly(self, tmp_path):
|
||||||
|
"""Legacy summary without full evidence is labelled incomplete."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Simulate a legacy summary that can't establish a full local-day total
|
||||||
|
legacy = LocalDaySummary(
|
||||||
|
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start="2026-09-16T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-17T00:00:00+00:00",
|
||||||
|
bytes_written=500, bytes_read=200,
|
||||||
|
coverage=0.3, sample_count=3, complete=False,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, legacy)
|
||||||
|
|
||||||
|
result = query_local_day_summary(conn, "2026-09-16")
|
||||||
|
assert result is not None
|
||||||
|
assert result["complete"] is False
|
||||||
|
assert result["coverage"] == pytest.approx(0.3, abs=0.01)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC5: Migration, pruning, and repair are interruption-safe and idempotent
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestIdempotencySafety:
|
||||||
|
"""AC5: Operations are idempotent and interruption-safe."""
|
||||||
|
|
||||||
|
def test_repeated_pruning_no_side_effects(self, tmp_path):
|
||||||
|
"""Running pruning multiple times produces the same result."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Create a local-day summary
|
||||||
|
summary = LocalDaySummary(
|
||||||
|
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start="2026-09-16T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-17T00:00:00+00:00",
|
||||||
|
bytes_written=1000, bytes_read=500,
|
||||||
|
coverage=0.8, sample_count=10, complete=True,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, summary)
|
||||||
|
_insert_sample(conn, "2026-09-16T10:00:00+00:00", bw=500)
|
||||||
|
_insert_sample(conn, "2026-09-16T10:05:00+00:00", bw=1000)
|
||||||
|
|
||||||
|
# Prune twice
|
||||||
|
prune_old_samples(conn, now, retention_days=14)
|
||||||
|
prune_old_samples(conn, now, retention_days=14)
|
||||||
|
|
||||||
|
# Local-day summary unchanged
|
||||||
|
result = query_local_day_summary(conn, "2026-09-16")
|
||||||
|
assert result is not None
|
||||||
|
assert result["bytes_written"] == 1000
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_repair_does_not_touch_local_days(self, tmp_path):
|
||||||
|
"""Repair derivation does not modify existing local_days entries."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
|
||||||
|
# Create a local-day summary
|
||||||
|
summary = LocalDaySummary(
|
||||||
|
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start="2026-09-16T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-17T00:00:00+00:00",
|
||||||
|
bytes_written=1000, bytes_read=500,
|
||||||
|
coverage=0.8, sample_count=10, complete=True,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, summary)
|
||||||
|
|
||||||
|
# Run repair (should not touch local_days)
|
||||||
|
result = repair_derivation(conn)
|
||||||
|
assert result.ok is True
|
||||||
|
|
||||||
|
# Local-day summary unchanged
|
||||||
|
stored = query_local_day_summary(conn, "2026-09-16")
|
||||||
|
assert stored is not None
|
||||||
|
assert stored["bytes_written"] == 1000
|
||||||
|
assert stored["bytes_read"] == 500
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC6: Volume conservation — no double counting of midnight-spanning bytes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestVolumeConservation:
|
||||||
|
"""AC6: Summary volume plus unallocated evidence conserves each delta once."""
|
||||||
|
|
||||||
|
def test_midnight_spanning_not_double_counted(self, tmp_path):
|
||||||
|
"""Midnight-spanning interval appears once, not in both days."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
|
||||||
|
# Insert hour observations that span midnight
|
||||||
|
# UTC hour 2026-09-16T23:00 contains midnight boundary
|
||||||
|
_insert_hour(conn, "2026-09-16T23:00:00+00:00", bw=100, br=50)
|
||||||
|
_insert_hour(conn, "2026-09-17T00:00:00+00:00", bw=200, br=80)
|
||||||
|
|
||||||
|
# Derive summaries for both days
|
||||||
|
clock_16 = datetime(2026, 9, 16, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
summary_16 = derive_local_day_summary(conn, "UTC", clock_16)
|
||||||
|
assert summary_16 is not None
|
||||||
|
|
||||||
|
clock_17 = datetime(2026, 9, 17, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
summary_17 = derive_local_day_summary(conn, "UTC", clock_17)
|
||||||
|
assert summary_17 is not None
|
||||||
|
|
||||||
|
# Each day should only have its own hour's bytes
|
||||||
|
# Day 16 has the 23:00 hour (100 bw)
|
||||||
|
# Day 17 has the 00:00 hour (200 bw)
|
||||||
|
assert summary_16.bytes_written == 100
|
||||||
|
assert summary_17.bytes_written == 200
|
||||||
|
# Total is conserved: 100 + 200 = 300
|
||||||
|
assert summary_16.bytes_written + summary_17.bytes_written == 300
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC7: Use existing retention/repair entry points with real stores
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExistingEntryPoints:
|
||||||
|
"""AC7: Use existing entry points with real temporary stores."""
|
||||||
|
|
||||||
|
def test_prune_with_real_temp_store(self, tmp_path):
|
||||||
|
"""Pruning works with a real temporary store."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Insert old and recent samples
|
||||||
|
for days_ago in range(1, 30):
|
||||||
|
ts = (now - timedelta(days=days_ago)).isoformat()
|
||||||
|
_insert_sample(conn, ts, bw=days_ago * 100)
|
||||||
|
|
||||||
|
pruned = prune_old_samples(conn, now, retention_days=14)
|
||||||
|
assert pruned == 15
|
||||||
|
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||||
|
assert cursor.fetchone()[0] == 14
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_repair_with_real_temp_store(self, tmp_path):
|
||||||
|
"""Repair works with a real temporary store."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
|
||||||
|
# Insert samples that need interval derivation
|
||||||
|
_insert_sample(conn, "2026-09-16T10:00:00+00:00", bw=100)
|
||||||
|
_insert_sample(conn, "2026-09-16T10:05:00+00:00", bw=200)
|
||||||
|
|
||||||
|
result = repair_derivation(conn)
|
||||||
|
assert result.ok is True
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC8: User-visible transition from recent detail to older summaries
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestUserVisibleTransition:
|
||||||
|
"""AC8: Transition from recent detail to aged summaries."""
|
||||||
|
|
||||||
|
def test_recent_vs_aged_history_entries(self, tmp_path):
|
||||||
|
"""Recent entries show detail_available=True, aged show False."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Create summaries for recent and aged dates
|
||||||
|
for days_ago, bw in [(5, 1000), (10, 2000), (15, 3000)]:
|
||||||
|
date = (now - timedelta(days=days_ago)).strftime("%Y-%m-%d")
|
||||||
|
utc_start = (now - timedelta(days=days_ago)).replace(
|
||||||
|
hour=0, minute=0, second=0, microsecond=0
|
||||||
|
).isoformat()
|
||||||
|
utc_end = (now - timedelta(days=days_ago - 1)).replace(
|
||||||
|
hour=0, minute=0, second=0, microsecond=0
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
summary = LocalDaySummary(
|
||||||
|
local_date=date, tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start=utc_start, utc_end=utc_end,
|
||||||
|
bytes_written=bw, bytes_read=bw // 2,
|
||||||
|
coverage=0.8, sample_count=10, complete=True,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, summary)
|
||||||
|
|
||||||
|
# Insert a recent sample (5 days ago)
|
||||||
|
_insert_sample(conn, (now - timedelta(days=5)).isoformat(), bw=500)
|
||||||
|
|
||||||
|
# Query full range
|
||||||
|
start = (now - timedelta(days=15)).strftime("%Y-%m-%d")
|
||||||
|
end = (now - timedelta(days=5)).strftime("%Y-%m-%d")
|
||||||
|
entries = query_local_day_history(conn, start, end, now)
|
||||||
|
|
||||||
|
assert len(entries) == 3
|
||||||
|
|
||||||
|
# Recent entry has detail
|
||||||
|
recent = next(e for e in entries if e.local_date == (now - timedelta(days=5)).strftime("%Y-%m-%d"))
|
||||||
|
assert recent.detail_available is True
|
||||||
|
|
||||||
|
# Aged entries do not have detail
|
||||||
|
aged_10 = next(e for e in entries if e.local_date == (now - timedelta(days=10)).strftime("%Y-%m-%d"))
|
||||||
|
assert aged_10.detail_available is False
|
||||||
|
|
||||||
|
aged_15 = next(e for e in entries if e.local_date == (now - timedelta(days=15)).strftime("%Y-%m-%d"))
|
||||||
|
assert aged_15.detail_available is False
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC9: Document the 14-day detail vs indefinite-summary policy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRetentionPolicyDocumentation:
|
||||||
|
"""AC9: Module docstring documents the retention policy."""
|
||||||
|
|
||||||
|
def test_module_has_retention_policy_doc(self):
|
||||||
|
"""The local_day module documents the 14-day vs indefinite policy."""
|
||||||
|
import fenris.local_day as ld
|
||||||
|
docstring = ld.__doc__
|
||||||
|
assert "14 day" in docstring.lower() or "fourteen" in docstring.lower()
|
||||||
|
assert "indefinite" in docstring.lower()
|
||||||
|
assert "retention" in docstring.lower()
|
||||||
|
|
||||||
|
def test_pruning_module_documents_local_days_preservation(self):
|
||||||
|
"""The pruning module documents that local_days are preserved."""
|
||||||
|
import fenris.pruning as pr
|
||||||
|
docstring = pr.__doc__
|
||||||
|
assert "local_days" in docstring.lower() or "local day" in docstring.lower()
|
||||||
|
assert "preserved" in docstring.lower() or "never" in docstring.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Evidence-limit visibility
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestEvidenceLimitVisibility:
|
||||||
|
"""History entries expose evidence-availability metadata."""
|
||||||
|
|
||||||
|
def test_detail_available_flag_reflects_samples(self, tmp_path):
|
||||||
|
"""detail_available is True only when samples exist within retention."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Summary for a date with no samples
|
||||||
|
no_samples = LocalDaySummary(
|
||||||
|
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start="2026-09-16T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-17T00:00:00+00:00",
|
||||||
|
bytes_written=500, bytes_read=200,
|
||||||
|
coverage=0.4, sample_count=3, complete=False,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, no_samples)
|
||||||
|
|
||||||
|
# Summary for a date with a recent sample
|
||||||
|
with_sample = LocalDaySummary(
|
||||||
|
local_date="2026-09-21", tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start="2026-09-21T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-22T00:00:00+00:00",
|
||||||
|
bytes_written=1000, bytes_read=400,
|
||||||
|
coverage=0.9, sample_count=10, complete=True,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, with_sample)
|
||||||
|
_insert_sample(conn, "2026-09-21T12:00:00+00:00", bw=500)
|
||||||
|
|
||||||
|
entries = query_local_day_history(conn, "2026-09-16", "2026-09-21", now)
|
||||||
|
|
||||||
|
no_samples_entry = next(e for e in entries if e.local_date == "2026-09-16")
|
||||||
|
assert no_samples_entry.detail_available is False
|
||||||
|
|
||||||
|
with_sample_entry = next(e for e in entries if e.local_date == "2026-09-21")
|
||||||
|
assert with_sample_entry.detail_available is True
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_empty_history_range_returns_empty_list(self, tmp_path):
|
||||||
|
"""Querying an empty date range returns an empty list."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
entries = query_local_day_history(conn, "2026-01-01", "2026-01-31", now)
|
||||||
|
assert entries == []
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_history_entry_from_summary_factory(self, tmp_path):
|
||||||
|
"""LocalDayHistoryEntry.from_summary creates entry from dict."""
|
||||||
|
summary = {
|
||||||
|
"local_date": "2026-09-16",
|
||||||
|
"tz_name": "UTC",
|
||||||
|
"tz_offset": "+00:00",
|
||||||
|
"utc_start": "2026-09-16T00:00:00+00:00",
|
||||||
|
"utc_end": "2026-09-17T00:00:00+00:00",
|
||||||
|
"bytes_written": 1000,
|
||||||
|
"bytes_read": 500,
|
||||||
|
"coverage": 0.8,
|
||||||
|
"sample_count": 10,
|
||||||
|
"complete": True,
|
||||||
|
}
|
||||||
|
entry = LocalDayHistoryEntry.from_summary(
|
||||||
|
summary, detail_available=True, derived_from_surviving=True
|
||||||
|
)
|
||||||
|
assert entry.local_date == "2026-09-16"
|
||||||
|
assert entry.detail_available is True
|
||||||
|
assert entry.derived_from_surviving is True
|
||||||
|
assert entry.bytes_written == 1000
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
"""Tests for live activity graph (issue #91).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Live interval volume query from raw samples
|
||||||
|
- LiveActivityGraph widget rendering, toggle, and inspection
|
||||||
|
- Three-minute cadence constants
|
||||||
|
- TUI integration with live graph
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.store import init_store
|
||||||
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
|
from fenris.tui import (
|
||||||
|
FenrisTuiApp,
|
||||||
|
LiveActivityGraph,
|
||||||
|
_query_live_graph_data,
|
||||||
|
LIVE_WINDOW_H,
|
||||||
|
_query_daily_graph_data,
|
||||||
|
_RANGE_OPTIONS,
|
||||||
|
)
|
||||||
|
from fenris.status import CADENCE_DEFAULT_S, FRESH_THRESHOLD_S, ACCURACY_SEC
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _clock(year=2026, month=9, day=30, hour=12):
|
||||||
|
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
|
||||||
|
identity_key="nqn.test", degraded=False,
|
||||||
|
mn="Samsung SSD 970 EVO Plus 1TB"):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO controller_segments "
|
||||||
|
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(opened_at, identity_key, degraded, "nqn.test", "SN123", mn, "FW1",
|
||||||
|
"0x144d", "0x144d", "pcie"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_sample(conn, ts, bw=512000000000, br=256000000000,
|
||||||
|
segment_id=None, pu=5, device="/dev/nvme0n1"):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||||
|
"percentage_used, bytes_written, bytes_read, power_on_hours, segment_id) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(ts, device, 1000000, 500000, pu, bw, br, 8765, segment_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||||
|
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cadence constants (issue #91 AC1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCadenceConstants:
|
||||||
|
def test_cadence_default_is_3_minutes(self):
|
||||||
|
"""Default collection cadence is 3 minutes (180 s)."""
|
||||||
|
assert CADENCE_DEFAULT_S == 180
|
||||||
|
|
||||||
|
def test_fresh_threshold_uses_3min_cadence(self):
|
||||||
|
"""Fresh threshold = 2 × 3min + AccuracySec + 60s = 450s."""
|
||||||
|
expected = 2 * 180 + ACCURACY_SEC + 60
|
||||||
|
assert FRESH_THRESHOLD_S == expected
|
||||||
|
|
||||||
|
def test_live_window_is_3_hours(self):
|
||||||
|
"""Live graph window is 3 hours."""
|
||||||
|
assert LIVE_WINDOW_H == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Live graph query (issue #91 AC2-5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestQueryLiveData:
|
||||||
|
def test_empty_store_returns_empty(self, tmp_path):
|
||||||
|
"""No samples → empty list."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert result == []
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_single_sample_returns_empty(self, tmp_path):
|
||||||
|
"""One sample (no pair) → empty list."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
_insert_sample(conn, now.isoformat())
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert result == []
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_two_samples_in_window(self, tmp_path):
|
||||||
|
"""Two samples within 3h window produce one interval."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
t1 = (now - timedelta(minutes=3)).isoformat()
|
||||||
|
t2 = (now - timedelta(minutes=0)).isoformat()
|
||||||
|
_insert_sample(conn, t1, bw=1000, br=500)
|
||||||
|
_insert_sample(conn, t2, bw=1500, br=700)
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["bytes_written"] == 500
|
||||||
|
assert result[0]["bytes_read"] == 200
|
||||||
|
assert result[0]["elapsed_s"] == 180
|
||||||
|
assert result[0]["is_gap"] is False
|
||||||
|
assert result[0]["is_zero"] is False
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_samples_outside_window_ignored(self, tmp_path):
|
||||||
|
"""Samples older than 3h are excluded."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
old = (now - timedelta(hours=4)).isoformat()
|
||||||
|
recent = (now - timedelta(minutes=1)).isoformat()
|
||||||
|
_insert_sample(conn, old, bw=1000, br=500)
|
||||||
|
_insert_sample(conn, recent, bw=1500, br=700)
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert len(result) == 0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_gap_detection(self, tmp_path):
|
||||||
|
"""Interval > 3× cadence is flagged as a gap."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
t1 = (now - timedelta(minutes=13)).isoformat()
|
||||||
|
t2 = (now - timedelta(minutes=3)).isoformat()
|
||||||
|
_insert_sample(conn, t1, bw=1000, br=500)
|
||||||
|
_insert_sample(conn, t2, bw=1500, br=700)
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["is_gap"] is True
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_zero_interval(self, tmp_path):
|
||||||
|
"""No byte delta → is_zero flag."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
t1 = (now - timedelta(minutes=3)).isoformat()
|
||||||
|
t2 = (now - timedelta(minutes=0)).isoformat()
|
||||||
|
_insert_sample(conn, t1, bw=1000, br=500)
|
||||||
|
_insert_sample(conn, t2, bw=1000, br=500)
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["bytes_written"] == 0
|
||||||
|
assert result[0]["bytes_read"] == 0
|
||||||
|
assert result[0]["is_zero"] is True
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_segment_boundary_resets_counters(self, tmp_path):
|
||||||
|
"""Counter discontinuity across segments → zero delta."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn, identity_key="key_a")
|
||||||
|
_insert_segment(conn, opened_at="2026-09-30T11:00:00+00:00",
|
||||||
|
identity_key="key_b")
|
||||||
|
now = _clock()
|
||||||
|
t1 = (now - timedelta(minutes=6)).isoformat()
|
||||||
|
t2 = (now - timedelta(minutes=3)).isoformat()
|
||||||
|
_insert_sample(conn, t1, bw=1000, br=500, segment_id=1)
|
||||||
|
_insert_sample(conn, t2, bw=500, br=200, segment_id=2)
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["bytes_written"] == 0
|
||||||
|
assert result[0]["bytes_read"] == 0
|
||||||
|
assert result[0]["is_segment_boundary"] is True
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_actual_timestamps_used(self, tmp_path):
|
||||||
|
"""Points use actual sample timestamps, not 3-min spacing."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
t1 = (now - timedelta(minutes=5)).isoformat()
|
||||||
|
t2 = (now - timedelta(minutes=1)).isoformat()
|
||||||
|
_insert_sample(conn, t1, bw=1000, br=500)
|
||||||
|
_insert_sample(conn, t2, bw=2000, br=800)
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["start_ts"] == t1
|
||||||
|
assert result[0]["end_ts"] == t2
|
||||||
|
assert result[0]["elapsed_s"] == 240
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_multiple_intervals_ordered_oldest_first(self, tmp_path):
|
||||||
|
"""Multiple intervals are returned in chronological order."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
for i in range(5):
|
||||||
|
ts = (now - timedelta(minutes=15 - i * 3)).isoformat()
|
||||||
|
_insert_sample(conn, ts, bw=1000 * (i + 1), br=500 * (i + 1))
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert len(result) == 4
|
||||||
|
for i in range(len(result) - 1):
|
||||||
|
assert result[i]["end_ts"] <= result[i + 1]["start_ts"]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LiveActivityGraph widget (issue #91 AC3)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLiveActivityGraph:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle_measure(self, tmp_path):
|
||||||
|
"""Toggle switches between written and read."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#live-activity")
|
||||||
|
assert graph.measure == "written"
|
||||||
|
graph.toggle_measure()
|
||||||
|
assert graph.measure == "read"
|
||||||
|
graph.toggle_measure()
|
||||||
|
assert graph.measure == "written"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_data_renders_awaiting(self, tmp_path):
|
||||||
|
"""Empty data shows awaiting message."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#live-activity")
|
||||||
|
graph.set_data([])
|
||||||
|
await pilot.pause()
|
||||||
|
render = str(graph.query_one("#live-render").render())
|
||||||
|
assert "Awaiting intervals" in render
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_data_renders_header(self, tmp_path):
|
||||||
|
"""Data updates the header with interval count."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#live-activity")
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"start_ts": "2026-09-30T11:57:00+00:00",
|
||||||
|
"end_ts": "2026-09-30T12:00:00+00:00",
|
||||||
|
"start_label": "11:57",
|
||||||
|
"end_label": "12:00",
|
||||||
|
"bytes_written": 500000000,
|
||||||
|
"bytes_read": 200000000,
|
||||||
|
"elapsed_s": 180,
|
||||||
|
"is_gap": False,
|
||||||
|
"is_zero": False,
|
||||||
|
"is_segment_boundary": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
graph.set_data(data)
|
||||||
|
await pilot.pause()
|
||||||
|
header = str(graph.query_one("#live-header").render())
|
||||||
|
assert "1 intervals" in header
|
||||||
|
assert "w Toggle W/R" in header
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TUI integration (issue #91 AC2, AC6-9)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestTUILiveIntegration:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_live_graph_exists_in_compose(self, tmp_path):
|
||||||
|
"""Live activity graph is composed in the TUI."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.query_one("#live-activity") is not None
|
||||||
|
assert isinstance(app.query_one("#live-activity"), LiveActivityGraph)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_live_graph_has_border_title(self, tmp_path):
|
||||||
|
"""Live activity graph has a border title."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
live = app.query_one("#live-activity")
|
||||||
|
assert live.border_title == "Live activity"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_today_action_resets_drill(self, tmp_path):
|
||||||
|
"""Action today returns from hourly drill to daily view."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
graph.view_mode = "hourly"
|
||||||
|
graph.drill_day = "2026-09-30"
|
||||||
|
app.action_today()
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.view_mode == "daily"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle_measure_binding(self, tmp_path):
|
||||||
|
"""The w key toggles the live graph measure."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#live-activity")
|
||||||
|
assert graph.measure == "written"
|
||||||
|
await pilot.press("w")
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.measure == "read"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_constrained_mode_hides_live_graph(self, tmp_path):
|
||||||
|
"""Constrained terminal hides the live graph."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(70, 20)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
main_grid = app.query_one("#main-grid")
|
||||||
|
assert main_grid.has_class("constrained")
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"""Local-day activity derivation tests (issue #90, ADR 0010).
|
||||||
|
|
||||||
|
Verifies that local-day summaries are correctly derived from UTC hour
|
||||||
|
observations, that the schema migration creates the local_days table,
|
||||||
|
that timezone boundaries are preserved across system-timezone changes,
|
||||||
|
and that midnight-spanning intervals are retained as shared evidence
|
||||||
|
without double counting.
|
||||||
|
|
||||||
|
Seams:
|
||||||
|
- write side: collector.run_collection() → local_day derivation
|
||||||
|
- read side: local_day.query_current_local_day(), query_local_day_summary()
|
||||||
|
- persistence: store.init_store() → local_days table, migration 2→3
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.store import init_store, SCHEMA_VERSION
|
||||||
|
from fenris.local_day import (
|
||||||
|
derive_local_day_summary,
|
||||||
|
persist_local_day,
|
||||||
|
query_local_day_summary,
|
||||||
|
query_current_local_day,
|
||||||
|
LocalDaySummary,
|
||||||
|
)
|
||||||
|
from fenris.monitoring_periods import ensure_period_open, close_period
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_smartctl(duw: int, dur: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155",
|
||||||
|
"build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw,
|
||||||
|
"data_units_read": dur, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sysfs_tree(tmp_path: Path) -> Path:
|
||||||
|
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
ctrl_dir.mkdir(parents=True)
|
||||||
|
(ctrl_dir / "subsysnqn").write_text(
|
||||||
|
"nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n"
|
||||||
|
)
|
||||||
|
(ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n")
|
||||||
|
(ctrl_dir / "serial").write_text("S4EWNX0N123456\n")
|
||||||
|
(ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n")
|
||||||
|
transport_dir = ctrl_dir / "transport"
|
||||||
|
transport_dir.mkdir()
|
||||||
|
(transport_dir / "address").write_text("0000:03:00.0")
|
||||||
|
(transport_dir / "trstring").write_text("pcie")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
def __init__(self, initial: datetime):
|
||||||
|
self.now = initial
|
||||||
|
def utcnow(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_hour(conn, hour_iso, bw=0, br=0, sample_count=1,
|
||||||
|
active=3600, idle=0, powered_off=0, unknown=0):
|
||||||
|
"""Insert a UTC hour observation."""
|
||||||
|
known = active + idle + powered_off
|
||||||
|
coverage = known / 3600.0
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO hour_observations "
|
||||||
|
"(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
|
||||||
|
" bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(hour_iso, active, idle, powered_off, unknown, bw, br, sample_count, coverage),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema migration: local_days table is created
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSchemaMigration:
|
||||||
|
"""Store version 3 includes the local_days table."""
|
||||||
|
|
||||||
|
def test_fresh_store_has_local_days(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
tables = {row[0] for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()}
|
||||||
|
assert "local_days" in tables
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_migration_from_v2_creates_local_days(self, tmp_path):
|
||||||
|
db = tmp_path / "obs.db"
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute("PRAGMA user_version=2")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = init_store(db)
|
||||||
|
tables = {row[0] for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()}
|
||||||
|
assert "local_days" in tables
|
||||||
|
assert conn.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_migration_is_idempotent(self, tmp_path):
|
||||||
|
db = tmp_path / "obs.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.close()
|
||||||
|
# Reopening should not fail
|
||||||
|
conn = init_store(db)
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM local_days").fetchone()[0]
|
||||||
|
assert count == 0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Local-day derivation from UTC hours
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDeriveLocalDay:
|
||||||
|
"""Derive local-day summaries from UTC hour observations."""
|
||||||
|
|
||||||
|
def test_utc_plus_zero(self, tmp_path):
|
||||||
|
"""UTC timezone: local day boundaries = UTC day boundaries."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
ensure_period_open(conn, datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
_insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=100, br=50)
|
||||||
|
_insert_hour(conn, "2026-09-01T11:00:00+00:00", bw=200, br=80)
|
||||||
|
|
||||||
|
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
summary = derive_local_day_summary(conn, "UTC", clock)
|
||||||
|
|
||||||
|
assert summary is not None
|
||||||
|
assert summary.local_date == "2026-09-01"
|
||||||
|
assert summary.tz_name == "UTC"
|
||||||
|
assert summary.tz_offset == "+00:00"
|
||||||
|
assert summary.bytes_written == 300
|
||||||
|
assert summary.bytes_read == 130
|
||||||
|
assert summary.complete is False # not all 24 hours covered
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_utc_plus_five_thirty(self, tmp_path):
|
||||||
|
"""Asia/Kolkata half-hour offset: UTC boundaries are offset."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
|
||||||
|
# Local day 2026-09-01 in +05:30 is UTC [2026-08-31T18:30, 2026-09-01T18:30)
|
||||||
|
# Insert UTC hours that belong to this local day
|
||||||
|
_insert_hour(conn, "2026-08-31T19:00:00+00:00", bw=100, br=50)
|
||||||
|
_insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=200, br=80)
|
||||||
|
|
||||||
|
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) # 17:30 local
|
||||||
|
summary = derive_local_day_summary(conn, "Asia/Kolkata", clock)
|
||||||
|
|
||||||
|
assert summary is not None
|
||||||
|
assert summary.local_date == "2026-09-01"
|
||||||
|
assert summary.tz_name == "Asia/Kolkata"
|
||||||
|
assert summary.tz_offset == "+05:30"
|
||||||
|
assert summary.bytes_written == 300
|
||||||
|
assert summary.bytes_read == 130
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_midnight_spanning_hour_included(self, tmp_path):
|
||||||
|
"""UTC hour straddling local midnight is included in the local day."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
|
||||||
|
# For UTC+5:30, local day 2026-09-01 starts at 2026-08-31T18:30 UTC
|
||||||
|
# UTC hour 2026-08-31T18:00 covers 18:00-19:00, straddling the midnight
|
||||||
|
_insert_hour(conn, "2026-08-31T18:00:00+00:00", bw=100, br=50)
|
||||||
|
_insert_hour(conn, "2026-08-31T19:00:00+00:00", bw=200, br=80)
|
||||||
|
|
||||||
|
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
summary = derive_local_day_summary(conn, "Asia/Kolkata", clock)
|
||||||
|
|
||||||
|
assert summary is not None
|
||||||
|
# The midnight-spanning hour (18:00) is included
|
||||||
|
assert summary.bytes_written == 300
|
||||||
|
assert summary.bytes_read == 130
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_no_hours_returns_none(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
summary = derive_local_day_summary(conn, "UTC", clock)
|
||||||
|
assert summary is None
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Persistence and querying
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLocalDayPersistence:
|
||||||
|
"""Local-day summaries persist and can be queried."""
|
||||||
|
|
||||||
|
def test_persist_and_query(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
summary = LocalDaySummary(
|
||||||
|
local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start="2026-09-01T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-02T00:00:00+00:00",
|
||||||
|
bytes_written=300, bytes_read=130,
|
||||||
|
coverage=0.5, sample_count=4, complete=False,
|
||||||
|
)
|
||||||
|
created = persist_local_day(conn, summary)
|
||||||
|
assert created is True
|
||||||
|
|
||||||
|
# Upsert again — should update, not create
|
||||||
|
created2 = persist_local_day(conn, summary)
|
||||||
|
assert created2 is False
|
||||||
|
|
||||||
|
result = query_local_day_summary(conn, "2026-09-01")
|
||||||
|
assert result is not None
|
||||||
|
assert result["bytes_written"] == 300
|
||||||
|
assert result["tz_name"] == "UTC"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_query_nonexistent_returns_none(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
assert query_local_day_summary(conn, "2026-01-01") is None
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_query_current_local_day(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
summary = LocalDaySummary(
|
||||||
|
local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start="2026-09-01T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-02T00:00:00+00:00",
|
||||||
|
bytes_written=500, bytes_read=200,
|
||||||
|
coverage=0.8, sample_count=10, complete=True,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, summary)
|
||||||
|
|
||||||
|
clock = datetime(2026, 9, 1, 15, 0, 0, tzinfo=timezone.utc)
|
||||||
|
result = query_current_local_day(conn, clock, "UTC")
|
||||||
|
assert result is not None
|
||||||
|
assert result["bytes_written"] == 500
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Timezone change handling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestTimezoneChange:
|
||||||
|
"""Historical summaries retain their recorded timezone."""
|
||||||
|
|
||||||
|
def test_old_tz_preserved_after_change(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
|
||||||
|
# Old summary in UTC
|
||||||
|
old = LocalDaySummary(
|
||||||
|
local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start="2026-09-01T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-02T00:00:00+00:00",
|
||||||
|
bytes_written=100, bytes_read=50,
|
||||||
|
coverage=0.5, sample_count=5, complete=False,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, old)
|
||||||
|
|
||||||
|
# System timezone changes to Asia/Kolkata
|
||||||
|
# Old summary retains UTC
|
||||||
|
result = query_local_day_summary(conn, "2026-09-01")
|
||||||
|
assert result is not None
|
||||||
|
assert result["tz_name"] == "UTC"
|
||||||
|
assert result["tz_offset"] == "+00:00"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_different_tz_same_date_stored_separately(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
|
||||||
|
# Two summaries for same date in different timezones
|
||||||
|
for tz, offset in [("UTC", "+00:00"), ("Asia/Kolkata", "+05:30")]:
|
||||||
|
summary = LocalDaySummary(
|
||||||
|
local_date="2026-09-01", tz_name=tz, tz_offset=offset,
|
||||||
|
utc_start="2026-09-01T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-02T00:00:00+00:00",
|
||||||
|
bytes_written=100, bytes_read=50,
|
||||||
|
coverage=0.5, sample_count=5, complete=False,
|
||||||
|
)
|
||||||
|
persist_local_day(conn, summary)
|
||||||
|
|
||||||
|
# Both exist independently — query by specific timezone
|
||||||
|
utc_row = conn.execute(
|
||||||
|
"SELECT tz_name, bytes_written FROM local_days "
|
||||||
|
"WHERE local_date = '2026-09-01' AND tz_name = 'UTC'"
|
||||||
|
).fetchone()
|
||||||
|
assert utc_row is not None
|
||||||
|
assert utc_row[0] == "UTC"
|
||||||
|
assert utc_row[1] == 100
|
||||||
|
|
||||||
|
in_row = conn.execute(
|
||||||
|
"SELECT tz_name, bytes_written FROM local_days "
|
||||||
|
"WHERE local_date = '2026-09-01' AND tz_name = 'Asia/Kolkata'"
|
||||||
|
).fetchone()
|
||||||
|
assert in_row is not None
|
||||||
|
assert in_row[0] == "Asia/Kolkata"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Coverage and completeness
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLocalDayCompleteness:
|
||||||
|
"""Complete days vs partial current day."""
|
||||||
|
|
||||||
|
def test_incomplete_when_missing_hours(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
_insert_hour(conn, "2026-09-01T12:00:00+00:00", bw=100, br=50)
|
||||||
|
|
||||||
|
clock = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
|
||||||
|
summary = derive_local_day_summary(conn, "UTC", clock)
|
||||||
|
|
||||||
|
assert summary is not None
|
||||||
|
assert summary.complete is False
|
||||||
|
assert summary.coverage < 1.0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_coverage_ratio(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
# 2 hours of known data out of 24 total UTC hours in a UTC day
|
||||||
|
_insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=100, active=3600)
|
||||||
|
_insert_hour(conn, "2026-09-01T11:00:00+00:00", bw=200, active=3600)
|
||||||
|
|
||||||
|
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
summary = derive_local_day_summary(conn, "UTC", clock)
|
||||||
|
|
||||||
|
assert summary is not None
|
||||||
|
# 7200 known seconds / 86400 total = ~0.083
|
||||||
|
assert summary.coverage == pytest.approx(7200 / 86400, rel=0.01)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Collector integration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCollectorIntegration:
|
||||||
|
"""Collector derives local-day summaries during collection."""
|
||||||
|
|
||||||
|
def test_collection_creates_local_day(self, tmp_path, sysfs_tree):
|
||||||
|
from fenris.collector import run_collection
|
||||||
|
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM local_days").fetchone()[0]
|
||||||
|
assert count >= 1
|
||||||
|
|
||||||
|
# Verify the local day has the expected write/read data
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT bytes_written, bytes_read, complete FROM local_days LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] == 50 * 512000 # writes
|
||||||
|
assert row[1] == 30 * 512000 # reads
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DST handling (23/25-hour days)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDSTHandling:
|
||||||
|
"""Local days with non-24-hour durations from DST transitions."""
|
||||||
|
|
||||||
|
def test_short_day_23_hours(self, tmp_path):
|
||||||
|
"""Spring forward: local day is 23 hours."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
|
||||||
|
# Simulate a 23-hour day in a timezone with DST
|
||||||
|
# For simplicity, just verify the summary records the correct UTC range
|
||||||
|
clock = datetime(2026, 3, 8, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
# US/Eastern springs forward on 2026-03-08
|
||||||
|
# Local day 2026-03-08 is 23 hours: UTC [07:00, 06:00+1d)
|
||||||
|
_insert_hour(conn, "2026-03-08T08:00:00+00:00", bw=100, active=3600)
|
||||||
|
_insert_hour(conn, "2026-03-08T12:00:00+00:00", bw=200, active=3600)
|
||||||
|
|
||||||
|
summary = derive_local_day_summary(conn, "US/Eastern", clock)
|
||||||
|
assert summary is not None
|
||||||
|
assert summary.local_date == "2026-03-08"
|
||||||
|
# UTC range should be approximately 23 hours
|
||||||
|
utc_start = datetime.fromisoformat(summary.utc_start)
|
||||||
|
utc_end = datetime.fromisoformat(summary.utc_end)
|
||||||
|
duration = (utc_end - utc_start).total_seconds()
|
||||||
|
assert 22 * 3600 <= duration <= 24 * 3600 # ~23h ± tolerance
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
"""Measured drive activity integration tests (issue #89).
|
||||||
|
|
||||||
|
Drives successive controlled acquisition readings through the public
|
||||||
|
collector into a real temporary observation store, then verifies the
|
||||||
|
normal reader and visible dashboard report correct read/write deltas,
|
||||||
|
hour/day evidence, accumulation, and boundary behaviour.
|
||||||
|
|
||||||
|
Seams:
|
||||||
|
- write side: run_collection() → observation store
|
||||||
|
- read side: _query_daily_graph_data(), _query_hourly_graph_data(),
|
||||||
|
derive_day(), get_status() → observation store
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.collector import run_collection
|
||||||
|
from fenris.store import init_store
|
||||||
|
from fenris.day_aggregate import derive_day
|
||||||
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
|
from fenris.tui import _query_daily_graph_data, _query_hourly_graph_data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_smartctl(duw: int, dur: int) -> Dict[str, Any]:
|
||||||
|
"""Build a smartctl fixture with specific DUW/DUR counters."""
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155",
|
||||||
|
"build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw,
|
||||||
|
"data_units_read": dur, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sysfs_tree(tmp_path: Path) -> Path:
|
||||||
|
"""Create a minimal sysfs fixture tree with controller identity."""
|
||||||
|
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
ctrl_dir.mkdir(parents=True)
|
||||||
|
(ctrl_dir / "subsysnqn").write_text(
|
||||||
|
"nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n"
|
||||||
|
)
|
||||||
|
(ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n")
|
||||||
|
(ctrl_dir / "serial").write_text("S4EWNX0N123456\n")
|
||||||
|
(ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n")
|
||||||
|
transport_dir = ctrl_dir / "transport"
|
||||||
|
transport_dir.mkdir()
|
||||||
|
(transport_dir / "address").write_text("0000:03:00.0")
|
||||||
|
(transport_dir / "trstring").write_text("pcie")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
"""Injected clock returning controlled time."""
|
||||||
|
def __init__(self, initial: datetime):
|
||||||
|
self.now = initial
|
||||||
|
def utcnow(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC1: Two successive readings produce correct read/write deltas
|
||||||
|
# visible through both the hour observations and the TUI query path.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSuccessiveReadings:
|
||||||
|
"""First reading is an anchor; second yields a measured interval."""
|
||||||
|
|
||||||
|
def test_two_readings_produce_both_rw_deltas(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
# DUW=10000000, DUR=8000000
|
||||||
|
r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
assert r1["ok"]
|
||||||
|
# +50 DUW, +30 DUR
|
||||||
|
r2 = run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
assert r2["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
# Hour observation should have both read and write deltas
|
||||||
|
hour = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta "
|
||||||
|
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
assert hour is not None
|
||||||
|
assert hour[0] == 50 * 512000 # writes
|
||||||
|
assert hour[1] == 30 * 512000 # reads
|
||||||
|
|
||||||
|
# TUI graph query should report both
|
||||||
|
daily = _query_daily_graph_data(conn)
|
||||||
|
assert len(daily) >= 1
|
||||||
|
day_entry = daily[-1]
|
||||||
|
bw_expected = 50 * 512000
|
||||||
|
br_expected = 30 * 512000
|
||||||
|
assert day_entry["total_written"] == bw_expected
|
||||||
|
assert day_entry["total_read"] == br_expected
|
||||||
|
assert day_entry["allocated_bytes"] == bw_expected
|
||||||
|
assert day_entry["allocated_read"] == br_expected
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_first_reading_is_anchor_no_delta(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
"""First sample alone produces no hour observation or day aggregate."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 1
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM hour_observations").fetchone()[0] == 0
|
||||||
|
daily = _query_daily_graph_data(conn)
|
||||||
|
assert all(d["is_zero"] or d["is_gap"] for d in daily)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC3: Repeated same-hour collections accumulate both reads and writes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSameHourAccumulation:
|
||||||
|
"""Multiple intervals in the same hour accumulate both BW and BR."""
|
||||||
|
|
||||||
|
def test_three_same_hour_readings_accumulate(self, tmp_path, sysfs_tree):
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
base_t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Three readings: 0→20→50 DUW, 0→10→35 DUR (all same hour)
|
||||||
|
duw_seq = [10000000, 10000020, 10000050]
|
||||||
|
dur_seq = [8000000, 8000010, 8000035]
|
||||||
|
for i in range(3):
|
||||||
|
t = base_t + timedelta(minutes=i * 3)
|
||||||
|
r = run_collection(
|
||||||
|
_make_smartctl(duw_seq[i], dur_seq[i]), sysfs_nvme, cfg, _Clock(t)
|
||||||
|
)
|
||||||
|
assert r["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
hour = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta, sample_count "
|
||||||
|
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
assert hour is not None
|
||||||
|
# 0→20 + 20→50 = 50 DUW delta
|
||||||
|
assert hour[0] == 50 * 512000
|
||||||
|
# 0→10 + 10→35 = 35 DUR delta
|
||||||
|
assert hour[1] == 35 * 512000
|
||||||
|
# 2 intervals × 2 samples each = 4 sample_count
|
||||||
|
assert hour[2] == 4
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_same_hour_zero_delta_both_counters(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
"""Repeated identical readings produce zero in both BW and BR."""
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
base_t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
t = base_t + timedelta(minutes=i * 3)
|
||||||
|
r = run_collection(
|
||||||
|
_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t)
|
||||||
|
)
|
||||||
|
assert r["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
hour = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta "
|
||||||
|
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
assert hour is not None
|
||||||
|
assert hour[0] == 0
|
||||||
|
assert hour[1] == 0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC4: Cross-hour measurements attributed to correct hours
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCrossHourAttribution:
|
||||||
|
"""Cross-hour deltas are unattributed to hours, kept at day level."""
|
||||||
|
|
||||||
|
def test_cross_hour_unattributed_bytes(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
t1 = datetime(2026, 9, 1, 11, 55, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
assert r1["ok"]
|
||||||
|
r2 = run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
assert r2["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
|
||||||
|
# Hour 11 and 12 should NOT contain the full cross-hour delta
|
||||||
|
full_bw = 100 * 512000
|
||||||
|
full_br = 60 * 512000
|
||||||
|
for prefix in ("2026-09-01T11%", "2026-09-01T12%"):
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta "
|
||||||
|
"FROM hour_observations WHERE hour LIKE ?", (prefix,)
|
||||||
|
).fetchone()
|
||||||
|
if row is not None:
|
||||||
|
assert row[0] != full_bw, "Hour should not have full cross-hour BW"
|
||||||
|
assert row[1] != full_br, "Hour should not have full cross-hour BR"
|
||||||
|
|
||||||
|
# Day aggregates should have unattributed bytes for both reads and writes
|
||||||
|
for day in ("2026-09-01",):
|
||||||
|
agg = derive_day(conn, day)
|
||||||
|
assert agg is not None
|
||||||
|
total_accounted = agg.bytes_written_delta + agg.bytes_read_delta
|
||||||
|
unattributed_w = conn.execute(
|
||||||
|
"SELECT unattributed_bytes_written FROM day_aggregates WHERE day = ?",
|
||||||
|
(day,)
|
||||||
|
).fetchone()
|
||||||
|
unattributed_r = conn.execute(
|
||||||
|
"SELECT unattributed_bytes_read FROM day_aggregates WHERE day = ?",
|
||||||
|
(day,)
|
||||||
|
).fetchone()
|
||||||
|
# Unattributed bytes should be present
|
||||||
|
assert unattributed_w is not None
|
||||||
|
assert unattributed_r is not None
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC5: Repeated refresh does not duplicate measured bytes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestNoDuplication:
|
||||||
|
"""Repeated collection does not duplicate measured bytes."""
|
||||||
|
|
||||||
|
def test_multiple_same_hour_no_duplication(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
"""Five same-hour reads, all monotonic: bytes never double-count."""
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
base_t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Monotonically increasing DUW: 100, 110, 125, 145, 180
|
||||||
|
duw_increments = [0, 10, 15, 20, 35]
|
||||||
|
dur_increments = [0, 5, 8, 12, 20]
|
||||||
|
for i in range(5):
|
||||||
|
duw = 10000000 + sum(duw_increments[:i + 1])
|
||||||
|
dur = 8000000 + sum(dur_increments[:i + 1])
|
||||||
|
t = base_t + timedelta(minutes=i * 2)
|
||||||
|
r = run_collection(_make_smartctl(duw, dur), sysfs_nvme, cfg, _Clock(t))
|
||||||
|
assert r["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
hour = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta "
|
||||||
|
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
assert hour is not None
|
||||||
|
# Total should be the cumulative delta across all 5 readings
|
||||||
|
total_duw_delta = sum(duw_increments)
|
||||||
|
total_dur_delta = sum(dur_increments)
|
||||||
|
assert hour[0] == total_duw_delta * 512000
|
||||||
|
assert hour[1] == total_dur_delta * 512000
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC: Consistent read-only snapshot
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestConcurrentReadConsistency:
|
||||||
|
"""A read-only reader sees consistent pre- or post-publication state."""
|
||||||
|
|
||||||
|
def test_read_only_sees_consistent_state(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
|
||||||
|
# Open read-only before second sample
|
||||||
|
ro_conn = sqlite3.connect("file:%s?mode=ro" % store, uri=True)
|
||||||
|
ro_conn.execute("BEGIN")
|
||||||
|
count_before = ro_conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
assert count_before == 1
|
||||||
|
ro_conn.close()
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
|
||||||
|
# Read-only after second sample sees updated state
|
||||||
|
ro_conn2 = sqlite3.connect("file:%s?mode=ro" % store, uri=True)
|
||||||
|
ro_conn2.execute("BEGIN")
|
||||||
|
count_after = ro_conn2.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
assert count_after == 2
|
||||||
|
ro_conn2.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC: Counter reset / segment boundary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCounterResetPreservesHistory:
|
||||||
|
"""Counter reset opens new segment without invalidating prior data."""
|
||||||
|
|
||||||
|
def test_duw_decrease_preserves_hour_data(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
t3 = datetime(2026, 9, 1, 12, 10, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
# Sample 1: normal
|
||||||
|
run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
# Sample 2: counter decrease (reset)
|
||||||
|
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
# Sample 3: new segment continuation
|
||||||
|
run_collection(_make_smartctl(10000080, 8000050), sysfs_nvme, cfg, _Clock(t3))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
|
||||||
|
# New segment opened for the reset
|
||||||
|
seg_count = conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0]
|
||||||
|
assert seg_count >= 2
|
||||||
|
|
||||||
|
# The reset sample (t2) is in a new segment; t3 derives from t2
|
||||||
|
hour_12 = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta "
|
||||||
|
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
assert hour_12 is not None
|
||||||
|
# Hour 12 should have data from at least the valid interval (t2→t3)
|
||||||
|
# t2→t3: 10000080-10000050=30 DUW, 8000050-8000030=20 DUR
|
||||||
|
assert hour_12[0] >= 30 * 512000
|
||||||
|
assert hour_12[1] >= 20 * 512000
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC: Monitoring period is opened and preserved
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMonitoringPeriodPreserved:
|
||||||
|
"""Collector opens monitoring period; subsequent samples keep it open."""
|
||||||
|
|
||||||
|
def test_first_sample_opens_period(self, tmp_path, sysfs_tree):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
period = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM monitoring_periods"
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert period == 1
|
||||||
|
|
||||||
|
def test_subsequent_samples_keep_one_period(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
period_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM monitoring_periods"
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert period_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC: Derivation failure preserves sample
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDerivationFailurePreservesSample:
|
||||||
|
"""Sample persists even if derivation fails."""
|
||||||
|
|
||||||
|
def test_sample_survives_derivation_failure(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
|
||||||
|
# Second sample succeeds normally
|
||||||
|
r2 = run_collection(
|
||||||
|
_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2)
|
||||||
|
)
|
||||||
|
assert r2["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC: Cross-day UTC boundary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCrossDayBoundary:
|
||||||
|
"""Measurements crossing UTC day boundary are conserved."""
|
||||||
|
|
||||||
|
def test_cross_day_unattributed_both_days(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 23, 55, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 2, 0, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
assert r1["ok"]
|
||||||
|
r2 = run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
assert r2["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
daily = _query_daily_graph_data(conn)
|
||||||
|
day_map = {d["day"]: d for d in daily}
|
||||||
|
|
||||||
|
# Both days should appear
|
||||||
|
assert "2026-09-01" in day_map
|
||||||
|
assert "2026-09-02" in day_map
|
||||||
|
|
||||||
|
# The cross-day delta is unattributed at the day level
|
||||||
|
sep1 = day_map["2026-09-01"]
|
||||||
|
sep2 = day_map["2026-09-02"]
|
||||||
|
# Both days may show the unattributed bytes
|
||||||
|
# (the delta is added to both days' unattributed totals as evidence)
|
||||||
|
total_w = sep1["allocated_bytes"] + sep1["unallocated_bytes"]
|
||||||
|
total_r = sep1["allocated_read"] + sep1["unallocated_read"]
|
||||||
|
# Day 1 has at least some recorded data
|
||||||
|
assert total_w >= 0
|
||||||
|
assert total_r >= 0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC: Hourly graph query includes read data
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestHourlyQueryIncludesReads:
|
||||||
|
"""Hourly graph data includes bytes_read_delta."""
|
||||||
|
|
||||||
|
def test_hourly_query_returns_read_data(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
hourly = _query_hourly_graph_data(conn, "2026-09-01", t2)
|
||||||
|
|
||||||
|
# Hour 12 should have read data
|
||||||
|
h12 = next(h for h in hourly if h["hour"] == "2026-09-01T12:00:00+00:00")
|
||||||
|
assert h12["bytes_written"] == 50 * 512000
|
||||||
|
assert h12["bytes_read"] == 30 * 512000
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC: Day aggregate derivation surfaces both read/write
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDayAggregateReadWrite:
|
||||||
|
"""Day aggregate includes both read and write deltas."""
|
||||||
|
|
||||||
|
def test_derive_day_shows_both_rw(
|
||||||
|
self, tmp_path, sysfs_tree,
|
||||||
|
):
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||||
|
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
day = derive_day(conn, "2026-09-01")
|
||||||
|
assert day is not None
|
||||||
|
assert day.bytes_written_delta == 50 * 512000
|
||||||
|
assert day.bytes_read_delta == 30 * 512000
|
||||||
|
assert day.sample_count >= 2
|
||||||
|
conn.close()
|
||||||
@@ -88,6 +88,33 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
|||||||
ensure_period_open(conn, datetime.fromisoformat(start))
|
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start=None, utc_end=None, bw=1024*1024*100,
|
||||||
|
br=0, coverage=0.95, samples=24, complete=True):
|
||||||
|
"""Insert a local_days row (issue #94 gate prerequisite)."""
|
||||||
|
if utc_start is None:
|
||||||
|
utc_start = local_date + "T00:00:00+00:00"
|
||||||
|
if utc_end is None:
|
||||||
|
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
|
||||||
|
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_days "
|
||||||
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
|
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||||
|
bw, br, coverage, samples, complete),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
|
||||||
|
"""Insert multiple complete local days to satisfy the issue #94 gate."""
|
||||||
|
for i in range(count):
|
||||||
|
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_local_day(conn, d, bw=bw)
|
||||||
|
|
||||||
|
|
||||||
class TestPrecedence:
|
class TestPrecedence:
|
||||||
def test_no_baseline_unavailable(self, store):
|
def test_no_baseline_unavailable(self, store):
|
||||||
_insert_segment(store)
|
_insert_segment(store)
|
||||||
@@ -127,6 +154,7 @@ class TestConfidenceRuleTable:
|
|||||||
def test_unavailable_no_baseline(self, store):
|
def test_unavailable_no_baseline(self, store):
|
||||||
_insert_segment(store)
|
_insert_segment(store)
|
||||||
_open_period(store)
|
_open_period(store)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
assert any("no applicable endurance baseline" in f for f in result.contributing_facts)
|
assert any("no applicable endurance baseline" in f for f in result.contributing_facts)
|
||||||
@@ -138,6 +166,7 @@ class TestConfidenceRuleTable:
|
|||||||
for i in range(20):
|
for i in range(20):
|
||||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=0)
|
_insert_day(store, d, bw=0)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
assert any("no finite projection" in f for f in result.contributing_facts)
|
assert any("no finite projection" in f for f in result.contributing_facts)
|
||||||
@@ -150,12 +179,14 @@ class TestConfidenceRuleTable:
|
|||||||
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=1024*1024*100)
|
_insert_day(store, d, bw=1024*1024*100)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.LIMITED
|
assert result.confidence_state == ConfidenceState.LIMITED
|
||||||
assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
|
assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
|
||||||
|
|
||||||
def test_limited_degraded_identity(self, store):
|
def test_limited_degraded_identity(self, store):
|
||||||
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
_insert_segment(store, degraded=True)
|
_insert_segment(store, degraded=True)
|
||||||
_open_period(store)
|
_open_period(store)
|
||||||
for i in range(20):
|
for i in range(20):
|
||||||
@@ -201,6 +232,7 @@ class TestZeroRate:
|
|||||||
for i in range(20):
|
for i in range(20):
|
||||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=0)
|
_insert_day(store, d, bw=0)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
assert any("no finite projection from this history" in f for f in result.contributing_facts)
|
assert any("no finite projection from this history" in f for f in result.contributing_facts)
|
||||||
@@ -385,6 +417,7 @@ class TestSustainedRegimeRate:
|
|||||||
d = (datetime(2026, 6, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 6, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-28T12:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-28T12:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock(year=2026, month=9, day=30, hour=12))
|
result = compute_projection(store, _clock(year=2026, month=9, day=30, hour=12))
|
||||||
# Regime should be capped at 90 days (from Jun 1 to Sep 30 = 90 days at cutoff)
|
# Regime should be capped at 90 days (from Jun 1 to Sep 30 = 90 days at cutoff)
|
||||||
# The 90-day cutoff is Sep 30 - 90 = Jul 1, so regime starts Jul 1
|
# The 90-day cutoff is Sep 30 - 90 = Jul 1, so regime starts Jul 1
|
||||||
@@ -447,6 +480,7 @@ class TestHabitChange:
|
|||||||
d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw_high)
|
_insert_day(store, d, bw=bw_high)
|
||||||
_insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-07", 1)
|
||||||
result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12))
|
result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12))
|
||||||
assert result.habit_change_fact is not None
|
assert result.habit_change_fact is not None
|
||||||
assert "usage habit changed" in result.habit_change_fact
|
assert "usage habit changed" in result.habit_change_fact
|
||||||
@@ -468,6 +502,7 @@ class TestHabitChange:
|
|||||||
d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw_low)
|
_insert_day(store, d, bw=bw_low)
|
||||||
_insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-07", 1)
|
||||||
result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12))
|
result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12))
|
||||||
assert result.habit_change_fact is not None
|
assert result.habit_change_fact is not None
|
||||||
assert "usage habit changed" in result.habit_change_fact
|
assert "usage habit changed" in result.habit_change_fact
|
||||||
@@ -524,6 +559,7 @@ class TestHabitChange:
|
|||||||
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.LIMITED
|
assert result.confidence_state == ConfidenceState.LIMITED
|
||||||
assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
|
assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
|
||||||
@@ -544,6 +580,7 @@ class TestWarmingGate:
|
|||||||
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.warming_fact is not None
|
assert result.warming_fact is not None
|
||||||
assert "warming up" in result.warming_fact
|
assert "warming up" in result.warming_fact
|
||||||
@@ -560,6 +597,7 @@ class TestWarmingGate:
|
|||||||
cov = 0.30 if i < 3 else 0.95
|
cov = 0.30 if i < 3 else 0.95
|
||||||
_insert_day(store, d, bw=bw, coverage=cov)
|
_insert_day(store, d, bw=bw, coverage=cov)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.warming_fact is not None
|
assert result.warming_fact is not None
|
||||||
assert "warming up" in result.warming_fact
|
assert "warming up" in result.warming_fact
|
||||||
@@ -589,6 +627,7 @@ class TestWarmingGate:
|
|||||||
cov = 0.30 if i < 3 else 0.95
|
cov = 0.30 if i < 3 else 0.95
|
||||||
_insert_day(store, d, bw=bw, coverage=cov)
|
_insert_day(store, d, bw=bw, coverage=cov)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.warming_fact is not None
|
assert result.warming_fact is not None
|
||||||
|
|
||||||
@@ -602,6 +641,7 @@ class TestWarmingGate:
|
|||||||
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
# Should have warming fact but still render
|
# Should have warming fact but still render
|
||||||
assert result.warming_fact is not None
|
assert result.warming_fact is not None
|
||||||
@@ -614,6 +654,7 @@ class TestWarmingGate:
|
|||||||
_insert_segment(store)
|
_insert_segment(store)
|
||||||
_open_period(store)
|
_open_period(store)
|
||||||
_insert_day(store, "2026-09-28", bw=100*1024*1024)
|
_insert_day(store, "2026-09-28", bw=100*1024*1024)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
assert result.headline_remaining_seconds is None
|
assert result.headline_remaining_seconds is None
|
||||||
@@ -626,6 +667,7 @@ class TestWarmingGate:
|
|||||||
for i in range(20):
|
for i in range(20):
|
||||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=0)
|
_insert_day(store, d, bw=0)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
assert result.headline_remaining_seconds is None
|
assert result.headline_remaining_seconds is None
|
||||||
@@ -646,6 +688,7 @@ class TestStalenessDrop:
|
|||||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
# Clock is 3 days after last data → staleness > 48h
|
# Clock is 3 days after last data → staleness > 48h
|
||||||
clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
|
clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
result = compute_projection(store, clock)
|
result = compute_projection(store, clock)
|
||||||
@@ -661,6 +704,7 @@ class TestStalenessDrop:
|
|||||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
|
clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
result = compute_projection(store, clock)
|
result = compute_projection(store, clock)
|
||||||
assert result.staleness_fact is not None
|
assert result.staleness_fact is not None
|
||||||
@@ -703,6 +747,7 @@ class TestSegmentBreakProjection:
|
|||||||
d = (datetime(2026, 9, 16) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 16) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-20T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-20T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-19", 1)
|
||||||
result = compute_projection(store, _clock(year=2026, month=9, day=20, hour=12))
|
result = compute_projection(store, _clock(year=2026, month=9, day=20, hour=12))
|
||||||
# Prior days exist in store but projection uses current segment
|
# Prior days exist in store but projection uses current segment
|
||||||
# 5 days in segment → regime_days = 5
|
# 5 days in segment → regime_days = 5
|
||||||
@@ -727,6 +772,7 @@ class TestSegmentBreakProjection:
|
|||||||
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-27", 1)
|
||||||
result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12))
|
result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12))
|
||||||
# Young regime (3 days) → Limited, not enough data for full confidence
|
# Young regime (3 days) → Limited, not enough data for full confidence
|
||||||
assert result.confidence_state == ConfidenceState.LIMITED
|
assert result.confidence_state == ConfidenceState.LIMITED
|
||||||
@@ -753,6 +799,7 @@ class TestSegmentBreakProjection:
|
|||||||
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-27", 1)
|
||||||
result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12))
|
result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12))
|
||||||
# Prior history quarantined; only 3 days in new segment
|
# Prior history quarantined; only 3 days in new segment
|
||||||
assert result.regime_days is not None
|
assert result.regime_days is not None
|
||||||
@@ -771,6 +818,7 @@ class TestDegradedIdentity:
|
|||||||
_insert_segment(store, identity_key=None, degraded=True)
|
_insert_segment(store, identity_key=None, degraded=True)
|
||||||
_open_period(store)
|
_open_period(store)
|
||||||
_insert_day(store, "2026-09-28", bw=100*1024*1024)
|
_insert_day(store, "2026-09-28", bw=100*1024*1024)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
# No baseline → Unavailable
|
# No baseline → Unavailable
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
@@ -787,6 +835,7 @@ class TestDegradedIdentity:
|
|||||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.LIMITED
|
assert result.confidence_state == ConfidenceState.LIMITED
|
||||||
assert any("controller identity unavailable" in f for f in result.contributing_facts)
|
assert any("controller identity unavailable" in f for f in result.contributing_facts)
|
||||||
@@ -802,6 +851,7 @@ class TestDegradedIdentity:
|
|||||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
# Stale clock (>48h)
|
# Stale clock (>48h)
|
||||||
clock = datetime(2026, 10, 5, 12, 0, 0, tzinfo=timezone.utc)
|
clock = datetime(2026, 10, 5, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
result = compute_projection(store, clock)
|
result = compute_projection(store, clock)
|
||||||
@@ -857,6 +907,7 @@ class TestIdentityChangeBlankKeys:
|
|||||||
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-25", 1)
|
||||||
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
|
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
|
||||||
# Prior history quarantined; only 5 days in new segment
|
# Prior history quarantined; only 5 days in new segment
|
||||||
assert result.regime_days is not None
|
assert result.regime_days is not None
|
||||||
@@ -880,6 +931,7 @@ class TestIdentityChangeBlankKeys:
|
|||||||
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-25", 1)
|
||||||
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
|
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
|
||||||
assert result.regime_days is not None
|
assert result.regime_days is not None
|
||||||
assert result.regime_days <= 5
|
assert result.regime_days <= 5
|
||||||
@@ -895,6 +947,7 @@ class TestIdentityChangeBlankKeys:
|
|||||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-25", 1)
|
||||||
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
|
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
|
||||||
# All 25 days in same segment (equal blanks continue)
|
# All 25 days in same segment (equal blanks continue)
|
||||||
assert result.regime_days is not None
|
assert result.regime_days is not None
|
||||||
|
|||||||
@@ -870,7 +870,7 @@ class TestRefreshTiming:
|
|||||||
_insert_segment(conn)
|
_insert_segment(conn)
|
||||||
_open_period(conn)
|
_open_period(conn)
|
||||||
now = _clock()
|
now = _clock()
|
||||||
ts = (now - timedelta(minutes=10)).isoformat()
|
ts = (now - timedelta(minutes=4)).isoformat()
|
||||||
_insert_sample(conn, ts)
|
_insert_sample(conn, ts)
|
||||||
for i in range(20):
|
for i in range(20):
|
||||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
@@ -878,27 +878,27 @@ class TestRefreshTiming:
|
|||||||
|
|
||||||
svc = {
|
svc = {
|
||||||
"boot_enabled": True, "timer_active": True,
|
"boot_enabled": True, "timer_active": True,
|
||||||
"last_collect_ok": True, "last_collect_age_s": 600,
|
"last_collect_ok": True, "last_collect_age_s": 240,
|
||||||
"last_collect_reason": None,
|
"last_collect_reason": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Sample is 10 min old (600s) → fresh (within 690s threshold)
|
# Sample is 4 min old (240s) → fresh (within 450s threshold)
|
||||||
comp1 = compose_status(conn, svc, now, store_fault=None, newer_schema=None)
|
comp1 = compose_status(conn, svc, now, store_fault=None, newer_schema=None)
|
||||||
assert comp1.freshness == "fresh"
|
assert comp1.freshness == "fresh"
|
||||||
assert comp1.state == StatusState.MONITORING
|
assert comp1.state == StatusState.MONITORING
|
||||||
|
|
||||||
# At now + 1 min → sample is 11 min old (660s) → still fresh
|
# At now + 3 min → sample is 7 min old (420s) → still fresh
|
||||||
now2 = now + timedelta(minutes=1)
|
now2 = now + timedelta(minutes=3)
|
||||||
comp2 = compose_status(conn, svc, now2, store_fault=None, newer_schema=None)
|
comp2 = compose_status(conn, svc, now2, store_fault=None, newer_schema=None)
|
||||||
assert comp2.freshness == "fresh"
|
assert comp2.freshness == "fresh"
|
||||||
|
|
||||||
# At now + 2 min → sample is 12 min old (720s) → missed (past 690s)
|
# At now + 5 min → sample is 9 min old (540s) → missed (past 450s)
|
||||||
now3 = now + timedelta(minutes=2)
|
now3 = now + timedelta(minutes=5)
|
||||||
comp3 = compose_status(conn, svc, now3, store_fault=None, newer_schema=None)
|
comp3 = compose_status(conn, svc, now3, store_fault=None, newer_schema=None)
|
||||||
assert comp3.freshness == "missed"
|
assert comp3.freshness == "missed"
|
||||||
assert comp3.state == StatusState.MONITORING
|
assert comp3.state == StatusState.MONITORING
|
||||||
|
|
||||||
# At now + 49 hours → sample is 49h 10min old → stale
|
# At now + 49 hours → sample is 49h 4min old → stale
|
||||||
now4 = now + timedelta(hours=49)
|
now4 = now + timedelta(hours=49)
|
||||||
comp4 = compose_status(conn, svc, now4, store_fault=None, newer_schema=None)
|
comp4 = compose_status(conn, svc, now4, store_fault=None, newer_schema=None)
|
||||||
assert comp4.freshness == "stale"
|
assert comp4.freshness == "stale"
|
||||||
|
|||||||
+116
-35
@@ -117,6 +117,33 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
|||||||
ensure_period_open(conn, datetime.fromisoformat(start))
|
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||||
|
utc_start=None, utc_end=None, bw=1024*1024*100,
|
||||||
|
br=0, coverage=0.95, samples=24, complete=True):
|
||||||
|
"""Insert a local_days row (issue #94 gate prerequisite)."""
|
||||||
|
if utc_start is None:
|
||||||
|
utc_start = local_date + "T00:00:00+00:00"
|
||||||
|
if utc_end is None:
|
||||||
|
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
|
||||||
|
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_days "
|
||||||
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
|
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||||
|
bw, br, coverage, samples, complete),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
|
||||||
|
"""Insert multiple complete local days to satisfy the issue #94 gate."""
|
||||||
|
for i in range(count):
|
||||||
|
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_local_day(conn, d, bw=bw)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Unit tests for helpers
|
# Unit tests for helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -184,6 +211,7 @@ class TestStateMatrix:
|
|||||||
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(conn, d, bw=1024*1024*100)
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||||
proj = compute_projection(conn, _clock())
|
proj = compute_projection(conn, _clock())
|
||||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||||
assert proj.headline_remaining_seconds is not None
|
assert proj.headline_remaining_seconds is not None
|
||||||
@@ -199,6 +227,7 @@ class TestStateMatrix:
|
|||||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||||
proj = compute_projection(conn, _clock())
|
proj = compute_projection(conn, _clock())
|
||||||
assert proj.confidence_state == ConfidenceState.SUPPORTED
|
assert proj.confidence_state == ConfidenceState.SUPPORTED
|
||||||
assert proj.headline_remaining_seconds is not None
|
assert proj.headline_remaining_seconds is not None
|
||||||
@@ -376,7 +405,7 @@ class TestDenseScreen:
|
|||||||
main_grid = app.query_one("#main-grid")
|
main_grid = app.query_one("#main-grid")
|
||||||
assert str(main_grid.styles.layout) == "<grid>"
|
assert str(main_grid.styles.layout) == "<grid>"
|
||||||
assert main_grid.has_class("paused")
|
assert main_grid.has_class("paused")
|
||||||
assert len(main_grid.styles.grid_rows) == 5
|
assert len(main_grid.styles.grid_rows) == 6
|
||||||
banner = str(paused_banner.render()).lower()
|
banner = str(paused_banner.render()).lower()
|
||||||
assert "monitoring: paused — deliberate disable" in banner
|
assert "monitoring: paused — deliberate disable" in banner
|
||||||
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner
|
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner
|
||||||
@@ -771,12 +800,29 @@ class TestQueryDailyGraphData:
|
|||||||
assert result[0]["is_gap"] is True
|
assert result[0]["is_gap"] is True
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def test_window_is_consecutive_utc_days_with_explicit_gaps(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_day(conn, "2026-09-14", bw=500)
|
||||||
|
result = _query_daily_graph_data(
|
||||||
|
conn, datetime(2026, 9, 16, 12, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert len(result) == 90
|
||||||
|
assert result[-3]["day"] == "2026-09-14"
|
||||||
|
assert result[-2]["day"] == "2026-09-15"
|
||||||
|
assert result[-2]["is_gap"] is True
|
||||||
|
assert result[-1]["day"] == "2026-09-16"
|
||||||
|
assert result[-1]["is_partial"] is True
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
class TestQueryHourlyGraphData:
|
class TestQueryHourlyGraphData:
|
||||||
def test_empty_day(self, tmp_path):
|
def test_empty_day(self, tmp_path):
|
||||||
conn = init_store(tmp_path / "test.db")
|
conn = init_store(tmp_path / "test.db")
|
||||||
result = _query_hourly_graph_data(conn, "2026-09-20")
|
result = _query_hourly_graph_data(
|
||||||
assert result == []
|
conn, "2026-09-20", datetime(2026, 9, 21, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert len(result) == 24
|
||||||
|
assert all(item["is_gap"] for item in result)
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def test_with_hours(self, tmp_path):
|
def test_with_hours(self, tmp_path):
|
||||||
@@ -795,9 +841,25 @@ class TestQueryHourlyGraphData:
|
|||||||
def test_hour_labels(self, tmp_path):
|
def test_hour_labels(self, tmp_path):
|
||||||
conn = init_store(tmp_path / "test.db")
|
conn = init_store(tmp_path / "test.db")
|
||||||
_insert_hour(conn, "2026-09-20T12:00:00+00:00", bw=1000)
|
_insert_hour(conn, "2026-09-20T12:00:00+00:00", bw=1000)
|
||||||
result = _query_hourly_graph_data(conn, "2026-09-20")
|
result = _query_hourly_graph_data(
|
||||||
assert len(result) == 1
|
conn, "2026-09-20", datetime(2026, 9, 21, tzinfo=timezone.utc),
|
||||||
assert result[0]["local_label"] == "12"
|
)
|
||||||
|
assert len(result) == 24
|
||||||
|
assert result[12]["local_label"] == "12"
|
||||||
|
assert result[12]["bytes_written"] == 1000
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_current_hour_is_partial_and_future_hours_are_not_gaps(self, tmp_path):
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
result = _query_hourly_graph_data(
|
||||||
|
conn, "2026-09-20",
|
||||||
|
datetime(2026, 9, 20, 14, 30, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert result[13]["is_gap"] is True
|
||||||
|
assert result[14]["is_partial"] is True
|
||||||
|
assert result[14]["is_gap"] is True
|
||||||
|
assert result[15]["is_future"] is True
|
||||||
|
assert result[15]["is_gap"] is False
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -848,6 +910,17 @@ class TestDailyBarGraph:
|
|||||||
graph._trim_to_range()
|
graph._trim_to_range()
|
||||||
assert len(graph._day_data) == 7
|
assert len(graph._day_data) == 7
|
||||||
|
|
||||||
|
def test_scale_includes_allocated_and_unallocated_writes(self):
|
||||||
|
graph = DailyBarGraph()
|
||||||
|
graph._all_day_data = [{
|
||||||
|
"day": "2026-09-16",
|
||||||
|
"total_bytes": 800,
|
||||||
|
"allocated_bytes": 500,
|
||||||
|
"unallocated_bytes": 300,
|
||||||
|
}]
|
||||||
|
graph._trim_to_range()
|
||||||
|
assert graph._max_bytes == 800
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Bar graph headless TUI tests (issue #75)
|
# Bar graph headless TUI tests (issue #75)
|
||||||
@@ -877,6 +950,13 @@ class TestBarGraphTUI:
|
|||||||
legend = str(app.query_one("#bar-legend").render())
|
legend = str(app.query_one("#bar-legend").render())
|
||||||
assert "Alloc" in legend
|
assert "Alloc" in legend
|
||||||
assert "Zero" in legend
|
assert "Zero" in legend
|
||||||
|
range_label = str(app.query_one("#bar-range").render())
|
||||||
|
assert "14 days" in range_label
|
||||||
|
assert "UTC" in range_label
|
||||||
|
plotted = str(app.query_one("#bar-render").render())
|
||||||
|
assert "Writes (" in plotted
|
||||||
|
assert "Day UTC" in plotted
|
||||||
|
assert "→" in plotted
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_arrow_selection(self, tmp_path):
|
async def test_arrow_selection(self, tmp_path):
|
||||||
@@ -897,20 +977,16 @@ class TestBarGraphTUI:
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
# Right arrow selects first bar
|
# The newest day is selected by default; left moves backward.
|
||||||
await pilot.press("right")
|
initial = graph.selected_index
|
||||||
await pilot.pause()
|
|
||||||
assert graph.selected_index == 0
|
|
||||||
|
|
||||||
# Right again moves to second
|
|
||||||
await pilot.press("right")
|
|
||||||
await pilot.pause()
|
|
||||||
assert graph.selected_index == 1
|
|
||||||
|
|
||||||
# Left moves back
|
|
||||||
await pilot.press("left")
|
await pilot.press("left")
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert graph.selected_index == 0
|
assert graph.selected_index == initial - 1
|
||||||
|
|
||||||
|
# Right returns to the newest day.
|
||||||
|
await pilot.press("right")
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.selected_index == initial
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_range_switching(self, tmp_path):
|
async def test_range_switching(self, tmp_path):
|
||||||
@@ -941,17 +1017,17 @@ class TestBarGraphTUI:
|
|||||||
assert graph.range_days == 7
|
assert graph.range_days == 7
|
||||||
assert len(graph._day_data) == 7
|
assert len(graph._day_data) == 7
|
||||||
|
|
||||||
# Press 3 for 28-day range
|
# Press 3 for 30-day range
|
||||||
await pilot.press("3")
|
await pilot.press("3")
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert graph.range_days == 28
|
assert graph.range_days == 30
|
||||||
assert len(graph._day_data) == 28
|
assert len(graph._day_data) == 30
|
||||||
|
|
||||||
# Press 4 for 90-day range (only 30 days available)
|
# Exact windows retain gap slots instead of compressing time.
|
||||||
await pilot.press("4")
|
await pilot.press("4")
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert graph.range_days == 90
|
assert graph.range_days == 90
|
||||||
assert len(graph._day_data) == 30
|
assert len(graph._day_data) == 90
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_readout_updates_on_selection(self, tmp_path):
|
async def test_readout_updates_on_selection(self, tmp_path):
|
||||||
@@ -972,12 +1048,13 @@ class TestBarGraphTUI:
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
# No selection initially
|
# Newest day is selected initially.
|
||||||
readout = str(app.query_one("#bar-readout").render())
|
readout = str(app.query_one("#bar-readout").render())
|
||||||
assert "select" in readout.lower()
|
assert "UTC" in readout
|
||||||
|
assert "GB" in readout
|
||||||
|
|
||||||
# Select first bar
|
# Moving left updates the selected-day readout.
|
||||||
await pilot.press("right")
|
await pilot.press("left")
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
readout = str(app.query_one("#bar-readout").render())
|
readout = str(app.query_one("#bar-readout").render())
|
||||||
assert "2026-09" in readout
|
assert "2026-09" in readout
|
||||||
@@ -1007,10 +1084,8 @@ class TestBarGraphTUI:
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
# Select a day
|
# The newest day is selected automatically.
|
||||||
await pilot.press("right")
|
assert graph.selected_index == len(graph._day_data) - 1
|
||||||
await pilot.pause()
|
|
||||||
assert graph.selected_index == 0
|
|
||||||
assert graph.view_mode == "daily"
|
assert graph.view_mode == "daily"
|
||||||
|
|
||||||
# Enter drill-down
|
# Enter drill-down
|
||||||
@@ -1020,6 +1095,14 @@ class TestBarGraphTUI:
|
|||||||
assert graph.drill_day is not None
|
assert graph.drill_day is not None
|
||||||
assert len(graph._hour_data) == 24
|
assert len(graph._hour_data) == 24
|
||||||
|
|
||||||
|
# Five-minute data refreshes retain the selected hour.
|
||||||
|
graph._hourly_selected = 12
|
||||||
|
selected_hour = graph._hour_data[12]["hour"]
|
||||||
|
app._refresh()
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.view_mode == "hourly"
|
||||||
|
assert graph._hour_data[graph._hourly_selected]["hour"] == selected_hour
|
||||||
|
|
||||||
# Esc returns to daily
|
# Esc returns to daily
|
||||||
await pilot.press("escape")
|
await pilot.press("escape")
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
@@ -1125,7 +1208,7 @@ class TestConstrainedLayout:
|
|||||||
summary_text = str(summary.render())
|
summary_text = str(summary.render())
|
||||||
assert "Graph needs ≥80×24" in summary_text
|
assert "Graph needs ≥80×24" in summary_text
|
||||||
assert "days" in summary_text
|
assert "days" in summary_text
|
||||||
assert "GB total" in summary_text
|
assert "GB" in summary_text
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_constrained_below_24_height(self, tmp_path):
|
async def test_constrained_below_24_height(self, tmp_path):
|
||||||
@@ -1208,9 +1291,7 @@ class TestConstrainedLayout:
|
|||||||
# Focus and select a day
|
# Focus and select a day
|
||||||
app.set_focus(graph)
|
app.set_focus(graph)
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
await pilot.press("right")
|
assert graph.selected_index == len(graph._day_data) - 1
|
||||||
await pilot.pause()
|
|
||||||
assert graph.selected_index == 0
|
|
||||||
|
|
||||||
# Resize to constrained — pilot.resize_terminal changes terminal size
|
# Resize to constrained — pilot.resize_terminal changes terminal size
|
||||||
await pilot.resize_terminal(79, 24)
|
await pilot.resize_terminal(79, 24)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Documentation=https://git.bongbetic.com/xavierk/Fenris
|
|||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
OnBootSec=2min
|
OnBootSec=2min
|
||||||
OnUnitInactiveSec=5min
|
OnUnitInactiveSec=3min
|
||||||
AccuracySec=30s
|
AccuracySec=30s
|
||||||
Persistent=no
|
Persistent=no
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
#
|
#
|
||||||
# Guarantees (must match systemd timer semantics):
|
# Guarantees (must match systemd timer semantics):
|
||||||
# - Initial 2-minute boot delay (sleep 120 before first collect)
|
# - Initial 2-minute boot delay (sleep 120 before first collect)
|
||||||
# - Completion-relative 5-minute cadence (sleep 300 after collect)
|
# - Completion-relative 3-minute cadence (sleep 180 after collect)
|
||||||
# - Bounded execution (timeout 90s on each collect)
|
# - Bounded execution (timeout 90s on each collect)
|
||||||
# - No catch-up (fixed sleep interval, no Persistent=)
|
# - No catch-up (fixed sleep interval, no Persistent=)
|
||||||
# - Serialized runs (runsv does not restart until exit)
|
# - Serialized runs (runsv does not restart until exit)
|
||||||
@@ -20,7 +20,7 @@ set -eu
|
|||||||
|
|
||||||
COLLECT_TIMEOUT=90
|
COLLECT_TIMEOUT=90
|
||||||
BOOT_DELAY=120
|
BOOT_DELAY=120
|
||||||
CADENCE=300
|
CADENCE=180
|
||||||
LOCK_FILE=/var/lib/fenris/fenris-collect.lock
|
LOCK_FILE=/var/lib/fenris/fenris-collect.lock
|
||||||
|
|
||||||
# Ensure lock directory exists
|
# Ensure lock directory exists
|
||||||
|
|||||||
Reference in New Issue
Block a user