Compare commits
15
Commits
f06424f3b8
..
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bac26836a | ||
|
|
88672e12bf | ||
|
|
cfd71a053b | ||
|
|
b76067104b | ||
|
|
95cca2e115 | ||
|
|
5df8a12339 | ||
|
|
e12f4a574c | ||
|
|
4f884b4b73 | ||
|
|
366c2f55b7 | ||
|
|
54cd56e4ec | ||
|
|
69e08d9d3a | ||
|
|
714e69be52 | ||
|
|
ba16413363 | ||
|
|
dfe6a6a2d0 | ||
|
|
44c57b70dd |
@@ -64,6 +64,20 @@ jobs:
|
||||
-o /tmp/nfpm.tar.gz
|
||||
sudo tar -xzf /tmp/nfpm.tar.gz -C /usr/local/bin nfpm
|
||||
nfpm --version
|
||||
# Ubuntu does not package the XBPS build tools. Use Void's static
|
||||
# toolchain, pinned and checksum-verified before it reaches PATH.
|
||||
XBPS_STATIC_VERSION=0.60.4_1
|
||||
XBPS_STATIC_ARCHIVE="xbps-static-static-${XBPS_STATIC_VERSION}.x86_64-musl.tar.xz"
|
||||
XBPS_STATIC_SHA256=603b3c55e9cabd5af79b461b929b14e1556a443c97b5714d188681c2172d9e28
|
||||
curl --fail --silent --show-error --location \
|
||||
"https://repo-default.voidlinux.org/static/${XBPS_STATIC_ARCHIVE}" \
|
||||
-o "/tmp/${XBPS_STATIC_ARCHIVE}"
|
||||
echo "${XBPS_STATIC_SHA256} /tmp/${XBPS_STATIC_ARCHIVE}" | sha256sum --check --strict
|
||||
mkdir -p /tmp/xbps-static
|
||||
tar -xJf "/tmp/${XBPS_STATIC_ARCHIVE}" -C /tmp/xbps-static
|
||||
export PATH="/tmp/xbps-static/usr/bin:${PATH}"
|
||||
echo "/tmp/xbps-static/usr/bin" >> "$GITHUB_PATH"
|
||||
xbps-create --version
|
||||
|
||||
- name: Build packages
|
||||
run: make package
|
||||
|
||||
@@ -9,6 +9,44 @@ backfill releases from before this changelog.
|
||||
|
||||
## [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
|
||||
|
||||
### Changed
|
||||
|
||||
- Use sentence case throughout the dashboard and add persistent keyboard and sudo guidance.
|
||||
- Share read-only status acquisition between the CLI and TUI, preserving unknown monitoring state and store-fault recovery guidance.
|
||||
- Use one authenticated action path for the CLI and TUI; let valid collection runs finish without the former 30-second dashboard cutoff.
|
||||
- Remove the unused sparkline and habit-bar rendering path while retaining the interactive history graph.
|
||||
- Align all package formats on the MIT license and Python 3.10 minimum; include the license text in native packages.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Correct observation-store directory permissions in native packages, including repair of older runit installations during upgrade.
|
||||
|
||||
## [0.3.5] - 2026-09-14
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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.
|
||||
_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**:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Fenris contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -74,7 +74,7 @@ install: check-python check-smartctl dist/fenris-*.whl
|
||||
|
||||
@echo "=== Creating data directory (root-written, group-read) ==="
|
||||
@sudo groupadd -f fenris
|
||||
@sudo install -d -o root -g fenris -m 2750 $(DATA_DIR)
|
||||
@sudo install -d -o root -g fenris -m 2770 $(DATA_DIR)
|
||||
|
||||
@echo "=== Installing version-neutral runtime packages ==="
|
||||
@sudo rm -rf $(VENV_DIR)
|
||||
|
||||
@@ -211,7 +211,7 @@ Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`)
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
@@ -252,11 +252,33 @@ is retried at the next interval; it never fabricates missing observations.
|
||||
|
||||
## Reading the dashboard
|
||||
|
||||
`fenris` opens the TUI dashboard.
|
||||
Run `fenris` as your normal user to open the TUI dashboard. The dashboard does
|
||||
not need `sudo`. Use `sudo` for package installation and system configuration;
|
||||
pause, resume, and collect-now actions normally authenticate through polkit.
|
||||
|
||||
If the observation store is inaccessible, add your login user to the `fenris`
|
||||
group with `sudo usermod -aG fenris "$USER"`, then log out and back in.
|
||||
|
||||
If polkit authentication is unavailable, quit the dashboard and run only the
|
||||
required administrative action in your terminal:
|
||||
|
||||
```bash
|
||||
sudo fenris monitor resume # Enable monitoring now and across reboots
|
||||
sudo fenris monitor pause # Confirm a deliberate monitoring pause
|
||||
sudo fenris sample # Request one collection run
|
||||
```
|
||||
|
||||
Reopen the dashboard with `fenris` afterward. Press `?` for these instructions
|
||||
and keyboard controls at any time; use the arrow keys to scroll and `Esc` to close.
|
||||
|
||||
The CLI and TUI share the same authenticated action path. Authentication and
|
||||
waiting for collection have no separate dashboard deadline; the native collector
|
||||
enforces its 90-second runtime limit. An interrupted or failed action is not
|
||||
automatically retried—check `fenris status` before retrying.
|
||||
|
||||
- **Continuity** — the service strip's continuity line (and `fenris status`) reports whether monitoring survives reboots: `monitoring: active in background · persists across reboots`, or `monitoring: does not start on next boot`.
|
||||
- **Paused vs. quit** — a full-width `monitoring: paused — deliberate disable` block means collection is stopped (`fenris monitor pause`); resume with `fenris monitor resume`. Pressing `q` only leaves the screen — monitoring keeps running in the background.
|
||||
- **Auth banner** — at launch, `privileged actions will prompt for authentication (polkit)` shows once and clears on the first refresh. Privileged actions elevate via polkit; Fenris never asks for sudo.
|
||||
- **Auth banner** — the launch notice explains normal-user startup and polkit authentication, then clears on the first refresh. The `?` help screen remains available.
|
||||
|
||||
Per-release notes live on the [releases page](https://git.bongbetic.com/xavierk/Fenris/releases): each entry is the version's `CHANGELOG.md` section — what was added, changed, and fixed — plus standing install and verification instructions.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Fenris's current single process combines daemonization, a PID file, an HTTP dash
|
||||
## 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).
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# One MIT license across source and packages
|
||||
|
||||
The native package metadata previously disagreed: deb/rpm declared Proprietary,
|
||||
while XBPS declared MIT and the repository carried no license text. On
|
||||
2026-09-16 the maintainer chose MIT for Fenris. The repository now includes the
|
||||
standard MIT license, and Python, deb, rpm, and XBPS distributions must preserve
|
||||
that same licensing decision; bundled third-party dependencies retain their own
|
||||
license notices.
|
||||
@@ -49,7 +49,7 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
|
||||
## 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-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-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.
|
||||
|
||||
@@ -16,7 +16,7 @@ Every constant is defined once, in the section named below; other sections cite,
|
||||
|
||||
| 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 |
|
||||
| Timer accuracy window | 30 s (`AccuracySec`) | §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:
|
||||
|
||||
- **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);
|
||||
- **stale** — ≥ 48 h, matching the §6.7 evidence gate;
|
||||
- **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
|
||||
```
|
||||
|
||||
Or install directly:
|
||||
Or make a one-off request without writing the configuration file:
|
||||
```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
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ description: >
|
||||
NVMe wear monitor with persistent TUI — observes real-world drive use and
|
||||
translates it into an understandable endurance outlook.
|
||||
homepage: https://git.bongbetic.com/xavierk/Fenris
|
||||
license: Proprietary
|
||||
license: MIT
|
||||
|
||||
depends:
|
||||
- python3 (>= 3.10)
|
||||
@@ -38,7 +38,7 @@ contents:
|
||||
- dst: /var/lib/fenris
|
||||
type: dir
|
||||
file_info:
|
||||
mode: 2750
|
||||
mode: 02770
|
||||
group: fenris
|
||||
|
||||
scripts:
|
||||
|
||||
@@ -63,7 +63,8 @@ print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||
else
|
||||
# runit: create group, set directory permissions
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2750 "${STORE_DIR}" 2>/dev/null || true
|
||||
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
|
||||
chmod 02770 "${STORE_DIR}"
|
||||
# Mark runit service as dormant (down) for fresh install
|
||||
if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then
|
||||
touch /etc/sv/fenris-collect/down
|
||||
|
||||
@@ -12,7 +12,10 @@ sudo xbps-install fenris # Void Linux
|
||||
For Void Linux, configure the XBPS repository first:
|
||||
|
||||
```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
|
||||
|
||||
@@ -24,7 +24,8 @@ if [ "$1" -eq 1 ]; then
|
||||
else
|
||||
# runit: create group, set directory permissions
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2750 "${STORE_DIR}" 2>/dev/null || true
|
||||
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
|
||||
chmod 02770 "${STORE_DIR}"
|
||||
# Mark runit service as dormant (down) for fresh install
|
||||
if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then
|
||||
touch /etc/sv/fenris-collect/down
|
||||
@@ -69,6 +70,8 @@ print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||
else
|
||||
# runit: repair log access when upgrading from an older package
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
|
||||
chmod 02770 "${STORE_DIR}"
|
||||
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -58,6 +58,9 @@ python3 -m pip install --disable-pip-version-check --no-compile \
|
||||
# observation store.
|
||||
chmod -R a+rX "${VENDOR_DIR}"
|
||||
|
||||
# Ship the application license in every native package.
|
||||
install -D -m 0644 "${REPO_ROOT}/LICENSE" "${STAGE_DIR}/usr/share/licenses/fenris/LICENSE"
|
||||
|
||||
# --- Inject version into wrapper from pyproject.toml ---
|
||||
# The wrapper has a hardcoded version string; patch it for packaging.
|
||||
WRAPPER_SRC="${REPO_ROOT}/scripts/fenris"
|
||||
|
||||
@@ -34,6 +34,10 @@ case "${ACTION}" in
|
||||
fi
|
||||
;;
|
||||
post)
|
||||
# Keep observation-store access consistent across fresh installs and upgrades.
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
|
||||
chmod 02770 "${STORE_DIR}"
|
||||
if [ "${UPDATE}" = "yes" ]; then
|
||||
# Upgrade — snapshot, migration, runit-aware reload
|
||||
if [ -f "${STORE_DB}" ]; then
|
||||
@@ -53,7 +57,6 @@ print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||
else
|
||||
# Fresh install — runit service setup
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2770 "${STORE_DIR}" 2>/dev/null || true
|
||||
# Mark runit service as dormant (down) for fresh install
|
||||
if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then
|
||||
touch /etc/sv/fenris-collect/down
|
||||
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
[project]
|
||||
name = "fenris"
|
||||
version = "0.3.5"
|
||||
version = "0.4.0"
|
||||
description = "NVMe wear monitor with persistent TUI"
|
||||
requires-python = ">=3.9"
|
||||
requires-python = ">=3.10"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"textual>=0.40.0",
|
||||
]
|
||||
|
||||
@@ -9,3 +9,4 @@ mdurl==0.1.2
|
||||
platformdirs==4.11.7
|
||||
Pygments==2.21.0
|
||||
linkify-it-py==2.2.0
|
||||
typing-extensions==4.16.0
|
||||
|
||||
+9
-30
@@ -7,8 +7,6 @@ Subcommands route through fenris-monitor for privileged operations.
|
||||
Spec: §1.2, §8.4
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -35,35 +33,16 @@ def add_runtime_packages() -> None:
|
||||
add_runtime_packages()
|
||||
|
||||
|
||||
def is_root() -> bool:
|
||||
"""Check if running as root."""
|
||||
return os.geteuid() == 0
|
||||
|
||||
|
||||
def run_monitor(*args: str) -> None:
|
||||
"""Run fenris-monitor with the given arguments.
|
||||
"""Render the shared privileged-action outcome for the CLI."""
|
||||
from fenris.control import MonitorError, run_monitor as invoke_monitor
|
||||
|
||||
If not root, re-exec under pkexec.
|
||||
"""
|
||||
monitor_cmd = "/usr/libexec/fenris/fenris-monitor"
|
||||
|
||||
if is_root():
|
||||
result = subprocess.run([monitor_cmd] + list(args))
|
||||
sys.exit(result.returncode)
|
||||
else:
|
||||
# Use pkexec to elevate
|
||||
pkexec = subprocess.run(
|
||||
["which", "pkexec"], capture_output=True
|
||||
)
|
||||
if pkexec.returncode != 0:
|
||||
print(
|
||||
"Error: No polkit agent available. "
|
||||
"Run as root: sudo fenris-monitor ...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
result = subprocess.run(["pkexec", monitor_cmd] + list(args))
|
||||
sys.exit(result.returncode)
|
||||
try:
|
||||
invoke_monitor(*args)
|
||||
except MonitorError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
sys.exit(exc.exit_code)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def cmd_tui(args: argparse.Namespace) -> None:
|
||||
@@ -143,7 +122,7 @@ def main() -> None:
|
||||
description="Fenris NVMe endurance monitor",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", action="version", version="%(prog)s 0.3.0"
|
||||
"--version", action="version", version="%(prog)s 0.3.6"
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
@@ -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 ""
|
||||
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 "Key ceremony: delete the private key after publication."
|
||||
echo " See docs/install/signing-key-ceremony.md"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
"""Fenris: NVMe wear monitor with persistent TUI."""
|
||||
__version__ = "0.3.1"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
@@ -340,6 +340,26 @@ def run_collection(
|
||||
"data_units_read": sample["data_units_read"],
|
||||
}
|
||||
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:
|
||||
# Derivation failure must not prevent sample persistence (issue #73 AC6)
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Terminal-attached invocation of Fenris's fixed privileged operations.
|
||||
|
||||
Both human entry points use this module. Authentication has no frontend
|
||||
deadline; collection runtime is bounded by the native scheduler (ADR 0003).
|
||||
"""
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
|
||||
MONITOR_HELPER = "/usr/libexec/fenris/fenris-monitor"
|
||||
|
||||
|
||||
class MonitorError(Exception):
|
||||
"""An action failed, with a message and exit status for either renderer."""
|
||||
|
||||
def __init__(self, message: str, exit_code: int = 1):
|
||||
super().__init__(message)
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
def run_monitor(*args: str, helper_path: str = MONITOR_HELPER) -> None:
|
||||
"""Run one helper operation, inheriting the terminal for authentication.
|
||||
|
||||
Never invoke a shell, retry an action, or fall back to sudo automatically.
|
||||
The helper owns the operation allow-list and privileged state changes.
|
||||
"""
|
||||
helper_command = [helper_path, *args]
|
||||
needs_auth = os.geteuid() != 0
|
||||
command = ["pkexec", *helper_command] if needs_auth else helper_command
|
||||
root_hint = (
|
||||
" If authentication is unavailable, run in your terminal: "
|
||||
+ shlex.join(["sudo", *helper_command])
|
||||
) if needs_auth else ""
|
||||
|
||||
try:
|
||||
# The collector owns its 90-second runtime limit. A frontend timeout
|
||||
# would also count time spent authenticating or waiting for a run.
|
||||
result = subprocess.run(command)
|
||||
except FileNotFoundError as exc:
|
||||
raise MonitorError(
|
||||
"Command not found: %s.%s" % (exc.filename or command[0], root_hint), 127,
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise MonitorError("Cannot run monitoring action: %s.%s" % (exc, root_hint)) from exc
|
||||
except KeyboardInterrupt as exc:
|
||||
raise MonitorError(
|
||||
"Action interrupted. Check fenris status before retrying.", 130,
|
||||
) from exc
|
||||
|
||||
if result.returncode:
|
||||
exit_code = result.returncode if result.returncode > 0 else 128 - result.returncode
|
||||
raise MonitorError(
|
||||
"Action failed (exit %d). Check fenris status before retrying.%s"
|
||||
% (exit_code, root_hint), exit_code,
|
||||
)
|
||||
@@ -163,3 +163,41 @@ def derive_all_days(conn: sqlite3.Connection) -> list[DayAggregate]:
|
||||
if agg is not None:
|
||||
results.append(agg)
|
||||
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]),
|
||||
)
|
||||
|
||||
+16
-9
@@ -175,7 +175,8 @@ def _upsert_hour_observation(
|
||||
"""Insert or update an hour observation."""
|
||||
# Check if hour exists
|
||||
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,),
|
||||
).fetchone()
|
||||
|
||||
@@ -198,10 +199,13 @@ def _upsert_hour_observation(
|
||||
else:
|
||||
# Merge: accumulate bytes and sample count
|
||||
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(
|
||||
"UPDATE hour_observations SET bytes_written_delta = ?, sample_count = ? WHERE id = ?",
|
||||
(new_bw, new_samples, existing[0]),
|
||||
"UPDATE hour_observations "
|
||||
"SET bytes_written_delta = ?, bytes_read_delta = ?, sample_count = ? "
|
||||
"WHERE id = ?",
|
||||
(new_bw, new_br, new_samples, existing[0]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -213,12 +217,15 @@ def _add_unattributed_bytes(
|
||||
bw_delta: int,
|
||||
br_delta: int,
|
||||
) -> None:
|
||||
"""Add unattributed byte deltas to day aggregates for each day touched."""
|
||||
prev_day = prev_ts.strftime("%Y-%m-%d")
|
||||
next_day = next_ts.strftime("%Y-%m-%d")
|
||||
"""Store unattributed byte deltas once as shared boundary evidence.
|
||||
|
||||
A cross-midnight interval's delta is preserved on the day where it
|
||||
STARTS (the earlier day). It is not duplicated into both days;
|
||||
the spec requires preserving the measured volume once as shared
|
||||
unallocated boundary evidence (issue #88).
|
||||
"""
|
||||
day = prev_ts.strftime("%Y-%m-%d")
|
||||
|
||||
days = {prev_day, next_day}
|
||||
for day in days:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM day_aggregates WHERE day = ?", (day,)
|
||||
).fetchone()
|
||||
|
||||
@@ -10,13 +10,13 @@ responsibility shared by the privileged control path and read-only status
|
||||
composition.
|
||||
|
||||
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/
|
||||
/var/service/fenris-collect — symlink to enable
|
||||
/etc/sv/fenris-collect/down — marker for dormant install
|
||||
|
||||
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)
|
||||
- Bounded execution (90s timeout via timeout(1))
|
||||
- No catch-up (service sleeps fixed interval, no Persistent= flag)
|
||||
@@ -166,11 +166,11 @@ def _systemd_query_state() -> Dict[str, Any]:
|
||||
"ActiveState", "ExecMainStatus", "ExecMainExitTimestamp",
|
||||
)
|
||||
|
||||
boot_enabled_str = timer_props.get("UnitFileState", "")
|
||||
boot_enabled = boot_enabled_str == "enabled"
|
||||
boot_enabled_str = timer_props.get("UnitFileState")
|
||||
boot_enabled = boot_enabled_str == "enabled" if boot_enabled_str else None
|
||||
|
||||
active_state = timer_props.get("ActiveState", "inactive")
|
||||
timer_active = active_state == "active"
|
||||
active_state = timer_props.get("ActiveState")
|
||||
timer_active = active_state == "active" if active_state else None
|
||||
|
||||
last_collect_ok = None
|
||||
last_collect_age_s = None
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -499,6 +524,30 @@ def compute_projection(conn, clock_now):
|
||||
tier, baseline, baseline_label, baseline_facts = _resolve_baseline(conn, current_segment)
|
||||
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)
|
||||
all_days = _get_all_days(conn)
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
Raw samples are pruned opportunistically to 14 days.
|
||||
Hour observations and day aggregates are retained indefinitely.
|
||||
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
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
+102
-231
@@ -14,10 +14,14 @@ Freshness constants are defined once here and shared with the TUI (§8.9):
|
||||
Criteria: LC-9, CI-2, CI-4, FL-4, FL-5, FL-7.
|
||||
"""
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .status_composition import StatusComposition
|
||||
|
||||
from .projection import compute_projection, ConfidenceState, DISCLOSURES
|
||||
from .store import SCHEMA_VERSION
|
||||
@@ -31,9 +35,9 @@ from .init_system import (
|
||||
# Freshness constants (§8.9, §8.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CADENCE_DEFAULT_S = 300 # 5 min
|
||||
CADENCE_DEFAULT_S = 180 # 3 min
|
||||
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
|
||||
|
||||
|
||||
@@ -98,7 +102,7 @@ def open_store_readonly(store_path: Path) -> sqlite3.Connection:
|
||||
# PermissionError before any StoreFault can be raised (issue #54).
|
||||
raise StoreFault("observation store not readable: %s" % e)
|
||||
if not exists:
|
||||
raise StoreFault("observation store not found at %s" % store_path)
|
||||
raise MissingStore("observation store not found at %s" % store_path)
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect("file:%s?mode=ro" % store_path, uri=True)
|
||||
@@ -125,6 +129,10 @@ class StoreFault(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MissingStore(StoreFault):
|
||||
"""No observation history has been created yet."""
|
||||
|
||||
|
||||
class NewerSchema(Exception):
|
||||
"""Store has a newer user_version (§9.5)."""
|
||||
def __init__(self, version: int):
|
||||
@@ -246,27 +254,12 @@ def check_retired_flag(flag: str) -> Optional[str]:
|
||||
# Formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
drive_facts: List[str], config_error: Optional[str],
|
||||
store_fault: Optional[str], newer_schema: Optional[str],
|
||||
journal_hint: Optional[str],
|
||||
def _format_projection(proj, freshness: str, drive_facts: List[str],
|
||||
config_error: Optional[str],
|
||||
sample_count: int = 0, day_count: int = 0) -> str:
|
||||
"""Format the complete status output."""
|
||||
"""Format projection details; monitoring status has its own renderer."""
|
||||
lines = []
|
||||
|
||||
# --- Store/system fault overrides (§9.4, §9.5) ---
|
||||
if store_fault:
|
||||
lines.append("observation store unreadable")
|
||||
if journal_hint:
|
||||
lines.append("")
|
||||
lines.append("Recent journal entries:")
|
||||
lines.append(journal_hint)
|
||||
return "\n".join(lines)
|
||||
|
||||
if newer_schema:
|
||||
lines.append("observation store written by a newer Fenris — upgrade Fenris")
|
||||
return "\n".join(lines)
|
||||
|
||||
# --- Configuration error (§8.3) ---
|
||||
if config_error:
|
||||
lines.append("configuration error: %s" % config_error)
|
||||
@@ -277,7 +270,6 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
lines.append("no observations yet")
|
||||
lines.append("")
|
||||
lines.append("Enable monitoring: fenris monitor resume")
|
||||
_append_service_facts(lines, service)
|
||||
return "\n".join(lines)
|
||||
|
||||
# --- Single sample: awaiting another sample (issue #73 AC3) ---
|
||||
@@ -287,7 +279,10 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
lines.append("awaiting another sample")
|
||||
lines.append("")
|
||||
lines.append("Collecting usage data — the first projection requires at least two samples.")
|
||||
_append_service_facts(lines, service)
|
||||
return "\n".join(lines)
|
||||
|
||||
if proj is None:
|
||||
lines.append("no projection available")
|
||||
return "\n".join(lines)
|
||||
|
||||
# --- Projection headline ---
|
||||
@@ -332,16 +327,6 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
lines.append(fact)
|
||||
lines.append("")
|
||||
|
||||
# --- Four separate service facts (§7.3, LC-9) ---
|
||||
_append_service_facts(lines, service)
|
||||
|
||||
# --- Journal hint on failure or staleness (§8.8) ---
|
||||
if journal_hint:
|
||||
if freshness in ("missed", "stale"):
|
||||
lines.append("")
|
||||
lines.append("Recent journal entries:")
|
||||
lines.append(journal_hint)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -386,36 +371,6 @@ def _format_headline(proj) -> str:
|
||||
return headline
|
||||
|
||||
|
||||
def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None:
|
||||
"""Append service facts and dashboard-clarity monitoring state."""
|
||||
boot = "enabled" if service.get("boot_enabled") else "disabled"
|
||||
activity = "active" if service.get("timer_active") else "inactive"
|
||||
|
||||
if service.get("last_collect_ok") is True:
|
||||
collect = "ok"
|
||||
elif service.get("last_collect_ok") is False:
|
||||
collect = "FAILED"
|
||||
if service.get("last_collect_reason"):
|
||||
collect += " (%s)" % service["last_collect_reason"]
|
||||
else:
|
||||
collect = "unknown"
|
||||
|
||||
collect_age = ""
|
||||
if service.get("last_collect_age_s") is not None:
|
||||
collect_age = " %s" % freshness_age_human(service["last_collect_age_s"])
|
||||
|
||||
freshness_str = service.get("freshness", "unknown")
|
||||
freshness_age = ""
|
||||
if service.get("freshness_age_s") is not None:
|
||||
freshness_age = " (%s)" % freshness_age_human(service["freshness_age_s"])
|
||||
|
||||
lines.append("boot: %s · timer: %s · last collect: %s%s · freshness: %s%s"
|
||||
% (boot, activity, collect, collect_age, freshness_str, freshness_age))
|
||||
lines.append("CONTINUITY: %s" % monitoring_continuity(service))
|
||||
if service.get("deliberately_paused"):
|
||||
lines.extend(deliberate_pause_lines())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard clarity parity wording (DC-2, DC-3)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -430,7 +385,9 @@ _PAUSED_CONSEQUENCE = (
|
||||
|
||||
def monitoring_continuity(service: Dict[str, Any]) -> str:
|
||||
"""Return the boot-persistence wording, independent of timer runtime."""
|
||||
return _CONTINUITY_ACTIVE if service.get("boot_enabled") else _CONTINUITY_DISABLED
|
||||
if service.get("boot_enabled") is None:
|
||||
return "monitoring: boot persistence unknown"
|
||||
return _CONTINUITY_ACTIVE if service["boot_enabled"] else _CONTINUITY_DISABLED
|
||||
|
||||
|
||||
def deliberate_pause_lines() -> List[str]:
|
||||
@@ -476,121 +433,92 @@ def format_disclosures() -> str:
|
||||
# Main status entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@contextmanager
|
||||
def read_status(
|
||||
store_path: Optional[Path] = None,
|
||||
clock_now: Optional[datetime] = None,
|
||||
query_services: bool = True,
|
||||
collecting: bool = False,
|
||||
reduced_motion: bool = False,
|
||||
) -> Iterator[Tuple[Optional[sqlite3.Connection], "StatusComposition"]]:
|
||||
"""Yield a read-only store snapshot and its composed monitoring status.
|
||||
|
||||
Own acquisition, fault classification, and connection lifetime for both
|
||||
renderers. An absent store is empty; an unreadable or newer store exposes
|
||||
no connection. Unknown monitoring facts are never coerced to disabled.
|
||||
"""
|
||||
from .status_composition import compose_status
|
||||
|
||||
clock_now = clock_now or datetime.now(timezone.utc)
|
||||
service = None
|
||||
if query_services:
|
||||
try:
|
||||
service = query_service_state()
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
|
||||
conn = None
|
||||
store_fault = newer_schema = None
|
||||
try:
|
||||
try:
|
||||
conn = open_store_readonly(store_path or Path("/var/lib/fenris/observations.db"))
|
||||
conn.execute("BEGIN")
|
||||
except MissingStore:
|
||||
pass
|
||||
except (StoreFault, sqlite3.Error) as exc:
|
||||
store_fault = str(exc)
|
||||
except NewerSchema as exc:
|
||||
newer_schema = str(exc)
|
||||
|
||||
comp = compose_status(
|
||||
conn, service, clock_now, store_fault=store_fault,
|
||||
newer_schema=newer_schema, collecting=collecting,
|
||||
reduced_motion=reduced_motion,
|
||||
)
|
||||
if conn is not None and (comp.store_fault or comp.newer_schema):
|
||||
conn.close()
|
||||
conn = None
|
||||
yield conn, comp
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
|
||||
query_services: bool = True, query_journal: bool = True) -> str:
|
||||
"""Render the complete read-only status (§8.8, LC-9).
|
||||
"""Render CLI status through the shared read-only acquisition path."""
|
||||
from .status_composition import render_status_cli
|
||||
|
||||
This is the single entry point for 'fenris status'. It never auto-samples,
|
||||
never prompts, and never writes to the store.
|
||||
"""
|
||||
if clock_now is None:
|
||||
clock_now = datetime.now(timezone.utc)
|
||||
|
||||
# --- Configuration (§8.3) ---
|
||||
clock_now = clock_now or datetime.now(timezone.utc)
|
||||
config_error = None
|
||||
device = None
|
||||
try:
|
||||
config = read_config()
|
||||
device = config["device"]
|
||||
except ConfigError as e:
|
||||
config_error = str(e)
|
||||
read_config()
|
||||
except ConfigError as exc:
|
||||
config_error = str(exc)
|
||||
|
||||
# --- Service state ---
|
||||
service = {}
|
||||
if query_services:
|
||||
service = query_service_state()
|
||||
|
||||
# --- Store open ---
|
||||
store_fault = None
|
||||
newer_schema = None
|
||||
conn = None
|
||||
|
||||
if store_path is None:
|
||||
store_path = Path("/var/lib/fenris/observations.db")
|
||||
|
||||
try:
|
||||
conn = open_store_readonly(store_path)
|
||||
except StoreFault as e:
|
||||
store_fault = str(e)
|
||||
except NewerSchema as e:
|
||||
newer_schema = str(e)
|
||||
|
||||
# --- Store fault / newer schema short-circuit ---
|
||||
if store_fault or newer_schema:
|
||||
journal_hint = None
|
||||
if query_journal:
|
||||
journal_hint = _journalctl_hint()
|
||||
service["freshness"] = "unknown"
|
||||
service["freshness_age_s"] = None
|
||||
return _format_projection(
|
||||
None, "unknown", service, [], config_error, store_fault, newer_schema, journal_hint
|
||||
)
|
||||
|
||||
# --- Freshness grading (§8.9) ---
|
||||
try:
|
||||
cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
newest_ts = row[0] if row else None
|
||||
except sqlite3.Error:
|
||||
newest_ts = None
|
||||
|
||||
freshness = grade_freshness(newest_ts, clock_now)
|
||||
|
||||
# --- Sample count for single-sample state (issue #73 AC3) ---
|
||||
sample_count = 0
|
||||
day_count = 0
|
||||
try:
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||
sample_count = cursor.fetchone()[0]
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||
day_count = cursor.fetchone()[0]
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
|
||||
# Freshness age for the service fact
|
||||
freshness_age_s = None
|
||||
if newest_ts:
|
||||
try:
|
||||
ts = datetime.fromisoformat(newest_ts)
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
freshness_age_s = int((clock_now - ts).total_seconds())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
service["freshness"] = freshness
|
||||
service["freshness_age_s"] = freshness_age_s
|
||||
try:
|
||||
service["deliberately_paused"] = is_deliberately_paused(conn, service)
|
||||
except sqlite3.Error:
|
||||
service["deliberately_paused"] = False
|
||||
|
||||
# --- Drive anomalies (§9.7, FL-7) ---
|
||||
with read_status(store_path, clock_now, query_services) as (conn, comp):
|
||||
parts = [render_status_cli(comp)]
|
||||
if not comp.store_fault and not comp.newer_schema:
|
||||
drive_facts = []
|
||||
proj = None
|
||||
if conn is not None:
|
||||
try:
|
||||
drive_facts = _query_drive_facts(conn)
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
|
||||
# --- Projection (§6 — recomputed on read, never stored) ---
|
||||
try:
|
||||
proj = compute_projection(conn, clock_now)
|
||||
except Exception:
|
||||
proj = None
|
||||
|
||||
# --- Journal hint on failure or staleness (§8.8) ---
|
||||
journal_hint = None
|
||||
if query_journal and freshness in ("missed", "stale"):
|
||||
journal_hint = _journalctl_hint()
|
||||
|
||||
# --- Compose output ---
|
||||
result = _format_projection(
|
||||
proj, freshness, service, drive_facts, config_error,
|
||||
None, None, journal_hint, sample_count, day_count,
|
||||
)
|
||||
|
||||
conn.close()
|
||||
return result
|
||||
except (sqlite3.Error, ValueError, TypeError):
|
||||
pass
|
||||
parts.append(_format_projection(
|
||||
proj, comp.freshness, drive_facts, config_error,
|
||||
comp.sample_count, comp.day_count,
|
||||
))
|
||||
if query_journal and (
|
||||
comp.store_fault or comp.last_collect_ok is False
|
||||
or comp.freshness in ("missed", "stale")
|
||||
):
|
||||
hint = _journalctl_hint()
|
||||
if hint:
|
||||
parts.append("Recent collector logs:\n" + hint)
|
||||
return "\n\n".join(part for part in parts if part)
|
||||
|
||||
|
||||
def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
|
||||
@@ -619,65 +547,8 @@ def get_status_composition(
|
||||
collecting: bool = False,
|
||||
reduced_motion: bool = False,
|
||||
) -> 'StatusComposition':
|
||||
"""Get the shared status composition consumed by both TUI and CLI.
|
||||
|
||||
This is the new entry point that centralizes the status lattice.
|
||||
"""
|
||||
# Lazy import to avoid circular dependency
|
||||
from .status_composition import (
|
||||
StatusComposition,
|
||||
compose_status,
|
||||
)
|
||||
|
||||
if clock_now is None:
|
||||
clock_now = datetime.now(timezone.utc)
|
||||
|
||||
# --- Configuration (§8.3) ---
|
||||
# Config errors are surfaced through the store fault mechanism
|
||||
|
||||
# --- Service state ---
|
||||
service = {}
|
||||
if query_services:
|
||||
try:
|
||||
service = query_service_state()
|
||||
except Exception:
|
||||
service = None
|
||||
|
||||
# --- Store open ---
|
||||
store_fault = None
|
||||
newer_schema = None
|
||||
conn = None
|
||||
|
||||
if store_path is None:
|
||||
store_path = Path("/var/lib/fenris/observations.db")
|
||||
|
||||
try:
|
||||
conn = open_store_readonly(store_path)
|
||||
except StoreFault as e:
|
||||
store_fault = str(e)
|
||||
except NewerSchema as e:
|
||||
newer_schema = str(e)
|
||||
|
||||
# --- Use shared composition ---
|
||||
if conn is not None:
|
||||
try:
|
||||
comp = compose_status(
|
||||
conn, service, clock_now,
|
||||
store_fault=store_fault,
|
||||
newer_schema=newer_schema,
|
||||
collecting=collecting,
|
||||
reduced_motion=reduced_motion,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
else:
|
||||
# Store fault or newer schema - compose without store data
|
||||
comp = compose_status(
|
||||
None, service, clock_now,
|
||||
store_fault=store_fault,
|
||||
newer_schema=newer_schema,
|
||||
collecting=collecting,
|
||||
reduced_motion=reduced_motion,
|
||||
)
|
||||
|
||||
"""Return monitoring status without retaining the read-only snapshot."""
|
||||
with read_status(
|
||||
store_path, clock_now, query_services, collecting, reduced_motion,
|
||||
) as (_, comp):
|
||||
return comp
|
||||
|
||||
@@ -188,7 +188,7 @@ def _determine_explanation(
|
||||
) -> str:
|
||||
"""Determine the explanation line for the status state."""
|
||||
if store_fault:
|
||||
return "observation store unreadable — see journal"
|
||||
return "observation store unreadable — see collector logs"
|
||||
|
||||
if newer_schema:
|
||||
return "observation store written by a newer Fenris — upgrade Fenris"
|
||||
@@ -200,7 +200,7 @@ def _determine_explanation(
|
||||
if last_collect_reason:
|
||||
parts.append("(%s)" % last_collect_reason)
|
||||
if freshness_age_s is not None and freshness != "empty":
|
||||
parts.append("· last good sample %s ago" % freshness_age_human(freshness_age_s))
|
||||
parts.append("· last good sample %s" % freshness_age_human(freshness_age_s))
|
||||
return " ".join(parts) if parts else "last run failed"
|
||||
|
||||
if state == StatusState.INTERRUPTED:
|
||||
@@ -211,7 +211,7 @@ def _determine_explanation(
|
||||
|
||||
if state == StatusState.STALE:
|
||||
if freshness_age_s is not None:
|
||||
return "last sample %s ago" % freshness_age_human(freshness_age_s)
|
||||
return "last sample %s" % freshness_age_human(freshness_age_s)
|
||||
return "data is stale"
|
||||
|
||||
if state == StatusState.WAITING:
|
||||
@@ -223,7 +223,7 @@ def _determine_explanation(
|
||||
|
||||
if state == StatusState.MONITORING:
|
||||
if freshness_age_s is not None:
|
||||
return "last sample %s ago" % freshness_age_human(freshness_age_s)
|
||||
return "last sample %s" % freshness_age_human(freshness_age_s)
|
||||
return "monitoring active"
|
||||
|
||||
if state == StatusState.UNKNOWN:
|
||||
@@ -257,7 +257,7 @@ def _determine_collecting_overlay(
|
||||
|
||||
|
||||
def compose_status(
|
||||
conn: sqlite3.Connection,
|
||||
conn: Optional[sqlite3.Connection],
|
||||
service: Optional[Dict[str, Any]],
|
||||
clock_now: datetime,
|
||||
store_fault: Optional[str] = None,
|
||||
@@ -269,14 +269,17 @@ def compose_status(
|
||||
|
||||
This is the single entry point consumed by both TUI and CLI.
|
||||
"""
|
||||
service_available = service is not None and len(service) > 0
|
||||
service_available = bool(service) and any(
|
||||
service.get(key) is not None
|
||||
for key in ("boot_enabled", "timer_active")
|
||||
)
|
||||
|
||||
# --- Separate facts from service ---
|
||||
boot_enabled = service.get("boot_enabled") if service_available else None
|
||||
timer_active = service.get("timer_active") if service_available else None
|
||||
last_collect_ok = service.get("last_collect_ok") if service_available else None
|
||||
last_collect_age_s = service.get("last_collect_age_s") if service_available else None
|
||||
last_collect_reason = service.get("last_collect_reason") if service_available else None
|
||||
boot_enabled = service.get("boot_enabled") if service else None
|
||||
timer_active = service.get("timer_active") if service else None
|
||||
last_collect_ok = service.get("last_collect_ok") if service else None
|
||||
last_collect_age_s = service.get("last_collect_age_s") if service else None
|
||||
last_collect_reason = service.get("last_collect_reason") if service else None
|
||||
|
||||
# --- Freshness from store ---
|
||||
freshness = "unknown"
|
||||
@@ -285,7 +288,10 @@ def compose_status(
|
||||
day_count = 0
|
||||
deliberately_paused = False
|
||||
|
||||
if store_fault is None and newer_schema is None:
|
||||
if conn is None and store_fault is None and newer_schema is None:
|
||||
freshness = "empty"
|
||||
|
||||
if conn is not None and store_fault is None and newer_schema is None:
|
||||
try:
|
||||
cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
@@ -301,28 +307,35 @@ def compose_status(
|
||||
freshness_age_s = int((clock_now - ts).total_seconds())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except sqlite3.Error:
|
||||
freshness = "unknown"
|
||||
except sqlite3.Error as exc:
|
||||
store_fault = str(exc)
|
||||
|
||||
try:
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||
sample_count = cursor.fetchone()[0]
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||
day_count = cursor.fetchone()[0]
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
except sqlite3.Error as exc:
|
||||
store_fault = str(exc)
|
||||
|
||||
try:
|
||||
svc_for_pause = service if service_available else {}
|
||||
deliberately_paused = is_deliberately_paused(conn, svc_for_pause)
|
||||
except sqlite3.Error:
|
||||
except sqlite3.Error as exc:
|
||||
store_fault = str(exc)
|
||||
|
||||
if store_fault or newer_schema:
|
||||
freshness = "unknown"
|
||||
freshness_age_s = None
|
||||
sample_count = day_count = 0
|
||||
deliberately_paused = False
|
||||
|
||||
# --- External stop detection ---
|
||||
# External stop = timer inactive + boot disabled + NOT deliberately paused
|
||||
# + period still open (the timer was stopped but Fenris didn't close the period)
|
||||
external_stop_reason = None
|
||||
if not deliberately_paused and timer_active is False and boot_enabled is False:
|
||||
if (conn is not None and not store_fault and not newer_schema
|
||||
and not deliberately_paused and timer_active is False and boot_enabled is False):
|
||||
# Check if there's an open monitoring period (external stop left it open)
|
||||
try:
|
||||
open_period = conn.execute(
|
||||
@@ -428,10 +441,9 @@ def render_status_cli(comp: StatusComposition) -> str:
|
||||
|
||||
# Separate facts
|
||||
facts = []
|
||||
if comp.freshness != "unknown":
|
||||
facts.append("freshness: %s" % comp.freshness)
|
||||
if comp.freshness_age_s is not None:
|
||||
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s) if facts else "freshness: %s" % freshness_age_human(comp.freshness_age_s)
|
||||
if comp.freshness_age_s is not None and facts:
|
||||
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s)
|
||||
if comp.last_collect_ok is True:
|
||||
facts.append("last collect: ok")
|
||||
elif comp.last_collect_ok is False:
|
||||
@@ -441,10 +453,12 @@ def render_status_cli(comp: StatusComposition) -> str:
|
||||
facts.append(collect_str)
|
||||
else:
|
||||
facts.append("last collect: unknown")
|
||||
if comp.boot_enabled is not None:
|
||||
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
|
||||
if comp.timer_active is not None:
|
||||
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
|
||||
facts.append("boot: %s" % (
|
||||
"unknown" if comp.boot_enabled is None else "enabled" if comp.boot_enabled else "disabled"
|
||||
))
|
||||
facts.append("timer: %s" % (
|
||||
"unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive"
|
||||
))
|
||||
|
||||
if facts:
|
||||
lines.append(" · ".join(facts))
|
||||
@@ -486,29 +500,30 @@ def render_status_tui(comp: StatusComposition) -> str:
|
||||
|
||||
# Explanation
|
||||
if comp.explanation:
|
||||
lines.append(comp.explanation)
|
||||
lines.append(comp.explanation[:1].upper() + comp.explanation[1:])
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Separate facts
|
||||
facts = []
|
||||
if comp.freshness != "unknown":
|
||||
facts.append("freshness: %s" % comp.freshness)
|
||||
facts.append("Freshness: %s" % comp.freshness)
|
||||
if comp.freshness_age_s is not None and facts:
|
||||
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s)
|
||||
if comp.last_collect_ok is True:
|
||||
facts.append("last collect: ok")
|
||||
facts.append("Last collect: ok")
|
||||
elif comp.last_collect_ok is False:
|
||||
collect_str = "last collect: FAILED"
|
||||
collect_str = "Last collect: failed"
|
||||
if comp.last_collect_reason:
|
||||
collect_str += " (%s)" % comp.last_collect_reason
|
||||
facts.append(collect_str)
|
||||
else:
|
||||
facts.append("last collect: unknown")
|
||||
if comp.boot_enabled is not None:
|
||||
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
|
||||
if comp.timer_active is not None:
|
||||
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
|
||||
facts.append("Last collect: unknown")
|
||||
facts.append("Boot: %s" % (
|
||||
"unknown" if comp.boot_enabled is None else "enabled" if comp.boot_enabled else "disabled"
|
||||
))
|
||||
facts.append("Timer: %s" % (
|
||||
"unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive"
|
||||
))
|
||||
|
||||
if facts:
|
||||
lines.append(" · ".join(facts))
|
||||
@@ -516,11 +531,11 @@ def render_status_tui(comp: StatusComposition) -> str:
|
||||
# Continuity
|
||||
if comp.continuity:
|
||||
lines.append("")
|
||||
lines.append("[bold]CONTINUITY[/bold] %s" % comp.continuity)
|
||||
lines.append("[bold]Continuity[/bold] %s" % comp.continuity)
|
||||
|
||||
# Deliberate pause
|
||||
if comp.paused_lines:
|
||||
for pl in comp.paused_lines:
|
||||
lines.append(pl)
|
||||
lines.append(pl[:1].upper() + pl[1:])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
+46
-1
@@ -12,7 +12,7 @@ from typing import Optional
|
||||
|
||||
|
||||
# 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
|
||||
@@ -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)
|
||||
conn.execute("""
|
||||
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")
|
||||
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:
|
||||
"""Apply forward-only migrations to bring the store to SCHEMA_VERSION.
|
||||
|
||||
+1068
-458
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))
|
||||
|
||||
|
||||
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,
|
||||
bw=1024*1024*100, coverage=0.95, samples_per_day=24,
|
||||
sample_ts="2026-09-30T10:00:00+00:00",
|
||||
period_start="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:
|
||||
_insert_baseline(conn, **(baseline_kw or {}))
|
||||
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)
|
||||
if 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")
|
||||
_insert_day(conn, d, bw=1024*1024*100)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||
proj = compute_projection(conn, _clock())
|
||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||
conn.close()
|
||||
@@ -230,6 +262,7 @@ class TestCI1StateMatrix:
|
||||
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_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||
proj = compute_projection(conn, _clock())
|
||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||
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)
|
||||
stale_ts = (_clock() - timedelta(days=5)).isoformat()
|
||||
_insert_sample(conn, stale_ts)
|
||||
_insert_complete_local_days(conn, "2026-08-30", 1)
|
||||
proj = compute_projection(conn, _clock())
|
||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||
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")
|
||||
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||
proj = compute_projection(conn, _clock())
|
||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||
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")
|
||||
_insert_day(conn, d, bw=0)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||
proj = compute_projection(conn, _clock())
|
||||
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert proj.zero_rate_fact is not None
|
||||
@@ -461,7 +497,7 @@ class TestCI2Parity:
|
||||
}
|
||||
|
||||
with patch("fenris.status.query_service_state", return_value=service_state), patch(
|
||||
"fenris.tui.query_service_state", return_value=service_state
|
||||
"fenris.status.query_service_state", return_value=service_state
|
||||
):
|
||||
status = get_status(
|
||||
store_path=db, clock_now=_clock(), query_services=True, query_journal=False
|
||||
@@ -502,18 +538,6 @@ class TestCI2Parity:
|
||||
query_services=True, query_journal=False)
|
||||
assert "no observations yet" in status.lower()
|
||||
|
||||
def test_store_fault_phrase_both_views(self, tmp_path):
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
tui_src = (FENRIS_PKG / "tui.py").read_text()
|
||||
phrase = "observation store unreadable"
|
||||
assert phrase in status_src
|
||||
assert phrase.lower() in tui_src.lower()
|
||||
|
||||
def test_newer_schema_phrase_both_views(self):
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
phrase = "observation store written by a newer Fenris"
|
||||
assert phrase in status_src
|
||||
|
||||
def test_status_never_prompts(self):
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
assert "input(" not in status_src
|
||||
@@ -603,12 +627,6 @@ class TestCI3ProhibitionSet:
|
||||
table_names.append(m.group(1))
|
||||
assert "projection" not in [t.lower() for t in table_names]
|
||||
|
||||
def test_no_partial_newer_schema_interpretation(self):
|
||||
"""Readers refuse newer-schema stores. [3.6, 9.5]"""
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
assert "NewerSchema" in status_src
|
||||
assert "upgrade Fenris" in status_src
|
||||
|
||||
def test_polkit_authorizes_one_binary(self):
|
||||
"""Polkit authorizes exactly one binary: fenris-monitor. [8.5]"""
|
||||
monitor_src = (FENRIS_PKG / "monitor.py").read_text()
|
||||
@@ -688,18 +706,6 @@ class TestCI4WordingAndDisclosures:
|
||||
proj_src = (FENRIS_PKG / "projection.py").read_text()
|
||||
assert phrase in proj_src
|
||||
|
||||
def test_store_fault_phrase(self):
|
||||
phrase = "observation store unreadable"
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
assert phrase in status_src
|
||||
tui_src = (FENRIS_PKG / "tui.py").read_text()
|
||||
assert phrase.lower() in tui_src.lower()
|
||||
|
||||
def test_newer_schema_phrase(self):
|
||||
phrase = "observation store written by a newer Fenris"
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
assert phrase in status_src
|
||||
|
||||
def test_no_observations_phrase(self):
|
||||
phrase = "no observations yet"
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
|
||||
@@ -79,6 +79,20 @@ def test_release_footer_verifies_the_clearsigned_checksum_asset():
|
||||
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(
|
||||
("changelog", "expected_error"),
|
||||
[
|
||||
|
||||
@@ -154,7 +154,7 @@ class TestSchemaMigration:
|
||||
|
||||
# Migrate
|
||||
steps = migrate_to_latest(db)
|
||||
assert steps == 1
|
||||
assert steps == 2 # v1→v2→v3
|
||||
|
||||
# Verify data preserved
|
||||
conn = sqlite3.connect(str(db))
|
||||
@@ -412,7 +412,7 @@ class TestDisplayStates:
|
||||
|
||||
def test_one_sample_awaiting_another_in_tui(self, tmp_path):
|
||||
"""One sample → TUI shows awaiting state."""
|
||||
from fenris.tui import FenrisTuiApp, _query_service_facts
|
||||
from fenris.tui import FenrisTuiApp
|
||||
db = tmp_path / "test.db"
|
||||
conn = init_store(db)
|
||||
conn.execute(
|
||||
|
||||
@@ -248,6 +248,7 @@ def test_store_initialization(config_fixture: Dict[str, Any]):
|
||||
# sqlite_sequence is a system table created by AUTOINCREMENT
|
||||
expected_tables.add("sqlite_sequence")
|
||||
expected_tables.add("store_metadata")
|
||||
expected_tables.add("local_days")
|
||||
assert expected_tables == tables
|
||||
|
||||
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,77 @@
|
||||
"""The CLI and TUI cross the same terminal-attached action interface."""
|
||||
import argparse
|
||||
import runpy
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
from fenris.control import MONITOR_HELPER, MonitorError, run_monitor
|
||||
from fenris.tui import FenrisTuiApp
|
||||
|
||||
|
||||
@pytest.mark.parametrize("uid", [0, 1000])
|
||||
@pytest.mark.parametrize("args", [("enable", "--now"), ("disable", "--now"), ("collect",)])
|
||||
def test_fixed_helper_and_terminal_attachment(uid, args):
|
||||
with patch("fenris.control.os.geteuid", return_value=uid), \
|
||||
patch("fenris.control.subprocess.run", return_value=subprocess.CompletedProcess([], 0)) as run:
|
||||
run_monitor(*args)
|
||||
expected = [MONITOR_HELPER, *args]
|
||||
if uid:
|
||||
expected.insert(0, "pkexec")
|
||||
# Inherited stdin/out/err keep authentication on the user's terminal.
|
||||
# No frontend deadline can cut short a valid 90-second Collection run.
|
||||
run.assert_called_once_with(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [1, 126, 127, -15])
|
||||
def test_failure_is_not_retried_and_root_equivalent_preserves_arguments(code):
|
||||
args = ("baseline", "set", '{"model": "Drive $(whoami)", "tbw": 100}')
|
||||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||||
patch("fenris.control.subprocess.run", return_value=subprocess.CompletedProcess([], code)) as run:
|
||||
with pytest.raises(MonitorError) as failure:
|
||||
run_monitor(*args)
|
||||
run.assert_called_once()
|
||||
assert failure.value.exit_code == (code if code > 0 else 128 - code)
|
||||
root_command = str(failure.value).split("run in your terminal: ")[1]
|
||||
assert shlex.split(root_command) == ["sudo", MONITOR_HELPER, *args]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("uid,missing", [(0, MONITOR_HELPER), (1000, "pkexec")])
|
||||
def test_missing_command_is_identified(uid, missing):
|
||||
with patch("fenris.control.os.geteuid", return_value=uid), \
|
||||
patch("fenris.control.subprocess.run", side_effect=FileNotFoundError(2, "Missing", missing)):
|
||||
with pytest.raises(MonitorError, match="Command not found") as failure:
|
||||
run_monitor("collect")
|
||||
assert missing in str(failure.value)
|
||||
assert failure.value.exit_code == 127
|
||||
assert ("sudo" in str(failure.value)) == bool(uid)
|
||||
|
||||
|
||||
def test_interrupt_reports_uncertain_outcome():
|
||||
with patch("fenris.control.subprocess.run", side_effect=KeyboardInterrupt):
|
||||
with pytest.raises(MonitorError, match="Check fenris status") as failure:
|
||||
run_monitor("collect")
|
||||
assert failure.value.exit_code == 130
|
||||
|
||||
|
||||
def test_cli_and_tui_show_the_same_failure(tmp_path, capsys):
|
||||
# The launcher may prepend an installed runtime while loading; isolate it.
|
||||
with patch.object(sys, "path", sys.path.copy()):
|
||||
cli = runpy.run_path(str(Path(__file__).parent.parent / "scripts" / "fenris"))
|
||||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||||
patch("fenris.control.subprocess.run", return_value=subprocess.CompletedProcess([], 126)):
|
||||
with pytest.raises(SystemExit) as exit_info:
|
||||
cli["cmd_monitor_resume"](argparse.Namespace())
|
||||
assert exit_info.value.code == 126
|
||||
cli_message = capsys.readouterr().err.strip()
|
||||
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
|
||||
with patch.object(app, "suspend"), patch.object(app, "_refresh") as refresh, \
|
||||
patch.object(app, "notify") as notify:
|
||||
app.action_resume()
|
||||
notify.assert_called_once_with(cli_message, severity="error")
|
||||
refresh.assert_called_once()
|
||||
@@ -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()
|
||||
@@ -283,7 +283,7 @@ class TestSystemdQueryState:
|
||||
def test_query_state_disabled_inactive(self):
|
||||
"""Query state for disabled and inactive timer."""
|
||||
with patch("fenris.init_system._systemctl_show") as mock_show:
|
||||
mock_show.return_value = {}
|
||||
mock_show.return_value = {"UnitFileState": "disabled", "ActiveState": "inactive"}
|
||||
result = _systemd_query_state()
|
||||
assert result["boot_enabled"] is False
|
||||
assert result["timer_active"] is False
|
||||
@@ -688,5 +688,5 @@ class TestEdgeCases:
|
||||
with patch("fenris.init_system._systemctl_show") as mock_show:
|
||||
mock_show.return_value = {}
|
||||
result = _systemd_query_state()
|
||||
assert result["boot_enabled"] is False
|
||||
assert result["timer_active"] is False
|
||||
assert result["boot_enabled"] is None
|
||||
assert result["timer_active"] is None
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
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:
|
||||
"""Issue #77: Show honest qualifying-day progress and confidence."""
|
||||
|
||||
@@ -103,6 +130,7 @@ class TestHonestQualifyingProgress:
|
||||
_insert_day(store, d, bw=bw, coverage=cov)
|
||||
|
||||
_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())
|
||||
|
||||
# After warming, qualifying_days_progress shows honest count
|
||||
@@ -125,6 +153,7 @@ class TestHonestQualifyingProgress:
|
||||
_insert_day(store, d, bw=bw, samples=samples)
|
||||
|
||||
_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())
|
||||
|
||||
# After warming, qualifying_days_progress shows honest count
|
||||
@@ -146,6 +175,7 @@ class TestHonestQualifyingProgress:
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
|
||||
_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())
|
||||
|
||||
# Should be warming
|
||||
@@ -164,6 +194,7 @@ class TestHonestQualifyingProgress:
|
||||
_insert_day(store, d, bw=0, coverage=0.95)
|
||||
|
||||
_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())
|
||||
|
||||
# Zero rate should be UNSUPPORTED
|
||||
@@ -185,6 +216,7 @@ class TestHonestQualifyingProgress:
|
||||
_insert_day(store, d, bw=bw, coverage=cov)
|
||||
|
||||
_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())
|
||||
|
||||
# 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_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())
|
||||
|
||||
# Should still be warming
|
||||
@@ -225,6 +258,7 @@ class TestHonestQualifyingProgress:
|
||||
_insert_day(store, d, bw=bw, coverage=cov)
|
||||
|
||||
_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())
|
||||
|
||||
# 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_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())
|
||||
|
||||
# Should not be warming
|
||||
|
||||
@@ -260,7 +260,7 @@ class TestPreservedBehavior:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": True, "last_collect_age_s": 60,
|
||||
"last_collect_reason": None,
|
||||
@@ -272,11 +272,11 @@ class TestPreservedBehavior:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quit_rail_preserved(self, tmp_path):
|
||||
"""Separate q QUIT TUI rail preserved."""
|
||||
"""Separate q Quit TUI rail preserved."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test(size=(120, 24)) as pilot:
|
||||
rail = str(app.query_one("#quit-rail").render())
|
||||
assert "q QUIT TUI" in rail
|
||||
assert "q Quit TUI" in rail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_banner_preserved(self, tmp_path):
|
||||
@@ -307,7 +307,7 @@ class TestPreservedBehavior:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": False, "timer_active": False,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
|
||||
+18
-11
@@ -171,8 +171,8 @@ class TestKeyBindings:
|
||||
assert "m" in binding_keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_t_cycles_through_themes(self, tmp_path):
|
||||
"""Pressing t cycles through the three presets."""
|
||||
async def test_t_goes_to_today(self, tmp_path):
|
||||
"""Pressing t returns to today's live view (issue #91)."""
|
||||
from fenris.tui import FenrisTuiApp
|
||||
|
||||
config_home = _make_prefs_dir(tmp_path)
|
||||
@@ -182,12 +182,17 @@ class TestKeyBindings:
|
||||
# Start at 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.pause()
|
||||
|
||||
# Should be nord or high_contrast now
|
||||
assert app.theme in ("fenris-nord", "fenris-high-contrast")
|
||||
# Should be back in daily mode
|
||||
assert graph.view_mode == "daily"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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):
|
||||
"""Theme preference persists across TUI restart."""
|
||||
from fenris.tui import FenrisTuiApp
|
||||
from fenris.preferences import save_preferences
|
||||
|
||||
config_home = _make_prefs_dir(tmp_path)
|
||||
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")
|
||||
async with app1.run_test() as pilot:
|
||||
await pilot.press("t")
|
||||
await pilot.pause()
|
||||
theme_after_t = app1.theme
|
||||
assert theme_after_t != "fenris-amber"
|
||||
save_preferences(theme="nord", reduced_motion=False)
|
||||
app1._current_theme_name = "nord"
|
||||
app1.theme = "fenris-nord"
|
||||
theme_after = app1.theme
|
||||
assert theme_after == "fenris-nord"
|
||||
|
||||
# Second run: theme should persist
|
||||
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app2.run_test() as pilot:
|
||||
assert app2.theme == theme_after_t
|
||||
assert app2.theme == theme_after
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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()
|
||||
+18
-10
@@ -135,7 +135,7 @@ RPM_TARGETS = [
|
||||
]
|
||||
|
||||
XBPS_TARGETS = [
|
||||
("voidlinux/void-linux:latest", "xbps"),
|
||||
("ghcr.io/void-linux/void-glibc-full:latest", "xbps"),
|
||||
]
|
||||
|
||||
ALL_TARGETS = DEB_TARGETS + RPM_TARGETS + XBPS_TARGETS
|
||||
@@ -176,9 +176,8 @@ def _build_xbps_dockerfile(image: str, pkg_name: str) -> str:
|
||||
"""Dockerfile for testing xbps installation."""
|
||||
return textwrap.dedent(f"""\
|
||||
FROM {image}
|
||||
RUN xbps-install -Suyn void-repo-nonfree && \\
|
||||
xbps-install -Syun python3 smartmontools runit && \\
|
||||
xbps-remove -Oy void-repo-nonfree || true
|
||||
RUN xbps-install -Sy python3 smartmontools runit shadow && \\
|
||||
xbps-remove -O
|
||||
COPY dist/{pkg_name} /pkg/{pkg_name}
|
||||
""")
|
||||
|
||||
@@ -1713,7 +1712,7 @@ def test_xbps_dormant_install(skip_no_docker, version):
|
||||
check=True,
|
||||
)
|
||||
(build_dir / "Dockerfile").write_text(
|
||||
_build_xbps_dockerfile("voidlinux/void-linux:latest", pkg_name)
|
||||
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
|
||||
)
|
||||
|
||||
tag = "fenris-test-xbps"
|
||||
@@ -1731,8 +1730,17 @@ def test_xbps_dormant_install(skip_no_docker, version):
|
||||
)
|
||||
|
||||
try:
|
||||
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
|
||||
# Exercise XBPS itself, including its file ownership and lifecycle hooks.
|
||||
# The temporary local repository does not need release signatures.
|
||||
rc, out = _container_exec(
|
||||
container,
|
||||
f"mkdir -p /tmp/fenris-repo && cp /pkg/{pkg_name} /tmp/fenris-repo/ && "
|
||||
f"xbps-rindex -a /tmp/fenris-repo/{pkg_name} && "
|
||||
"xbps-install -y -R /tmp/fenris-repo fenris",
|
||||
)
|
||||
assert rc == 0, f"XBPS install failed: {out}"
|
||||
rc, out = _container_exec(container, "xbps-query -p pkgver fenris")
|
||||
assert rc == 0 and out.strip() == f"fenris-{version}_1"
|
||||
_assert_runit_dormant_layout(container, version)
|
||||
finally:
|
||||
subprocess.run(
|
||||
@@ -1755,7 +1763,7 @@ def test_xbps_migration_guard(skip_no_docker, version):
|
||||
check=True,
|
||||
)
|
||||
(build_dir / "Dockerfile").write_text(
|
||||
_build_xbps_dockerfile("voidlinux/void-linux:latest", pkg_name)
|
||||
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
|
||||
)
|
||||
|
||||
tag = "fenris-guard-xbps"
|
||||
@@ -1803,7 +1811,7 @@ def test_xbps_upgrade_semantics(skip_no_docker, version):
|
||||
check=True,
|
||||
)
|
||||
(build_dir / "Dockerfile").write_text(
|
||||
_build_xbps_dockerfile("voidlinux/void-linux:latest", pkg_name)
|
||||
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
|
||||
)
|
||||
|
||||
tag = "fenris-upgrade-xbps"
|
||||
@@ -1889,7 +1897,7 @@ def test_xbps_remove_preserves_config_and_store(skip_no_docker, version):
|
||||
check=True,
|
||||
)
|
||||
(build_dir / "Dockerfile").write_text(
|
||||
_build_xbps_dockerfile("voidlinux/void-linux:latest", pkg_name)
|
||||
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
|
||||
)
|
||||
|
||||
tag = "fenris-remove-xbps"
|
||||
@@ -1960,7 +1968,7 @@ def test_xbps_purge_removes_everything(skip_no_docker, version):
|
||||
check=True,
|
||||
)
|
||||
(build_dir / "Dockerfile").write_text(
|
||||
_build_xbps_dockerfile("voidlinux/void-linux:latest", pkg_name)
|
||||
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
|
||||
)
|
||||
|
||||
tag = "fenris-purge-xbps"
|
||||
|
||||
@@ -88,6 +88,33 @@ 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",
|
||||
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:
|
||||
def test_no_baseline_unavailable(self, store):
|
||||
_insert_segment(store)
|
||||
@@ -127,6 +154,7 @@ class TestConfidenceRuleTable:
|
||||
def test_unavailable_no_baseline(self, store):
|
||||
_insert_segment(store)
|
||||
_open_period(store)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
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):
|
||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=0)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
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")
|
||||
_insert_day(store, d, bw=1024*1024*100)
|
||||
_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())
|
||||
assert result.confidence_state == ConfidenceState.LIMITED
|
||||
assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
|
||||
|
||||
def test_limited_degraded_identity(self, store):
|
||||
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
_insert_segment(store, degraded=True)
|
||||
_open_period(store)
|
||||
for i in range(20):
|
||||
@@ -201,6 +232,7 @@ class TestZeroRate:
|
||||
for i in range(20):
|
||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=0)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
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")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_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))
|
||||
# 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
|
||||
@@ -447,6 +480,7 @@ class TestHabitChange:
|
||||
d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw_high)
|
||||
_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))
|
||||
assert result.habit_change_fact is not None
|
||||
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")
|
||||
_insert_day(store, d, bw=bw_low)
|
||||
_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))
|
||||
assert result.habit_change_fact is not None
|
||||
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")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_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())
|
||||
assert result.confidence_state == ConfidenceState.LIMITED
|
||||
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")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_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())
|
||||
assert result.warming_fact is not None
|
||||
assert "warming up" in result.warming_fact
|
||||
@@ -560,6 +597,7 @@ class TestWarmingGate:
|
||||
cov = 0.30 if i < 3 else 0.95
|
||||
_insert_day(store, d, bw=bw, coverage=cov)
|
||||
_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())
|
||||
assert result.warming_fact is not None
|
||||
assert "warming up" in result.warming_fact
|
||||
@@ -589,6 +627,7 @@ class TestWarmingGate:
|
||||
cov = 0.30 if i < 3 else 0.95
|
||||
_insert_day(store, d, bw=bw, coverage=cov)
|
||||
_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())
|
||||
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")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_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())
|
||||
# Should have warming fact but still render
|
||||
assert result.warming_fact is not None
|
||||
@@ -614,6 +654,7 @@ class TestWarmingGate:
|
||||
_insert_segment(store)
|
||||
_open_period(store)
|
||||
_insert_day(store, "2026-09-28", bw=100*1024*1024)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert result.headline_remaining_seconds is None
|
||||
@@ -626,6 +667,7 @@ class TestWarmingGate:
|
||||
for i in range(20):
|
||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=0)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
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")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_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 = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
|
||||
result = compute_projection(store, clock)
|
||||
@@ -661,6 +704,7 @@ class TestStalenessDrop:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_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)
|
||||
result = compute_projection(store, clock)
|
||||
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")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_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))
|
||||
# Prior days exist in store but projection uses current segment
|
||||
# 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")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_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))
|
||||
# Young regime (3 days) → Limited, not enough data for full confidence
|
||||
assert result.confidence_state == ConfidenceState.LIMITED
|
||||
@@ -753,6 +799,7 @@ class TestSegmentBreakProjection:
|
||||
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_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))
|
||||
# Prior history quarantined; only 3 days in new segment
|
||||
assert result.regime_days is not None
|
||||
@@ -771,6 +818,7 @@ class TestDegradedIdentity:
|
||||
_insert_segment(store, identity_key=None, degraded=True)
|
||||
_open_period(store)
|
||||
_insert_day(store, "2026-09-28", bw=100*1024*1024)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
# No baseline → Unavailable
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
@@ -787,6 +835,7 @@ class TestDegradedIdentity:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_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())
|
||||
assert result.confidence_state == ConfidenceState.LIMITED
|
||||
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")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
# Stale clock (>48h)
|
||||
clock = datetime(2026, 10, 5, 12, 0, 0, tzinfo=timezone.utc)
|
||||
result = compute_projection(store, clock)
|
||||
@@ -857,6 +907,7 @@ class TestIdentityChangeBlankKeys:
|
||||
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_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))
|
||||
# Prior history quarantined; only 5 days in new segment
|
||||
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")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_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))
|
||||
assert result.regime_days is not None
|
||||
assert result.regime_days <= 5
|
||||
@@ -895,6 +947,7 @@ class TestIdentityChangeBlankKeys:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_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))
|
||||
# All 25 days in same segment (equal blanks continue)
|
||||
assert result.regime_days is not None
|
||||
|
||||
@@ -622,6 +622,7 @@ class TestProjectionInStatus:
|
||||
from fenris.status import get_status
|
||||
|
||||
nonexistent = tmp_path / "nonexistent.db"
|
||||
nonexistent.write_bytes(b"not a SQLite database")
|
||||
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
|
||||
@@ -587,7 +587,7 @@ class TestCLIStatusRendering:
|
||||
newer_schema=None)
|
||||
cli_text = render_status_cli(comp)
|
||||
assert "observation store unreadable" in cli_text
|
||||
assert "see journal" in cli_text.lower()
|
||||
assert "see collector logs" in cli_text.lower()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -653,7 +653,7 @@ class TestTUIStatusRendering:
|
||||
comp = compose_status(conn, svc, now, store_fault="observation store unreadable",
|
||||
newer_schema=None)
|
||||
tui_text = render_status_tui(comp)
|
||||
assert "observation store unreadable" in tui_text
|
||||
assert "Observation store unreadable" in tui_text
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -870,7 +870,7 @@ class TestRefreshTiming:
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
now = _clock()
|
||||
ts = (now - timedelta(minutes=10)).isoformat()
|
||||
ts = (now - timedelta(minutes=4)).isoformat()
|
||||
_insert_sample(conn, ts)
|
||||
for i in range(20):
|
||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
@@ -878,27 +878,27 @@ class TestRefreshTiming:
|
||||
|
||||
svc = {
|
||||
"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,
|
||||
}
|
||||
|
||||
# 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)
|
||||
assert comp1.freshness == "fresh"
|
||||
assert comp1.state == StatusState.MONITORING
|
||||
|
||||
# At now + 1 min → sample is 11 min old (660s) → still fresh
|
||||
now2 = now + timedelta(minutes=1)
|
||||
# At now + 3 min → sample is 7 min old (420s) → still fresh
|
||||
now2 = now + timedelta(minutes=3)
|
||||
comp2 = compose_status(conn, svc, now2, store_fault=None, newer_schema=None)
|
||||
assert comp2.freshness == "fresh"
|
||||
|
||||
# At now + 2 min → sample is 12 min old (720s) → missed (past 690s)
|
||||
now3 = now + timedelta(minutes=2)
|
||||
# At now + 5 min → sample is 9 min old (540s) → missed (past 450s)
|
||||
now3 = now + timedelta(minutes=5)
|
||||
comp3 = compose_status(conn, svc, now3, store_fault=None, newer_schema=None)
|
||||
assert comp3.freshness == "missed"
|
||||
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)
|
||||
comp4 = compose_status(conn, svc, now4, store_fault=None, newer_schema=None)
|
||||
assert comp4.freshness == "stale"
|
||||
@@ -966,7 +966,7 @@ class TestTUIIntegration:
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": True, "last_collect_age_s": 120,
|
||||
"last_collect_reason": None,
|
||||
@@ -992,7 +992,7 @@ class TestTUIIntegration:
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": False, "timer_active": False,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
@@ -1019,7 +1019,7 @@ class TestTUIIntegration:
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": False, "last_collect_age_s": 60,
|
||||
"last_collect_reason": "exit code 3",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Exercise shared status acquisition through the CLI and running TUI."""
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.status import get_status, get_status_composition, read_status
|
||||
from fenris.status_composition import StatusState
|
||||
from fenris.store import init_store, SCHEMA_VERSION
|
||||
from fenris.tui import FenrisTuiApp
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 16, 12, tzinfo=timezone.utc)
|
||||
ACTIVE = {"boot_enabled": True, "timer_active": True, "last_collect_ok": True}
|
||||
DISABLED = {"boot_enabled": False, "timer_active": False, "last_collect_ok": None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", ["missing", "corrupt", "newer", "incomplete", "denied"])
|
||||
@pytest.mark.parametrize("service", [ACTIVE, DISABLED, {}])
|
||||
async def test_cli_tui_acquisition_parity(tmp_path, kind, service):
|
||||
path = tmp_path / "observations.db"
|
||||
if kind == "corrupt":
|
||||
path.write_bytes(b"Not a SQLite database")
|
||||
elif kind == "incomplete":
|
||||
sqlite3.connect(path).close()
|
||||
elif kind != "missing":
|
||||
conn = init_store(path)
|
||||
if kind == "newer":
|
||||
conn.execute("PRAGMA user_version = %d" % (SCHEMA_VERSION + 1))
|
||||
conn.close()
|
||||
original_exists = Path.exists
|
||||
|
||||
def exists(target):
|
||||
if kind == "denied" and target == path:
|
||||
raise PermissionError("Observation store access denied")
|
||||
return original_exists(target)
|
||||
|
||||
before = path.read_bytes() if original_exists(path) else None
|
||||
with patch("fenris.status.query_service_state", return_value=service), \
|
||||
patch("fenris.status._journalctl_hint", return_value="[bold]Native collector log[/bold]"), \
|
||||
patch("fenris.tui._journalctl_hint", return_value="[bold]Native collector log[/bold]"), \
|
||||
patch.object(Path, "exists", exists), \
|
||||
patch("fenris.tui.compute_projection") as projection:
|
||||
comp = get_status_composition(path, NOW)
|
||||
cli = get_status(path, NOW).lower()
|
||||
app = FenrisTuiApp(store_path=path)
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
headline = str(app.query_one("#headline-band").render()).lower()
|
||||
strip = str(app.query_one("#service-strip").render()).lower()
|
||||
if kind == "missing":
|
||||
assert comp.store_fault is None
|
||||
assert comp.freshness == "empty"
|
||||
assert "no observations yet" in cli and "no observations yet" in headline
|
||||
else:
|
||||
assert comp.state == StatusState.ERROR
|
||||
assert comp.freshness == "unknown"
|
||||
phrase = "newer fenris — upgrade fenris" if kind == "newer" else "observation store unreadable"
|
||||
assert phrase in cli and phrase in headline
|
||||
if kind != "newer":
|
||||
assert str(app.query_one("#drive-health").render()) == "[bold]Native collector log[/bold]"
|
||||
assert not app.query_one("#main-grid").has_class("paused")
|
||||
await pilot.resize_terminal(60, 18)
|
||||
await pilot.pause()
|
||||
assert phrase in str(app.query_one("#constrained-summary").render()).lower()
|
||||
for text in (cli, strip):
|
||||
assert ("boot: enabled" if service == ACTIVE else "boot: disabled" if service == DISABLED else "boot: unknown") in text
|
||||
assert ("timer: active" if service == ACTIVE else "timer: inactive" if service == DISABLED else "timer: unknown") in text
|
||||
if not service:
|
||||
assert "does not start on next boot" not in text
|
||||
projection.assert_not_called()
|
||||
assert (path.read_bytes() if original_exists(path) else None) == before
|
||||
|
||||
|
||||
def test_reader_owns_readonly_snapshot_and_closes_on_error(tmp_path):
|
||||
path = tmp_path / "observations.db"
|
||||
writer = init_store(path)
|
||||
with patch("fenris.status.query_service_state", return_value=ACTIVE) as query:
|
||||
with pytest.raises(RuntimeError, match="renderer failed"):
|
||||
with read_status(path, NOW) as (conn, comp):
|
||||
assert comp.sample_count == 0
|
||||
assert conn.in_transaction
|
||||
with pytest.raises(sqlite3.OperationalError, match="readonly"):
|
||||
conn.execute("DELETE FROM samples")
|
||||
writer.execute("INSERT INTO monitoring_periods (started_at) VALUES ('2026-09-16')")
|
||||
writer.commit()
|
||||
assert conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0] == 0
|
||||
raise RuntimeError("renderer failed")
|
||||
query.assert_called_once()
|
||||
with pytest.raises(sqlite3.ProgrammingError, match="closed"):
|
||||
conn.execute("SELECT 1")
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_monitoring_query_failure_is_unknown(tmp_path):
|
||||
path = tmp_path / "observations.db"
|
||||
init_store(path).close()
|
||||
with patch("fenris.status.query_service_state", side_effect=OSError("Unavailable")):
|
||||
comp = get_status_composition(path, NOW)
|
||||
cli = get_status(path, NOW, query_journal=False)
|
||||
assert comp.state == StatusState.UNKNOWN
|
||||
assert comp.boot_enabled is None and comp.timer_active is None
|
||||
assert "boot: unknown" in cli and "timer: unknown" in cli
|
||||
assert "does not start on next boot" not in cli
|
||||
|
||||
|
||||
def test_native_systemd_query_failure_is_unknown(tmp_path):
|
||||
from fenris.init_system import InitSystem
|
||||
|
||||
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||
patch("fenris.init_system._systemctl_show", return_value={}):
|
||||
comp = get_status_composition(tmp_path / "missing.db", NOW)
|
||||
assert comp.state == StatusState.UNKNOWN
|
||||
assert comp.boot_enabled is None and comp.timer_active is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_store_fault_clears_paused_views_and_can_recover(tmp_path):
|
||||
path = tmp_path / "observations.db"
|
||||
conn = init_store(path)
|
||||
conn.execute(
|
||||
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||
"VALUES ('2026-09-15', '2026-09-16', 'user_disabled')"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with patch("fenris.status.query_service_state", return_value=DISABLED):
|
||||
app = FenrisTuiApp(store_path=path)
|
||||
async with app.run_test() as pilot:
|
||||
assert app.query_one("#main-grid").has_class("paused")
|
||||
writer = sqlite3.connect(path)
|
||||
writer.execute("PRAGMA user_version = %d" % (SCHEMA_VERSION + 1))
|
||||
writer.close()
|
||||
app.on_refresh_tick()
|
||||
await pilot.pause()
|
||||
assert not app.query_one("#main-grid").has_class("paused")
|
||||
assert str(app.query_one("#drive-health").render()) == ""
|
||||
assert "upgrade Fenris" in str(app.query_one("#headline-band").render())
|
||||
assert app.query_one("#usage-history")._day_data == []
|
||||
writer = sqlite3.connect(path)
|
||||
writer.execute("PRAGMA user_version = %d" % SCHEMA_VERSION)
|
||||
writer.close()
|
||||
app.on_refresh_tick()
|
||||
await pilot.pause()
|
||||
assert app.query_one("#main-grid").has_class("paused")
|
||||
+176
-144
@@ -42,11 +42,7 @@ from fenris.tui import (
|
||||
FenrisTuiApp,
|
||||
DailyBarGraph,
|
||||
_format_remaining,
|
||||
_sparkline,
|
||||
_habit_bar,
|
||||
_query_usage_history,
|
||||
_query_drive_health,
|
||||
_query_service_facts,
|
||||
_query_daily_graph_data,
|
||||
_query_hourly_graph_data,
|
||||
_RANGE_OPTIONS,
|
||||
@@ -121,6 +117,33 @@ 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",
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -142,64 +165,6 @@ class TestFormatRemaining:
|
||||
assert _format_remaining(-100) == "endurance exhausted"
|
||||
|
||||
|
||||
class TestSparkline:
|
||||
def test_empty(self):
|
||||
assert _sparkline([]) == ""
|
||||
|
||||
def test_single_value(self):
|
||||
result = _sparkline([100.0])
|
||||
assert len(result) == 1
|
||||
|
||||
def test_multiple_values(self):
|
||||
result = _sparkline([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
assert len(result) > 0
|
||||
assert all(c in " ▁▂▃▄▅▆▇█" for c in result)
|
||||
|
||||
def test_width_limit(self):
|
||||
result = _sparkline([1.0] * 100, width=20)
|
||||
assert len(result) <= 20
|
||||
|
||||
|
||||
class TestHabitBar:
|
||||
def test_all_active(self):
|
||||
result = _habit_bar(1.0, 0.0, 0.0, 0.0)
|
||||
assert "active 100%" in result
|
||||
|
||||
def test_mixed(self):
|
||||
result = _habit_bar(0.5, 0.3, 0.1, 0.1)
|
||||
assert "active 50%" in result
|
||||
assert "idle 30%" in result
|
||||
|
||||
def test_all_unknown(self):
|
||||
result = _habit_bar(0.0, 0.0, 0.0, 1.0)
|
||||
assert "unknown 100%" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data query tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQueryUsageHistory:
|
||||
def test_empty_store(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
result = _query_usage_history(conn)
|
||||
assert result["num_days"] == 0
|
||||
assert result["sparkline"] == ""
|
||||
conn.close()
|
||||
|
||||
def test_with_days(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
for i in range(14):
|
||||
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100)
|
||||
result = _query_usage_history(conn)
|
||||
assert result["num_days"] == 14
|
||||
assert result["sparkline"] != ""
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestQueryDriveHealth:
|
||||
def test_empty_store(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
@@ -215,27 +180,6 @@ class TestQueryDriveHealth:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestQueryServiceFacts:
|
||||
def test_empty_store(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
now = _clock()
|
||||
result = _query_service_facts(conn, now)
|
||||
assert result["freshness"] == "empty"
|
||||
conn.close()
|
||||
|
||||
def test_fresh_sample(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
_insert_day(conn, "2026-09-29", bw=1024*1024*100)
|
||||
now = _clock()
|
||||
ts = (now - timedelta(minutes=2)).isoformat()
|
||||
_insert_sample(conn, ts)
|
||||
result = _query_service_facts(conn, now)
|
||||
assert result["freshness"] == "fresh"
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CI-1: Exhaustive state matrix from synthetic stores
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -267,6 +211,7 @@ class TestStateMatrix:
|
||||
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||
proj = compute_projection(conn, _clock())
|
||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||
assert proj.headline_remaining_seconds is not None
|
||||
@@ -282,6 +227,7 @@ class TestStateMatrix:
|
||||
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_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||
proj = compute_projection(conn, _clock())
|
||||
assert proj.confidence_state == ConfidenceState.SUPPORTED
|
||||
assert proj.headline_remaining_seconds is not None
|
||||
@@ -397,10 +343,10 @@ class TestDenseScreen:
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test() as pilot:
|
||||
strip = str(app.query_one("#service-strip").render())
|
||||
assert "boot:" in strip
|
||||
assert "timer:" in strip
|
||||
assert "last collect:" in strip
|
||||
assert "freshness:" in strip
|
||||
assert "Boot:" in strip
|
||||
assert "Timer:" in strip
|
||||
assert "Last collect:" in strip
|
||||
assert "Freshness:" in strip
|
||||
# Maker credit now in titlebox only (issue #79)
|
||||
assert "by Bongbetic" not in strip
|
||||
|
||||
@@ -412,7 +358,7 @@ class TestDenseScreen:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": True, "last_collect_age_s": 60,
|
||||
"last_collect_reason": None,
|
||||
@@ -427,7 +373,7 @@ class TestDenseScreen:
|
||||
assert "monitoring: active in background · persists across reboots" in strip
|
||||
assert "r resume — enable monitoring and future boots" in strip
|
||||
assert "q quit" not in strip
|
||||
assert rail == "q QUIT TUI"
|
||||
assert rail == "q Quit TUI"
|
||||
assert usage.region.y < service.region.y < quit_rail.region.y
|
||||
assert usage.region.bottom <= service.region.y
|
||||
assert service.region.bottom <= quit_rail.region.y
|
||||
@@ -446,11 +392,11 @@ class TestDenseScreen:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": False, "timer_active": False,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
|
||||
}), patch("fenris.control.subprocess.run") as subprocess_run, patch.object(
|
||||
app, "_run_helper"
|
||||
) as run_helper:
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
@@ -459,7 +405,7 @@ class TestDenseScreen:
|
||||
main_grid = app.query_one("#main-grid")
|
||||
assert str(main_grid.styles.layout) == "<grid>"
|
||||
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()
|
||||
assert "monitoring: paused — deliberate disable" in banner
|
||||
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner
|
||||
@@ -472,7 +418,7 @@ class TestDenseScreen:
|
||||
visible_text = " ".join(
|
||||
"".join(ElementTree.fromstring(screenshot).itertext()).split()
|
||||
)
|
||||
assert "paused time is excluded from your usage habit" in visible_text
|
||||
assert "Paused time is excluded from your usage habit" in visible_text
|
||||
assert "resume: fenris monitor resume" in visible_text
|
||||
assert "monitoring: does not start on next boot" in str(
|
||||
app.query_one("#service-strip").render()
|
||||
@@ -487,7 +433,7 @@ class TestDenseScreen:
|
||||
ElementTree.fromstring(app.export_screenshot()).itertext()
|
||||
).split()
|
||||
)
|
||||
assert "q QUIT TUI" in footer_text
|
||||
assert "q Quit TUI" in footer_text
|
||||
await pilot.press("q")
|
||||
assert not app.is_running
|
||||
subprocess_run.assert_not_called()
|
||||
@@ -509,11 +455,11 @@ class TestDenseScreen:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
|
||||
}), patch("fenris.control.subprocess.run") as subprocess_run, patch.object(
|
||||
app, "_run_helper"
|
||||
) as run_helper:
|
||||
async with app.run_test() as pilot:
|
||||
@@ -539,7 +485,7 @@ class TestDenseScreen:
|
||||
store_path=tmp_path / "nonexistent.db",
|
||||
refresh_interval_s=0.2,
|
||||
)
|
||||
auth_notice = "privileged actions will prompt for authentication (polkit)"
|
||||
auth_notice = "Open with fenris (no sudo). Actions authenticate via polkit. ? Help"
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
headline = str(app.query_one("#headline-band").render())
|
||||
@@ -595,12 +541,47 @@ class TestDisclosuresAndGreeting:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFirstRun:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("size", [(80, 24), (60, 18)])
|
||||
async def test_sudo_help_remains_available_after_launch(self, tmp_path, size):
|
||||
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
|
||||
with patch.object(app, "_run_helper") as run_helper:
|
||||
async with app.run_test(size=size) as pilot:
|
||||
app.on_refresh_tick()
|
||||
await pilot.press("?")
|
||||
help_text = str(app.screen.query_one("#help-text").render())
|
||||
assert "The dashboard does not need sudo" in help_text
|
||||
assert "sudo fenris monitor resume" in help_text
|
||||
assert "sudo fenris monitor pause" in help_text
|
||||
assert "sudo fenris sample" in help_text
|
||||
assert 'sudo usermod -aG fenris "$USER"' in help_text
|
||||
assert "log out and back in" in help_text
|
||||
await pilot.press("end")
|
||||
visible = " ".join("".join(
|
||||
ElementTree.fromstring(app.export_screenshot()).itertext()
|
||||
).split())
|
||||
assert "Esc Close" in visible
|
||||
await pilot.press("escape")
|
||||
assert len(app.screen_stack) == 1
|
||||
run_helper.assert_not_called()
|
||||
|
||||
def test_missing_polkit_identifies_command_and_points_to_help(self, tmp_path):
|
||||
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
|
||||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||||
patch("fenris.control.subprocess.run", side_effect=FileNotFoundError(2, "Missing", "pkexec")), \
|
||||
patch.object(app, "suspend"), patch.object(app, "_refresh"), \
|
||||
patch.object(app, "notify") as notify:
|
||||
app.action_resume()
|
||||
message = notify.call_args.args[0]
|
||||
assert "Command not found: pkexec" in message
|
||||
assert "sudo /usr/libexec/fenris/fenris-monitor enable --now" in message
|
||||
|
||||
def test_unprivileged_resume_uses_polkit(self, tmp_path):
|
||||
"""TUI controls use the same authenticated path as the CLI."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
|
||||
with patch("fenris.tui.os.geteuid", return_value=1000), \
|
||||
patch("fenris.tui.subprocess.run") as run, \
|
||||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||||
patch("fenris.control.subprocess.run") as run, \
|
||||
patch.object(app, "suspend"), \
|
||||
patch.object(app, "_refresh"):
|
||||
run.return_value.returncode = 0
|
||||
@@ -608,7 +589,6 @@ class TestFirstRun:
|
||||
|
||||
run.assert_called_once_with(
|
||||
["pkexec", "/usr/libexec/fenris/fenris-monitor", "enable", "--now"],
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -820,12 +800,29 @@ class TestQueryDailyGraphData:
|
||||
assert result[0]["is_gap"] is True
|
||||
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:
|
||||
def test_empty_day(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
result = _query_hourly_graph_data(conn, "2026-09-20")
|
||||
assert result == []
|
||||
result = _query_hourly_graph_data(
|
||||
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()
|
||||
|
||||
def test_with_hours(self, tmp_path):
|
||||
@@ -844,9 +841,25 @@ class TestQueryHourlyGraphData:
|
||||
def test_hour_labels(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_hour(conn, "2026-09-20T12:00:00+00:00", bw=1000)
|
||||
result = _query_hourly_graph_data(conn, "2026-09-20")
|
||||
assert len(result) == 1
|
||||
assert result[0]["local_label"] == "12"
|
||||
result = _query_hourly_graph_data(
|
||||
conn, "2026-09-20", datetime(2026, 9, 21, tzinfo=timezone.utc),
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
@@ -897,6 +910,17 @@ class TestDailyBarGraph:
|
||||
graph._trim_to_range()
|
||||
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)
|
||||
@@ -924,8 +948,15 @@ class TestBarGraphTUI:
|
||||
assert len(graph._day_data) > 0
|
||||
# Legend should be visible
|
||||
legend = str(app.query_one("#bar-legend").render())
|
||||
assert "alloc" in legend
|
||||
assert "zero" in legend
|
||||
assert "Alloc" 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
|
||||
async def test_arrow_selection(self, tmp_path):
|
||||
@@ -946,20 +977,16 @@ class TestBarGraphTUI:
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
# Right arrow selects first bar
|
||||
await pilot.press("right")
|
||||
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
|
||||
# The newest day is selected by default; left moves backward.
|
||||
initial = graph.selected_index
|
||||
await pilot.press("left")
|
||||
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
|
||||
async def test_range_switching(self, tmp_path):
|
||||
@@ -990,17 +1017,17 @@ class TestBarGraphTUI:
|
||||
assert graph.range_days == 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.pause()
|
||||
assert graph.range_days == 28
|
||||
assert len(graph._day_data) == 28
|
||||
assert graph.range_days == 30
|
||||
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.pause()
|
||||
assert graph.range_days == 90
|
||||
assert len(graph._day_data) == 30
|
||||
assert len(graph._day_data) == 90
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readout_updates_on_selection(self, tmp_path):
|
||||
@@ -1021,12 +1048,13 @@ class TestBarGraphTUI:
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
# No selection initially
|
||||
# Newest day is selected initially.
|
||||
readout = str(app.query_one("#bar-readout").render())
|
||||
assert "select" in readout.lower()
|
||||
assert "UTC" in readout
|
||||
assert "GB" in readout
|
||||
|
||||
# Select first bar
|
||||
await pilot.press("right")
|
||||
# Moving left updates the selected-day readout.
|
||||
await pilot.press("left")
|
||||
await pilot.pause()
|
||||
readout = str(app.query_one("#bar-readout").render())
|
||||
assert "2026-09" in readout
|
||||
@@ -1056,10 +1084,8 @@ class TestBarGraphTUI:
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
# Select a day
|
||||
await pilot.press("right")
|
||||
await pilot.pause()
|
||||
assert graph.selected_index == 0
|
||||
# The newest day is selected automatically.
|
||||
assert graph.selected_index == len(graph._day_data) - 1
|
||||
assert graph.view_mode == "daily"
|
||||
|
||||
# Enter drill-down
|
||||
@@ -1069,6 +1095,14 @@ class TestBarGraphTUI:
|
||||
assert graph.drill_day is not None
|
||||
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
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
@@ -1091,7 +1125,7 @@ class TestBarGraphTUI:
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
graph = app.query_one("#usage-history")
|
||||
assert graph.border_title == "usage history"
|
||||
assert graph.border_title == "Usage history"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_glyphs_in_legend(self, tmp_path):
|
||||
@@ -1172,9 +1206,9 @@ class TestConstrainedLayout:
|
||||
# Constrained summary should be visible
|
||||
summary = app.query_one("#constrained-summary")
|
||||
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 "GB total" in summary_text
|
||||
assert "GB" in summary_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_constrained_below_24_height(self, tmp_path):
|
||||
@@ -1196,7 +1230,7 @@ class TestConstrainedLayout:
|
||||
# Constrained summary should be visible
|
||||
summary = app.query_one("#constrained-summary")
|
||||
summary_text = str(summary.render())
|
||||
assert "graph needs ≥80×24" in summary_text
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resize_from_constrained_to_normal(self, tmp_path):
|
||||
@@ -1257,9 +1291,7 @@ class TestConstrainedLayout:
|
||||
# Focus and select a day
|
||||
app.set_focus(graph)
|
||||
await pilot.pause()
|
||||
await pilot.press("right")
|
||||
await pilot.pause()
|
||||
assert graph.selected_index == 0
|
||||
assert graph.selected_index == len(graph._day_data) - 1
|
||||
|
||||
# Resize to constrained — pilot.resize_terminal changes terminal size
|
||||
await pilot.resize_terminal(79, 24)
|
||||
@@ -1273,7 +1305,7 @@ class TestConstrainedLayout:
|
||||
# Constrained summary should show selected day context
|
||||
summary = app.query_one("#constrained-summary")
|
||||
summary_text = str(summary.render())
|
||||
assert "graph needs ≥80×24" in summary_text
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
# Selected day context should survive
|
||||
assert "2026-09" in summary_text
|
||||
|
||||
@@ -1285,7 +1317,7 @@ class TestConstrainedLayout:
|
||||
assert app._is_constrained_mode
|
||||
summary = app.query_one("#constrained-summary")
|
||||
summary_text = str(summary.render())
|
||||
assert "graph needs ≥80×24" in summary_text
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
assert "awaiting" in summary_text.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1315,13 +1347,13 @@ class TestConstrainedLayout:
|
||||
|
||||
# Service strip should have actions
|
||||
strip = str(app.query_one("#service-strip").render())
|
||||
assert "p pause" in strip
|
||||
assert "r resume" in strip
|
||||
assert "p Pause" in strip
|
||||
assert "r Resume" in strip
|
||||
assert "q quit" not in strip # Quit is in quit-rail
|
||||
|
||||
# Quit rail should be visible
|
||||
rail = str(app.query_one("#quit-rail").render())
|
||||
assert "QUIT" in rail
|
||||
assert "Quit" in rail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_constrained_long_reasons_visible(self, tmp_path):
|
||||
|
||||
@@ -4,7 +4,7 @@ Documentation=https://git.bongbetic.com/xavierk/Fenris
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitInactiveSec=5min
|
||||
OnUnitInactiveSec=3min
|
||||
AccuracySec=30s
|
||||
Persistent=no
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
#
|
||||
# Guarantees (must match systemd timer semantics):
|
||||
# - 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)
|
||||
# - No catch-up (fixed sleep interval, no Persistent=)
|
||||
# - Serialized runs (runsv does not restart until exit)
|
||||
@@ -20,7 +20,7 @@ set -eu
|
||||
|
||||
COLLECT_TIMEOUT=90
|
||||
BOOT_DELAY=120
|
||||
CADENCE=300
|
||||
CADENCE=180
|
||||
LOCK_FILE=/var/lib/fenris/fenris-collect.lock
|
||||
|
||||
# Ensure lock directory exists
|
||||
|
||||
Reference in New Issue
Block a user