Compare commits

..
22 Commits
Author SHA1 Message Date
xavierk 5aeee7b6f1 fix: guard local-day pruning across monitoring gaps (#100) 2026-09-28 14:37:22 +05:30
xavierk d69753690a fix: prune old detail after publication (#100) 2026-09-28 14:03:37 +05:30
xavierk 017a562566 fix: rebuild trustworthy local-day evidence (#99) 2026-09-28 13:09:00 +05:30
xavierk 7e392c4ea2 fix: conserve local-day activity evidence 2026-09-28 10:21:55 +05:30
xavierk 8a5ef05188 refactor: clarify publication recovery count 2026-09-28 09:06:09 +05:30
xavierk 847bde1be0 test: account for pending publication table 2026-09-28 03:23:46 +05:30
xavierk 0d45f263d2 feat: retain pending publications (#97) 2026-09-28 03:08:00 +05:30
xavierk 7c21b044ba test: own monitoring period fixture commit 2026-09-28 02:26:21 +05:30
xavierk 437ea6be73 fix: publish collection history atomically 2026-09-28 01:56:32 +05:30
xavierk 4550dd5111 Keep package directories accessible under restrictive build umasks
Release / release (push) Successful in 1m14s
2026-09-19 11:21:57 +05:30
xavierk e49aac8569 Release Fenris 0.5.0 with Chalktone dashboard and dotted activity plots 2026-09-19 11:13:37 +05:30
xavierk 8bac26836a Release Fenris 0.4.0
Release / release (push) Successful in 1m16s
2026-09-18 23:12:14 +05:30
xavierk 88672e12bf test: add edge-case tests for issue #72 acceptance criteria 2026-09-18 17:18:22 +05:30
xavierk cfd71a053b fix: store cross-midnight unattributed bytes once, not twice
The _add_unattributed_bytes function was adding the same cross-hour
delta to both days when the interval spanned midnight, violating the
spec requirement to preserve measured volume once as shared boundary
evidence.

Store the unattributed bytes only on the day where the interval starts
(the earlier day). This ensures the total across both days equals the
actual delta without duplication.

Closes #88 (cross-day delta duplication portion)
2026-09-18 16:54:17 +05:30
xavierk b76067104b feat(projection): add complete observation day gate (issue #94) 2026-09-18 15:23:59 +05:30
xavierk 95cca2e115 Keep local-day history trustworthy after detail expires (#93)
Add query_local_day_history() with evidence-limit metadata to local_day.py,
including LocalDayHistoryEntry dataclass that annotates each summary with
detail_available and derived_from_surviving flags.  Document the 14-day
detail vs indefinite-summary retention policy in both local_day.py and
pruning.py.

Fix query_local_day_summary() to convert SQLite integer booleans to Python
bool, and add deduplication guard to the midnight-spanning hour logic in
derive_local_day_summary().

Add 17 lifecycle tests covering all acceptance criteria:
- AC1: Summaries survive sample pruning, evidence limits visible
- AC2: Boundary anchors preserved before pruning
- AC3: Timezone survives system-timezone change
- AC4: Legacy UTC summaries labelled incomplete
- AC5: Idempotent pruning and repair
- AC6: Volume conservation (no double counting)
- AC7: Real temporary stores
- AC8: User-visible transition from recent to aged
- AC9: Retention policy documented

All 775 tests pass.
2026-09-18 14:27:44 +05:30
xavierkandCommandCodeBot 5df8a12339 Browse activity dates from the keyboard (#92)
Add keyboard-driven date navigation to the TUI:
- [ and ] keys select previous/next day, adjusting the visible range
- g opens a date entry modal for direct date navigation
- t returns to today's live view from any historical browsing
- Background refresh preserves the browsed selection
- Local-day widget shows data for the selected date
- Updated help screen and action legend with new bindings
- DatePickerScreen modal with input validation
- 24 headless tests covering navigation, date entry, browse
  stability, local-day evidence, and constrained widths

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-18 14:17:02 +05:30
xavierk e12f4a574c Plot live drive activity every three minutes (#91)
- Change default collection cadence from 5 minutes to 3 minutes
  (CADENCE_DEFAULT_S=180, systemd OnUnitInactiveSec=3min,
  runit CADENCE=180)
- Update freshness threshold to 450s (2×180 + AccuracySec + 60)
- Add _query_live_graph_data(): queries raw samples from the last
  3 hours and computes interval byte deltas with actual timestamps
- Add LiveActivityGraph widget: vertical bar chart of interval
  volumes with read/write toggle (w key), arrow key inspection,
  and click support
- Wire live graph into TUI layout (full-width row between daily
  graph and drive health), refresh cycle, and CSS grid
- Replace t theme binding with t today/live binding; theme
  selection via preferences file
- Add w binding for read/write toggle on live graph
- Update action legend, help screen, and grid layout for new
  live-activity row
- Add 20 tests covering cadence constants, live query, widget
  rendering, toggle, and TUI integration
- Update all cadence documentation (README, ADR 0003, acceptance
  criteria LC-2, fenris-redesign constants table, CHANGELOG)
2026-09-18 13:17:18 +05:30
xavierk 4f884b4b73 Show trustworthy local-day activity totals (#90)
Add local-day activity summaries derived from UTC hour observations using
the system timezone, with durable storage in a new local_days table.
The collector derives local-day read/write totals after UTC aggregation;
the TUI displays them with timezone, completeness state, and coverage.

Schema: bump SCHEMA_VERSION to 3, add local_days table (migration 2→3
is pure addition, idempotent, preserves newer-schema refusal).
2026-09-18 12:28:23 +05:30
xavierk 366c2f55b7 Publish consistent measured drive activity (#89)
Repair the collection-to-display path so each successful acquisition
publishes correct read/write deltas through the observation store and
visible dashboard.

Fixes:
- derive.py: accumulate bytes_read_delta on same-hour hour_observation
  merge (was silently dropped)
- collector.py: rebuild day_aggregates from hour observations after
  each collection run (previously only populated for cross-hour intervals)
- day_aggregate.py: add persist_day_aggregate upsert helper
- tui.py: query and display both read and write deltas in daily and
  hourly readouts, constrained summaries, and graph data queries

Tests:
- Add 14 integration tests (test_measured_activity.py) exercising the
  full collector→store→reader→display path with real fixtures and
  injected time
- Update constrained-layout assertion to match new W/R format

Closes #89
2026-09-17 17:43:31 +05:30
xavierk 54cd56e4ec Fix XBPS repository setup instructions 2026-09-16 09:48:06 +05:30
xavierk 69e08d9d3a Release Fenris 0.3.7
Release / release (push) Successful in 1m6s
2026-09-16 08:53:38 +05:30
69 changed files with 9654 additions and 916 deletions
+38
View File
@@ -9,6 +9,44 @@ backfill releases from before this changelog.
## [Unreleased] ## [Unreleased]
## [0.5.0] - 2026-09-19
### Changed
- Make drive activity the focus of a Chalktone dashboard with Live, Day, and History tabs, panel zoom, and fixed monitoring controls.
- Replace block bars with labelled dotted volume plots that fit the terminal and preserve gaps, partial evidence, and point inspection.
### Fixed
- Preserve historical selections and store-fault messages through graph refresh and resize.
- Show hourly drill-down results without overwriting them with a loading placeholder.
- Keep known unallocated daily volume visible across coverage gaps, and distinguish missing hourly evidence from zero on small terminals.
- Stage package directories with consistent public permissions, avoiding openSUSE RPM conflicts and inaccessible runtime paths when built with a restrictive umask.
## [0.4.0] - 2026-09-18
### Added
- Show local-day read and write totals with their recorded timezone, labelled as totals so far for the current day.
- Open the dashboard on a live three-hour written-volume graph with a read/write toggle and selected-point inspection.
- Browse history by day from the keyboard with `[`, `]`, `g` date entry, and `t` for today and the live view.
### Changed
- Collect every three minutes on systemd and runit, plot live points at their actual timestamps, and retain three-minute detail for 14 days before durable summaries.
- Withhold the endurance outlook until one full local observation day has usable evidence, then show it with categorical confidence.
### Fixed
- Keep midnight-spanning activity once as shared boundary evidence instead of adding it to both days.
- Publish derived hour and day history consistently with each collection run.
## [0.3.7] - 2026-09-16
### Changed
- Make usage-history axes, units, UTC boundaries, active 7/14/30/90-day window, gaps, partial periods, stacked write attribution, and hourly drill-down explicit; keep live graph data refreshed on the current three-minute cadence.
## [0.3.6] - 2026-09-16 ## [0.3.6] - 2026-09-16
### Changed ### Changed
+8
View File
@@ -40,6 +40,14 @@ _Avoid_: Hourly record, hourly.jsonl entry
One row per UTC day derived from hour observations; the grain at which usage-habit evidence is judged. One row per UTC day derived from hour observations; the grain at which usage-habit evidence is judged.
_Avoid_: Daily summary, daily stats _Avoid_: Daily summary, daily stats
**Usage-history window**:
An exact consecutive span of UTC calendar days ending today, shown from day aggregates; a day without trustworthy evidence remains an explicit gap rather than disappearing or being estimated.
_Avoid_: Available records, dataset range
**Unallocated write evidence**:
Writes known to belong to a UTC day but which cannot be assigned honestly to a particular hour; they contribute to that day's total but are never distributed across hourly bars.
_Avoid_: Missing writes, estimated hourly writes
**Controller segment**: **Controller segment**:
A span of observation history within which the drive's controller identity is unchanged and counters are monotonic; write deltas are never computed across a segment boundary. A span of observation history within which the drive's controller identity is unchanged and counters are monotonic; write deltas are never computed across a segment boundary.
_Avoid_: Counter reset handling, drive swap detection _Avoid_: Counter reset handling, drive swap detection
+31 -2
View File
@@ -211,7 +211,7 @@ Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`)
## Cadence drop-ins ## Cadence drop-ins
The default collection cadence is **5 minutes** (`OnUnitInactiveSec=5min` in the timer unit). To change it, place a systemd drop-in: The default collection cadence is **3 minutes** (`OnUnitInactiveSec=3min` in the timer unit). To change it, place a systemd drop-in:
```bash ```bash
sudo systemctl edit fenris-collect.timer sudo systemctl edit fenris-collect.timer
@@ -223,7 +223,7 @@ sudo systemctl edit fenris-collect.timer
No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concern, not a Fenris configuration key. No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concern, not a Fenris configuration key.
On Void, Fenris uses its native runit service instead: its initial collection On Void, Fenris uses its native runit service instead: its initial collection
is delayed by two minutes and later collections run five minutes after the is delayed by two minutes and later collections run three minutes after the
previous run finishes. Inspect its state and diagnostics with: previous run finishes. Inspect its state and diagnostics with:
```bash ```bash
@@ -252,6 +252,35 @@ is retried at the next interval; it never fabricates missing observations.
## Reading the dashboard ## Reading the dashboard
![Chalktone dashboard with a dotted activity plot](assets/dashboard-chalktone.png)
Preview uses synthetic observations, not measurements from a real drive.
The Chalktone dashboard opens with a large **Live** activity plot. **Day** shows
hourly evidence for a selected date, labelled UTC, and **History** shows daily
evidence. Local-day totals keep their recorded timezone. Dotted traces show
measured read/write volumes, not transfer speed; missing evidence breaks the
trace. `?` marks a gap, `~` a partial total, and `u` unallocated daily volume.
Use the selected-point readout for exact values and evidence state.
Local-day totals use measured intervals inside each recorded local date. An
interval that crosses local midnight appears once as shared evidence and stays
outside both known totals. The dashboard labels current totals as so far and
shows incomplete or unavailable dates without treating them as zero. Older
UTC-only summaries cannot establish exact local-day totals.
- `v` cycles Live / Day / History; the tabs are also clickable.
- `←` / `→` inspect points; `w` switches read/write volume in every view.
- `[` / `]` browse dates, `g` enters a date, and `t` returns to today/live.
- `Tab` / `Shift+Tab` move focus; `z` expands the focused panel, and `z` or
`Esc` restores it. Monitoring status and controls remain visible.
- `s` cycles Chalktone, Amber, Nord, and High Contrast; saved theme preferences
survive upgrades. `m` toggles reduced motion.
On smaller terminals, textual summaries and scrollable panels keep evidence
accessible. Pause, resume, collect, disclosures, help, and quit remain available
in the fixed control row.
Run `fenris` as your normal user to open the TUI dashboard. The dashboard does 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; not need `sudo`. Use `sudo` for package installation and system configuration;
pause, resume, and collect-now actions normally authenticate through polkit. pause, resume, and collect-now actions normally authenticate through polkit.
Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

@@ -13,7 +13,7 @@ Fenris's current single process combines daemonization, a PID file, an HTTP dash
## Decision ## Decision
1. **Units.** Two system units only: `fenris-collect.timer` (`WantedBy=timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`; no listener, no UI code). The TUI and CLI are ordinary unprivileged processes and never units. There is no `/run/fenris` coordination surface: systemd serializes runs, the observation store holds state, and failures go to the journal per [ADR 0001](0001-observation-store-sqlite.md). 1. **Units.** Two system units only: `fenris-collect.timer` (`WantedBy=timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`; no listener, no UI code). The TUI and CLI are ordinary unprivileged processes and never units. There is no `/run/fenris` coordination surface: systemd serializes runs, the observation store holds state, and failures go to the journal per [ADR 0001](0001-observation-store-sqlite.md).
2. **Cadence.** Default five minutes: `OnBootSec=2min`, `OnUnitInactiveSec=5min` (measured from run completion; drift accepted because hours are the evidence grain), `AccuracySec=30s`, `Persistent=no`, no suspend catch-up (absent hours classify through power-on-hours evidence), `TimeoutStartSec=90s` so a hung interrogation fails visibly. Cadence changes are documented drop-ins on the timer unit (`systemctl edit` + daemon-reload); no interval key exists in configuration. 2. **Cadence.** Default three minutes: `OnBootSec=2min`, `OnUnitInactiveSec=3min` (measured from run completion; drift accepted because hours are the evidence grain), `AccuracySec=30s`, `Persistent=no`, no suspend catch-up (absent hours classify through power-on-hours evidence), `TimeoutStartSec=90s` so a hung interrogation fails visibly. Cadence changes are documented drop-ins on the timer unit (`systemctl edit` + daemon-reload); no interval key exists in configuration.
3. **Configuration.** `/etc/fenris/fenris.conf` holds exactly one key: the device selector, a stable `/dev/disk/by-id/…` path (raw nodes accepted with an instability warning), validated at collection time. The oneshot re-reads it every run, so there is no reload path to design. An invalid selector is a bounded failed run — journal plus failed unit result, retried next interval; `status` and the TUI also read the world-readable file directly and surface a `configuration error: <reason>` fact. 3. **Configuration.** `/etc/fenris/fenris.conf` holds exactly one key: the device selector, a stable `/dev/disk/by-id/…` path (raw nodes accepted with an instability warning), validated at collection time. The oneshot re-reads it every run, so there is no reload path to design. An invalid selector is a bounded failed run — journal plus failed unit result, retried next interval; `status` and the TUI also read the world-readable file directly and surface a `configuration error: <reason>` fact.
4. **Entry points.** Two privileged binaries: `/usr/libexec/fenris/fenris-collect` (device interrogation and store writes; the unit's `ExecStart`) and `/usr/libexec/fenris/fenris-monitor` (fixed operations `enable` and `disable` with optional `--now`, plus the collect trigger, monitoring-period bookkeeping, and `baseline set`/`baseline clear` persistence for the CLI-validated endurance baseline; the only binary the polkit policy authorizes). One unprivileged `fenris` for humans: no arguments opens the TUI; subcommands (`status`, `sample`, `monitor pause`, `monitor resume`) are the CLI. 4. **Entry points.** Two privileged binaries: `/usr/libexec/fenris/fenris-collect` (device interrogation and store writes; the unit's `ExecStart`) and `/usr/libexec/fenris/fenris-monitor` (fixed operations `enable` and `disable` with optional `--now`, plus the collect trigger, monitoring-period bookkeeping, and `baseline set`/`baseline clear` persistence for the CLI-validated endurance baseline; the only binary the polkit policy authorizes). One unprivileged `fenris` for humans: no arguments opens the TUI; subcommands (`status`, `sample`, `monitor pause`, `monitor resume`) are the CLI.
5. **Sanctioned toggle.** Pause = `disable --now`; Resume = `enable --now`; both executed by `fenris-monitor`, which performs the systemctl operation and the monitoring-period bookkeeping in one step, under polkit action `com.bongbetic.fenris.monitor` (`auth_admin`, covering the collect trigger too). Root invokes the helpers directly; where no polkit agent exists the operation fails cleanly and prints the root equivalent. This amends the research's direct-systemctl toggle: a period boundary cannot be recorded by systemctl, so the toggle must be Fenris's own fixed operation. 5. **Sanctioned toggle.** Pause = `disable --now`; Resume = `enable --now`; both executed by `fenris-monitor`, which performs the systemctl operation and the monitoring-period bookkeeping in one step, under polkit action `com.bongbetic.fenris.monitor` (`auth_admin`, covering the collect trigger too). Root invokes the helpers directly; where no polkit agent exists the operation fails cleanly and prints the root equivalent. This amends the research's direct-systemctl toggle: a period boundary cannot be recorded by systemctl, so the toggle must be Fenris's own fixed operation.
@@ -0,0 +1,11 @@
# 10. Preserve local-day activity history with its recorded timezone
Status: Accepted; legacy-history migration and repair implemented in issue #99.
Fenris will retain local-day read/write summaries and the boundary evidence needed to interpret them in the existing observation store before three-minute detail expires after 14 days. Each historical summary retains its recorded timezone and day boundaries; this extends [ADR 0001](0001-observation-store-sqlite.md) while preserving the UTC hour/day evidence used for endurance projections. Collection derives new measured intervals. Ordered schema migration and explicit repair rebuild legacy summaries from surviving evidence; TUI and CLI readers remain read-only.
UTC hourly summaries alone cannot recover a local day whose midnight falls inside a UTC hour. Preserving labelled local summaries trades arbitrary future timezone reinterpretation for bounded detailed-history retention; retaining all fine-grained intervals indefinitely or estimating a split from UTC summaries would violate the agreed retention or evidence semantics.
Only surviving evidence may be used to derive older local summaries. Dates without enough evidence remain incomplete or unavailable; a measured interval crossing midnight is retained once as shared boundary evidence, never prorated or counted in full on both days. A later timezone change does not silently rewrite historical day boundaries.
The agreed presentation and validation requirements are in the [live drive activity specification](../spec/live-drive-activity.md).
+67
View File
@@ -0,0 +1,67 @@
# Glint dashboard design adoption
Research date: 2026-09-19. Status: feasibility findings with interview decisions in progress. This note does not authorize implementation or change Fenris's accepted behavior.
## Finding
Fenris can adopt a Glint-inspired terminal dashboard through an independently authored redesign of its existing Textual presentation layer. Glint is a Rust terminal application, not an HTML/CSS dashboard or a drop-in Textual component library. Its most relevant ideas are a compact pane grid, consistent title rows and metadata, obvious focus, restrained colors, and enlarging a focused pane. A wholesale Glint integration would introduce a different UI runtime, unrelated application infrastructure, and a licensing decision without being necessary to achieve this visual direction.
The user has selected **Chalktone / screenshot 3**, **a large activity chart as the primary panel**, and **panel styling plus keyboard focus and zoom**. The activity panel has **Live / Day / History tabs**; enlarging a panel retains a **fixed monitoring status, freshness, and control strip**. A general dashboard builder is outside the selected scope. The developing product specification is [Glint dashboard design](../spec/glint-dashboard-design.md).
## Primary-source snapshot
Inspected Glint's default branch at commit [`c1d73d3e8ead2f4069630b2a237306af8f6e69c8`](https://github.com/ntrospect0/glint/tree/c1d73d3e8ead2f4069630b2a237306af8f6e69c8), committed 2026-07-19. A shallow reference checkout was created outside Fenris at `/tmp/fenris-glint-reference`.
- [README](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md) contains three dashboard screenshots, a setup screenshot, and a live-capture link. The first two dashboard screenshots use `tokyonight`; the third uses `chalktone`. They show example compositions, not one mandatory layout.
- [Screenshot 1](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo1.png), [screenshot 2](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo2.png), [screenshot 3](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo3.png), and [setup screenshot](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-setup.png) are versioned in the repository. This source investigation does not substitute for visual inspection of those images.
- [Cargo.toml](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/Cargo.toml) declares Rust edition 2021, package version 0.5.0, Ratatui 0.28, Crossterm 0.28, and a `glint` binary. It also brings Tokio, HTTP clients, configuration/watch infrastructure, and optional widget dependencies.
- [GitHub repository API](https://api.github.com/repos/ntrospect0/glint), checked on the research date, reports an unarchived repository created 2026-05-27. [GitHub releases API](https://api.github.com/repos/ntrospect0/glint/releases) returned no releases. The [changelog](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/CHANGELOG.md) labels 0.5.0 and 0.4.0 unreleased, and README installation is from source. This is evidence of a young project and its distribution state, not proof that it is abandoned.
- [CI configuration](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/.github/workflows/ci.yml) runs Cargo tests and Clippy on Ubuntu; Clippy warnings do not fail CI. Source review alone does not establish that current CI passes. No Glint build or test run was performed for this assessment.
## What can transfer
| Glint pattern and source | Fit for Fenris | Scope implication |
| --- | --- | --- |
| Pane grid with row/column spans: [layout model](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/layout.rs), [default configuration](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/defaults/config.toml). | Fenris already uses a Textual grid and rounded bordered panes. A considered re-layout can use that existing ownership. | Decide which Fenris facts deserve persistent space. A fixed layout is much smaller than Glint's user-composable dashboard system. |
| Title integrated into the border, right-aligned metadata, focus treatment, shortcut indication: [UI title helpers](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/ui/mod.rs). | Useful for clearly named activity, history, drive, and monitoring panes. Date/range/freshness can become concise pane metadata where legible. | Independently implement the visible behavior in Fenris; do not translate or copy these GPL helpers. Ensure essential evidence is not merely truncated away. |
| Semantic theme roles separating border, title, metadata, and text: [theme model](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/theme/mod.rs), [bundled schemes](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/defaults/colorschemes.toml). | Fenris already owns themes in `src/fenris/themes.py`. Extend that role system for a chosen visual direction while keeping semantic status colors and text. | Select the preferred screenshot/palette before making the default. High Contrast and reduced-motion preferences already exist and must remain usable. |
| Tab/click focus, keyboard shortcuts, and focused-pane enlargement: [README controls](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md), [app focus/zoom ownership](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/app.rs). | Particularly useful for graphs when a small terminal limits detail. | Zoom is new interaction behavior, not just styling. Selection/date/focus must survive entry, exit, resize, and refresh; essential fault/pause state needs a visibility decision. |
| Multiple widgets stacked in one cell, cycled with `.`/`,`: [stack implementation](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/stack.rs). | A possible way to expose secondary details without permanent screen cost. | Hiding monitoring state, confidence, or evidence behind inactive tabs would weaken Fenris's current contract. Choose deliberately; not assumed needed. |
| Responsive views exposing more detail at larger sizes: [view tiers](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/view_tier.rs), [widget author guide](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/widget-sdk.md). | Useful principle, but Glint's breakpoints describe its content and are not Fenris requirements. | Preserve Fenris's constrained-terminal facts and controls, then design larger views. Do not simply reuse Glint's threshold values. |
| Compact bottom status bar: [status bar](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/ui/status_bar.rs). | Supports a quieter action/focus rail. | Fenris has real monitoring continuity, freshness, collection outcome, authentication, and quit semantics. Its footer cannot be reduced to Glint's version/clock/theme bar. |
Glint's calendar, stocks, news, email, weather, notes, galleries, external credentials, and profile/setup machinery are outside the stated Fenris redesign. Its widget SDK adds widgets inside the Rust application via a trait and registry; it is not an existing Python integration seam ([widget SDK](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/widget-sdk.md), [widget interface](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/mod.rs)).
## Fenris ownership and constraints
The present application is Python with Textual, pinned to Textual 8.2.8 in [`requirements.txt`](../../requirements.txt). [`FenrisTuiApp`](../../src/fenris/tui.py) already owns the grid, header, bordered panes, compact layout, help, action bindings, and theme registration; `DailyBarGraph` and `LiveActivityGraph` already own graph selection/rendering. [`themes.py`](../../src/fenris/themes.py) owns Amber, Nord, High Contrast, and status color precedence. These are the natural extension points; a new backend or service is unnecessary for a visual redesign.
The design must preserve these existing responsibilities and evidence semantics:
- The collector owns privileged drive acquisition and observation-store writes; the TUI is an unprivileged reader and dispatches administrative actions through the sanctioned helper. A visual redesign does not imply a second sampler or store writer. Sources: [`CONTEXT.md`](../../CONTEXT.md), [redesign spec](../spec/fenris-redesign.md), [`tui.py`](../../src/fenris/tui.py).
- Usage-adjusted theoretical lifespan remains a write-endurance projection, accompanied by projection confidence and supporting facts. It must not become a physical failure countdown. Unknown, unavailable, warming, stale, and store-fault states require meaningful presentation. Sources: [`CONTEXT.md`](../../CONTEXT.md), [projection ADR](../adr/0002-projection-model-sustained-regime.md), [live activity specification](../spec/live-drive-activity.md).
- Gaps must not become zeros, and unavailable measurements must not become decorative smooth curves. Local-day labels, read/write units, incomplete evidence, selected dates, and timezone context matter. Sources: [local-day history ADR](../adr/0010-local-day-activity-history.md), [live activity specification](../spec/live-drive-activity.md).
- Pause is a deliberate disable; quitting only leaves the TUI. Monitoring continuity, last collection outcome, and freshness must remain understandable. Sources: [service lifecycle ADR](../adr/0003-service-lifecycle-and-sanctioned-toggle.md), [dashboard clarity specification](../spec/dashboard-clarity.md), [`status_composition.py`](../../src/fenris/status_composition.py).
- The current source switches to constrained presentation below 80 columns or 24 rows; the accepted live-activity specification requires daily totals, dates, evidence labels, confidence, and controls to remain accessible on narrow terminals. A new design must be reviewed against real target dimensions. Sources: [`tui.py`](../../src/fenris/tui.py), [live activity specification](../spec/live-drive-activity.md).
The fixed Panes information architecture is normative in the older [redesign specification, section 7](../spec/fenris-redesign.md), and [dashboard clarity](../spec/dashboard-clarity.md) says it preserves that architecture and prescribes a separate quit rail. A significantly different pane hierarchy or footer should explicitly amend those presentation decisions when the user selects the new design, rather than silently treating old contracts as irrelevant. The underlying evidence and action semantics can be retained.
## License boundary
Glint declares **GPL-3.0-or-later** in [Cargo.toml](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/Cargo.toml) and source SPDX headers. Its [README license section](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md) says distributed modified versions must remain GPL-licensed with copyright notices, and its [LICENSE](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/LICENSE) specifies the distribution obligations. Fenris deliberately uses MIT across source and packages under [ADR 0009](../adr/0009-mit-license.md).
Copying or translating Glint implementation into an integrated distributed Fenris would therefore raise GPL compliance and licensing choices that conflict with keeping the combined implementation solely MIT. It is not an adoption route to take implicitly. The recommended route is independently authored Fenris UI code using common layout/interaction ideas observed in Glint, without copying Glint code or assets. If direct implementation reuse becomes a requirement, resolve licensing or separate permission first; this research does not establish that permission.
## Interview state
The selected direction is an independently implemented Chalktone-inspired dashboard with a fixed Fenris layout, an activity-first hierarchy, keyboard focus and enlargement, one activity panel with Live / Day / History tabs, and a persistent monitoring status/freshness/control strip during enlargement. Existing 80×24 support and constrained reflow are retained rather than reopened as a new minimum-size decision. The [developing design specification](../spec/glint-dashboard-design.md) owns the precise requirements and any remaining decisions; this research note is supporting evidence.
No product code, glossary, ADR, or existing acceptance criteria were changed by this investigation. Keyboard focus/zoom feasibility does not establish that exact interaction behavior has been implemented or runtime-validated in Fenris.
## Validation scope
Evidence comes from the pinned Glint source, its checked-in documentation/screenshots, GitHub's first-party API, and Fenris's current source and accepted documents. The main design investigation visually inspected screenshots 1 and 3 and separately verified Textual grid documentation.
The main investigation also reviewed synthetic renders of current Fenris at 140×44 and 80×24, after refresh cleared the launch authentication banner, using installed Textual 8.2.7 (the repository lock is 8.2.8). The captures are `/tmp/fenris-current-capture-3xdv46jm/fenris-current-140x44.png` and `/tmp/fenris-current-capture-3xdv46jm/fenris-current-80x24.png`. They showed a large blank vertical area; at 80×24 the drive, monitoring status, and actions fell below the initial viewport. This supports rearranging the panels and keeping the status/control strip visible, but is not a diagnosis of the deployed drive or proof across every state.
No Glint runtime was executed, and no Glint asset was copied into the product. No accessibility audit was conducted. This research establishes architectural feasibility and records decisions; it does not claim pixel fidelity, a working integration, an upstream test result, or a measured performance result.
+1 -1
View File
@@ -49,7 +49,7 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
## Collector lifecycle and privilege boundaries (ADR 0003) ## Collector lifecycle and privilege boundaries (ADR 0003)
- **LC-1** (P) Exactly two system units exist — `fenris-collect.timer` (`timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`); the TUI and CLI are ordinary unprivileged processes and never units. - **LC-1** (P) Exactly two system units exist — `fenris-collect.timer` (`timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`); the TUI and CLI are ordinary unprivileged processes and never units.
- **LC-2** (P) Timer defaults ship as `OnBootSec=2min`, `OnUnitInactiveSec=5min`, `AccuracySec=30s`, `Persistent=no`, `TimeoutStartSec=90s`; cadence changes are documented drop-ins and no interval key exists in configuration. - **LC-2** (P) Timer defaults ship as `OnBootSec=2min`, `OnUnitInactiveSec=3min`, `AccuracySec=30s`, `Persistent=no`, `TimeoutStartSec=90s`; cadence changes are documented drop-ins and no interval key exists in configuration.
- **LC-3** (P) A hung device interrogation fails visibly within `TimeoutStartSec=90s` as a bounded failed run retried next interval. - **LC-3** (P) A hung device interrogation fails visibly within `TimeoutStartSec=90s` as a bounded failed run retried next interval.
- **LC-4** (A/P) `/etc/fenris/fenris.conf` holds exactly the device selector (stable `/dev/disk/by-id/…` path; raw nodes warned), re-read every run; an invalid selector is a bounded failed run surfaced as `configuration error: <reason>` in `status` and the TUI. - **LC-4** (A/P) `/etc/fenris/fenris.conf` holds exactly the device selector (stable `/dev/disk/by-id/…` path; raw nodes warned), re-read every run; an invalid selector is a bounded failed run surfaced as `configuration error: <reason>` in `status` and the TUI.
- **LC-5** (P) Two privileged binaries ship at `/usr/libexec/fenris/fenris-collect` and `/usr/libexec/fenris/fenris-monitor`; the unprivileged `fenris` wrapper opens the TUI with no arguments. - **LC-5** (P) Two privileged binaries ship at `/usr/libexec/fenris/fenris-collect` and `/usr/libexec/fenris/fenris-monitor`; the unprivileged `fenris` wrapper opens the TUI with no arguments.
+7 -1
View File
@@ -1,5 +1,11 @@
# Fenris dashboard clarity specification # Fenris dashboard clarity specification
**Presentation amendment (2026-09-19):** The accepted
[Glint-inspired dashboard design](glint-dashboard-design.md) supersedes the
preserved Panes arrangement and standalone heavy quit rail. Identity, continuity,
paused-state explanations, and the distinction between quitting and pausing
remain required; quit now has an explicit entry in the fixed controls.
**Status: decision-complete.** Assembled by [Assemble the dashboard clarity specification and close the map](https://git.bongbetic.com/xavierk/Fenris/issues/60) from the closed tickets of the Wayfinder map [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55). This document is normative for the follow-up **execution effort**; nothing here is implemented by the map. **Status: decision-complete.** Assembled by [Assemble the dashboard clarity specification and close the map](https://git.bongbetic.com/xavierk/Fenris/issues/60) from the closed tickets of the Wayfinder map [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55). This document is normative for the follow-up **execution effort**; nothing here is implemented by the map.
**Canonical roles.** The [redesign specification](fenris-redesign.md) (frozen) and [ADRs 0001–0007](../adr/) remain authoritative and untouched — this is a companion spec covering five dashboard clarity additions plus the changelog-driven release-notes mechanism. The [criteria register](acceptance-criteria.md) carries the testable statements: **DC-1–DC-8**, appended by this assembly, with **TUI-4's binding list amended** (§4). Terminology follows the glossary in [`CONTEXT.md`](../../CONTEXT.md), including *Deliberate disable* and *Release*. **Canonical roles.** The [redesign specification](fenris-redesign.md) (frozen) and [ADRs 0001–0007](../adr/) remain authoritative and untouched — this is a companion spec covering five dashboard clarity additions plus the changelog-driven release-notes mechanism. The [criteria register](acceptance-criteria.md) carries the testable statements: **DC-1–DC-8**, appended by this assembly, with **TUI-4's binding list amended** (§4). Terminology follows the glossary in [`CONTEXT.md`](../../CONTEXT.md), including *Deliberate disable* and *Release*.
@@ -119,4 +125,4 @@ This resolves the map's README-wording fog: the wording is decided here; the act
## 9. Out of scope ## 9. Out of scope
Executing any of this — code, tests, releases — and any TUI layout or information-architecture redesign beyond the five additions named above. Execution is a fresh effort after handoff. Executing any of this — code, tests, releases — and any TUI layout or information-architecture redesign beyond the five additions named above. Execution is a fresh effort after handoff.
+8 -2
View File
@@ -16,7 +16,7 @@ Every constant is defined once, in the section named below; other sections cite,
| Constant | Value | Defined in | | Constant | Value | Defined in |
|---|---|---| |---|---|---|
| Collection cadence (default) | 5 min (`OnUnitInactiveSec`) | §8.2 | | Collection cadence (default) | 3 min (`OnUnitInactiveSec`) | §8.2 |
| First-boot delay | 2 min (`OnBootSec`) | §8.2 | | First-boot delay | 2 min (`OnBootSec`) | §8.2 |
| Timer accuracy window | 30 s (`AccuracySec`) | §8.2 | | Timer accuracy window | 30 s (`AccuracySec`) | §8.2 |
| Collection-run timeout | 90 s (`TimeoutStartSec`) | §8.2 | | Collection-run timeout | 90 s (`TimeoutStartSec`) | §8.2 |
@@ -362,6 +362,12 @@ Never "82 % confidence" or "95 % accurate".
## 7. Panes TUI ## 7. Panes TUI
**Presentation amendment (2026-09-19):** The accepted
[Glint-inspired dashboard design](glint-dashboard-design.md) supersedes this
section's panel arrangement and graph appearance with an activity-first layout,
dotted volume plots, Live / Day / History tabs, and focused-panel zoom. Shared
evidence, projection, authentication, and monitoring-action contracts still apply.
**Decisions:** [Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3) (Variant A adopted), [Evaluate Python TUI frameworks](https://git.bongbetic.com/xavierk/Fenris/issues/6) (Textual). **ADRs:** [0003](../adr/0003-service-lifecycle-and-sanctioned-toggle.md) §§8, 10; [0004](../adr/0004-install-upgrade-removal-lifecycle.md) §10. **Criteria:** TUI-1–TUI-4, CI-1, CI-2, CI-4. The [prototype](https://git.bongbetic.com/xavierk/Fenris/src/branch/prototype/tui-information-architecture/prototype/tui-ia) is visual reference only; this section is normative. **Decisions:** [Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3) (Variant A adopted), [Evaluate Python TUI frameworks](https://git.bongbetic.com/xavierk/Fenris/issues/6) (Textual). **ADRs:** [0003](../adr/0003-service-lifecycle-and-sanctioned-toggle.md) §§8, 10; [0004](../adr/0004-install-upgrade-removal-lifecycle.md) §10. **Criteria:** TUI-1–TUI-4, CI-1, CI-2, CI-4. The [prototype](https://git.bongbetic.com/xavierk/Fenris/src/branch/prototype/tui-information-architecture/prototype/tui-ia) is visual reference only; this section is normative.
### 7.1 Framework and floor ### 7.1 Framework and floor
@@ -479,7 +485,7 @@ Two privileged binaries — `/usr/libexec/fenris/fenris-collect` (device interro
Constants defined once, consumed by TUI and CLI alike; the grade derives from the **newest sample timestamp**, never a stored flag: Constants defined once, consumed by TUI and CLI alike; the grade derives from the **newest sample timestamp**, never a stored flag:
- **fresh** — newest sample within 2 × cadence + `AccuracySec` + 60 s (11.5 min at default cadence); - **fresh** — newest sample within 2 × cadence + `AccuracySec` + 60 s (7.5 min at default cadence);
- **missed** — between that and 48 h (a contributing fact); - **missed** — between that and 48 h (a contributing fact);
- **stale** — ≥ 48 h, matching the §6.7 evidence gate; - **stale** — ≥ 48 h, matching the §6.7 evidence gate;
- **empty store** — *"no observations yet"* with an enable hint. - **empty store** — *"no observations yet"* with an enable hint.
+148
View File
@@ -0,0 +1,148 @@
# Glint-inspired Fenris dashboard
Status: accepted on 2026-09-19. The user confirmed the complete design and
additionally requested Glint-style plotted graphs in place of block bars.
## Purpose and reference
Adopt the visual approach of [Glint](https://github.com/ntrospect0/glint) for
Fenris's terminal dashboard. The selected visual reference is the third README
screenshot, using Chalktone, at upstream commit
`c1d73d3e8ead2f4069630b2a237306af8f6e69c8`:
[reference screenshot](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo3.png).
The source and architectural assessment is recorded in
[the research note](../research/glint-dashboard-adoption.md).
## Confirmed user choices
- Adopt panel styling, keyboard focus, and panel zoom. A freely configurable
dashboard builder is outside the selected scope.
- Give the live read/write activity chart the largest opening-screen area,
with endurance outlook and monitoring state visible alongside it.
- Use the muted Chalktone appearance from Glint's third screenshot as the
visual direction.
- Put Live / Day / History views inside one activity panel, keeping both
read/write totals and the selected date visible.
- Expand a focused panel within the dashboard while retaining a fixed strip
for monitoring state, observation freshness, and essential controls.
- Replace block bars with thin dotted time-series plots resembling Glint's
chart. Label volume and time axes, retain exact selected-point readouts,
and leave explicit breaks for missing or incompatible evidence. Joining
adjacent measured points is visual guidance, not additional observations.
## Implementation recommendation
Implement the selected visual and interaction patterns independently in
Fenris's existing Python/Textual presentation layer. Retain the collector,
observation store, shared status/projection contracts, and authenticated control
path. Glint is a Rust/Ratatui application under GPL-3.0-or-later; copying or
porting its implementation into Fenris would require a separate licensing
decision under [ADR 0009](../adr/0009-mit-license.md). Referencing general panel
and navigation patterns does not require adopting its application architecture.
Use thin borders, compact titles, an explicit focused-panel indicator, and
restrained cream/earth-tone accents. Preserve readable contrast and semantic
state labels; the reference's dim secondary text is not a readability target.
Use Fenris content and identity, without Glint's unrelated clock, weather,
finance, email, or gallery features.
## Existing behavior to preserve
- Activity is measured read/write volume, with both totals accessible and
writes selected initially. A visual stock-chart reference must not turn
interval volume into speed or imply continuity through unknown evidence.
- Preserve the latest-three-hour opening view, date selection, local-day and
timezone labels, available history precision, and historical selection across
refresh under [live drive activity](live-drive-activity.md).
- Preserve the usage-adjusted theoretical lifespan and categorical projection
confidence with contributing facts. Navigation and graph selection cannot
change the projection evidence window or endurance accounting.
- Preserve explicit zero, gap, incomplete, unallocated, unavailable, paused,
stale, collection-failure, and store-fault states.
- Keep boot enablement, runtime activity, collection outcome, and observation
freshness distinct. Quit leaves background monitoring running; pause remains
a deliberate disable through the existing control path.
- Preserve keyboard and mouse access, constrained-terminal text/reflow,
high-contrast availability, reduced motion, help, and disclosures.
## Concrete layout and interaction proposal
- A compact identity header above one dashboard workspace.
- A narrow supporting column for the endurance outlook and drive facts;
a wide activity panel receives the remaining workspace. On the normal
dashboard, confidence and monitoring state remain visible beside activity.
- Live starts with the latest three hours; Day exposes the selected local
day's available detail; History exposes the retained longer-term evidence.
Reuse existing measurements and ranges rather than introducing a new data
model. Show selected date/timezone, read/write totals, measurement, and
evidence state wherever applicable.
- Use visible, clickable tabs; Tab/Shift+Tab and mouse clicks move focus.
Use `z` to toggle focused-panel zoom and Escape to restore the dashboard
when an input/dialog is not consuming Escape. Preserve selection, date,
range, measurement, and focus across zoom and refresh.
- Keep the bottom status/control area outside the expanding workspace. It
may wrap when required: compactness cannot merge the four service facts or
hide a fault/pause state. Keep quit distinct from pause in wording and
behavior without spending three full rows on a separate heavy quit box.
- Use existing date-navigation and read/write shortcuts, with visible help
updated for tabs, focus, and zoom. Text entry must consume its own keys.
- Use Chalktone-inspired styling as the new default while retaining existing
selectable themes, High Contrast, and reduced motion. Preserve an explicitly
saved theme preference during upgrade.
- Design for 80×24 and larger, with existing text/reflow behavior below that
size. Essential facts and actions must remain accessible; the large-screen
reference does not require squeezing its entire density into small terminals.
These defaults implement the confirmed choices. Date entry and inspection
retain existing evidence precision: hourly/daily UTC evidence is labelled UTC,
while local-day totals keep their recorded timezone. This visual redesign does
not manufacture finer or local-hour precision from coarse UTC evidence.
## Smallest sufficient implementation proof
Render normal and zoomed views at a representative large terminal and 80×24,
plus a constrained terminal. Check focus/tab/zoom/date/read-write interactions
and selection persistence through refresh. Exercise paused, stale, store-fault,
missing-baseline, and incomplete-evidence displays using existing synthetic
stores and headless TUI patterns. Verify quit never invokes monitoring control
and existing action tests still cover the sanctioned helper. UI fixtures are
not evidence of the user's deployed drive state.
## Specification reconciliation
This accepted redesign supersedes conflicting presentation requirements in
[the original Panes specification](fenris-redesign.md#7-panes-tui) and
[dashboard clarity](dashboard-clarity.md): the full-width headline becomes a
supporting endurance panel, the history/live plots share a tabbed activity
panel, and a compact fixed control row replaces the heavy standalone quit
rail. The block-bar requirement is superseded by dotted volume plots. Their
behavioral requirements, including clear quit-versus-pause semantics, remain.
The live-drive-activity specification excludes unrelated redesign from that
earlier task. This is a separate design request, not permission to undo its
accepted data, date-navigation, evidence, or forecast behavior. Existing design
documents and source may differ in implementation status; this assessment is
not proof that every earlier acceptance criterion has shipped.
## Documentation scope
No new domain term has been resolved: panel, focus, zoom, and theme are general
interface concepts and do not belong in the domain glossary. No new ADR is
needed for a reversible presentation change that retains the existing stack,
license, data model, and privilege boundary. Record any later durable
architectural trade-off separately if one emerges.
## Implementation validation
Implemented in Fenris's existing presentation layer with Textual 8.2.8.
The full suite passed 801 tests; 43 packaging/signing checks were skipped for
missing package artifacts or signing tools. After the final incomplete-evidence
fixes, all 81 focused dashboard, history, and plot tests passed. New renderer
and regression files pass configured Ruff checks; affected production files
pass correctness lint and the diff passes whitespace checks. Existing broader
lint warnings were not part of this redesign.
Rendered normal and zoomed dashboards at 140×44, plus normal 80×24 and
constrained 70×20 views. The [saved preview](../../assets/dashboard-chalktone.png)
uses synthetic observations. No installation or release was performed.
+2 -2
View File
@@ -130,9 +130,9 @@ Users add the repository to `/etc/xbps.d/xbps.conf`:
repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64 repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64
``` ```
Or install directly: Or make a one-off request without writing the configuration file:
```sh ```sh
sudo xbps-install -S https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64 sudo xbps-install -R https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64 -M -S fenris
``` ```
## Publication Mechanism ## Publication Mechanism
+4 -1
View File
@@ -12,7 +12,10 @@ sudo xbps-install fenris # Void Linux
For Void Linux, configure the XBPS repository first: For Void Linux, configure the XBPS repository first:
```bash ```bash
sudo xbps-install -S https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64 sudo install -d -m 0755 /etc/xbps.d
echo 'repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64' \
| sudo tee /etc/xbps.d/fenris.conf
sudo xbps-install -M -S fenris
``` ```
## Verify downloads ## Verify downloads
+4
View File
@@ -14,6 +14,10 @@
# /usr/lib/tmpfiles.d/fenris.conf # /usr/lib/tmpfiles.d/fenris.conf
set -euo pipefail set -euo pipefail
# Package directories must be traversable by every runtime user and agree
# with distribution-owned directories, regardless of the builder's umask.
umask 022
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
STAGE_DIR="${REPO_ROOT}/build/stage" STAGE_DIR="${REPO_ROOT}/build/stage"
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "fenris" name = "fenris"
version = "0.3.6" version = "0.5.0"
description = "NVMe wear monitor with persistent TUI" description = "NVMe wear monitor with persistent TUI"
requires-python = ">=3.10" requires-python = ">=3.10"
license = {file = "LICENSE"} license = {file = "LICENSE"}
+2 -1
View File
@@ -201,7 +201,8 @@ echo " Branch: ${GITEA_BRANCH}"
echo " Repository URL: https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}" echo " Repository URL: https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}"
echo "" echo ""
echo "Client installation:" echo "Client installation:"
echo " sudo xbps-install -S https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}" echo " echo 'repository=https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}' | sudo tee /etc/xbps.d/fenris.conf"
echo " sudo xbps-install -M -S fenris"
echo "" echo ""
echo "Key ceremony: delete the private key after publication." echo "Key ceremony: delete the private key after publication."
echo " See docs/install/signing-key-ceremony.md" echo " See docs/install/signing-key-ceremony.md"
+1 -1
View File
@@ -1,2 +1,2 @@
"""Fenris: NVMe wear monitor with persistent TUI.""" """Fenris: NVMe wear monitor with persistent TUI."""
__version__ = "0.3.6" __version__ = "0.5.0"
+104
View File
@@ -0,0 +1,104 @@
"""A terminal volume plot shared by Fenris's activity views.
Braille provides two by four dots per terminal cell. Only adjacent, complete
measurements are connected; gaps and partial evidence never imply continuity.
The returned columns also place mouse inspection on the plotted time axis.
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from rich.text import Text
@dataclass(frozen=True)
class VolumePoint:
time: float
volume: int | None
label: str
state: str = "measured"
def volume_plot(
points: Sequence[VolumePoint], width: int, height: int,
selected: int, colors: Mapping[str, str],
) -> tuple[Text, list[int], str]:
"""Render bounded axes and a dotted trace, without resampling evidence."""
width, height = max(12, width), max(4, height)
axis_width = 8
columns, rows = width - axis_width, height - 2
pixel_width, pixel_height = columns * 2, rows * 4
maximum = max((p.volume or 0 for p in points), default=0)
scale, unit = (1e12, "TB") if maximum >= 1e12 else (
(1e9, "GB") if maximum >= 1e9 else
(1e6, "MB") if maximum >= 1e6 else
(1e3, "KB") if maximum >= 1e3 else (1, "B")
)
ceiling = maximum or scale
start = points[0].time if points else 0
span = (points[-1].time - start) if len(points) > 1 else 0
xs = [round((p.time - start) / span * (pixel_width - 1)) if span else 0
for p in points]
cells = [[0] * columns for _ in range(rows)]
# Unicode braille dot numbering, indexed by y within cell and then x.
bits = ((1, 8), (2, 16), (4, 32), (64, 128))
def dot(x: int, y: int) -> None:
cells[y // 4][x // 2] |= bits[y % 4][x % 2]
previous = None
markers = {}
for index, (point, x) in enumerate(zip(points, xs)):
if point.volume is None:
previous = None
if point.state != "future":
markers[x // 2] = "?"
continue
y = round((1 - point.volume / ceiling) * (pixel_height - 1))
y = min(pixel_height - 1, max(0, y))
if previous is not None and point.state == "measured":
px, py = previous
steps = max(abs(x - px), abs(y - py), 1)
for step in range(steps + 1):
dot(round(px + (x - px) * step / steps),
round(py + (y - py) * step / steps))
dot(x, y)
previous = (x, y) if point.state == "measured" else None
if point.state != "measured":
markers[x // 2] = "~" if point.state == "partial" else "u"
elif point.volume == 0:
markers.setdefault(x // 2, "·")
selected_column = xs[selected] // 2 if 0 <= selected < len(xs) else -1
result = Text(no_wrap=True, overflow="crop")
ticks = {0, rows // 2, rows - 1}
for row, values in enumerate(cells):
value = ceiling * (rows - 1 - row) / max(1, rows - 1) / scale
label = f"{value:6.2f}"[-6:] if row in ticks else " "
result.append(label + " │", style=colors["muted"])
for col, value in enumerate(values):
char = chr(0x2800 + value) if value else " "
style = colors["allocated"]
if col == selected_column:
style = "bold " + colors["selection"]
if not value:
char = "┊"
style = colors["muted"]
result.append(char, style=style)
result.append("\n")
result.append(" └", style=colors["muted"])
for col in range(columns):
result.append("▼" if col == selected_column else markers.get(col, "─"),
style=colors["selection"] if col == selected_column else colors["muted"])
result.append("\n" + " " * axis_width)
labels = [" "] * columns
end_of_label = -1
for index in sorted({0, len(points) // 2, len(points) - 1}):
if index < 0 or not points:
continue
label = points[index].label[:columns]
column = max(0, min(columns - len(label), xs[index] // 2 - len(label) // 2))
if column > end_of_label:
labels[column:column + len(label)] = label
end_of_label = column + len(label)
result.append("".join(labels), style=colors["muted"])
return result, [axis_width + x // 2 for x in xs], unit
+14 -10
View File
@@ -27,7 +27,6 @@ if VENV_DIR.exists():
if site_packages: if site_packages:
sys.path.insert(0, str(site_packages)) sys.path.insert(0, str(site_packages))
from fenris.store import init_store, get_store_path
from fenris.collector import run_collection from fenris.collector import run_collection
@@ -103,14 +102,6 @@ def main() -> None:
config = load_config() config = load_config()
device = config["device"] device = config["device"]
# Interrogate the drive
smartctl_data = interrogate_drive(device)
# Find sysfs path
sysfs_path = find_nvme_sysfs()
if sysfs_path is None:
raise RuntimeError("No NVMe controller found in sysfs")
# Inject a simple clock # Inject a simple clock
class SimpleClock: class SimpleClock:
def utcnow(self): def utcnow(self):
@@ -118,11 +109,24 @@ def main() -> None:
clock = SimpleClock() clock = SimpleClock()
def acquire():
"""Interrogate the drive after pending-work preflight."""
smartctl_data = interrogate_drive(device)
sysfs_path = find_nvme_sysfs()
if sysfs_path is None:
raise RuntimeError("No NVMe controller found in sysfs")
return smartctl_data, sysfs_path
# Run collection # Run collection
result = run_collection(smartctl_data, sysfs_path, config, clock) result = run_collection(config=config, clock=clock, acquire=acquire)
if result["ok"]: if result["ok"]:
print(f"Collection successful: {result['sample_count']} sample(s)") print(f"Collection successful: {result['sample_count']} sample(s)")
if result.get("retention_error"):
print(
f"Retention deferred: {result['retention_error']}",
file=sys.stderr,
)
sys.exit(0) sys.exit(0)
else: else:
print(f"Collection failed: {result['error']}", file=sys.stderr) print(f"Collection failed: {result['error']}", file=sys.stderr)
+232 -84
View File
@@ -5,19 +5,21 @@ This module implements the thinnest complete write path:
- Acquire controller identity from sysfs - Acquire controller identity from sysfs
- Normalize identity exactly once at write time - Normalize identity exactly once at write time
- Validate every row against store invariants - Validate every row against store invariants
- Commit one well-formed sample - Stage valid observations privately before derivation
- Publish the sample and derived evidence in one collection-owned transaction
No code path outside the collector interrogates the device. No code path outside the collector interrogates the device.
""" """
import json import json
import sqlite3 import sqlite3
from collections.abc import Callable
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional
from .store import init_store, get_store_path from .derive import derive_hours_from_interval, find_previous_sample
from .monitoring_periods import ensure_period_open from .monitoring_periods import ensure_period_open
from .derive import find_previous_sample, derive_hours_from_interval from .store import init_store, get_store_path
class AcquisitionError(Exception): class AcquisitionError(Exception):
@@ -30,6 +32,9 @@ class InvariantViolationError(Exception):
pass pass
PENDING_PUBLICATION_LIMIT = 6720
def acquire_from_smartctl(smartctl_data: Dict[str, Any]) -> Dict[str, Any]: def acquire_from_smartctl(smartctl_data: Dict[str, Any]) -> Dict[str, Any]:
"""Acquire counters and thermal evidence from smartctl -a -j data. """Acquire counters and thermal evidence from smartctl -a -j data.
@@ -195,12 +200,12 @@ def write_sample(
sample: Dict[str, Any], sample: Dict[str, Any],
identity: Dict[str, Any], identity: Dict[str, Any],
conn: sqlite3.Connection, conn: sqlite3.Connection,
clock, observed_at: datetime,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Write one sample to the observation store. """Write one sample to the observation store.
Identity normalization happens exactly once here. Identity normalization happens exactly once here.
Returns segment info for the caller. Returns segment info for the caller. Caller owns the transaction.
""" """
from .segment import find_current_segment, should_open_new_segment, open_segment from .segment import find_current_segment, should_open_new_segment, open_segment
@@ -219,8 +224,7 @@ def write_sample(
# Open new segment if needed # Open new segment if needed
segment_opened = False segment_opened = False
if should_open: if should_open:
now = clock.utcnow() open_segment(conn, observed_at, identity, identity_key, identity_degraded)
open_segment(conn, now, identity, identity_key, identity_degraded)
segment_opened = True segment_opened = True
# Get current segment_id for provenance # Get current segment_id for provenance
@@ -235,8 +239,8 @@ def write_sample(
percentage_used, available_spare, media_errors, power_on_hours, percentage_used, available_spare, media_errors, power_on_hours,
power_cycles, unsafe_shutdowns, temperature_c, power_cycles, unsafe_shutdowns, temperature_c,
data_units_written, data_units_read, bytes_written, bytes_read, data_units_written, data_units_read, bytes_written, bytes_read,
critical_warning, segment_id critical_warning, segment_id, local_tz
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
sample["ts"], sample["ts"],
@@ -259,102 +263,246 @@ def write_sample(
sample["bytes_read"], sample["bytes_read"],
sample["critical_warning"], sample["critical_warning"],
segment_id, segment_id,
sample.get("local_tz"),
), ),
) )
conn.commit()
return { return {
"segment_opened": segment_opened, "segment_opened": segment_opened,
"segment_reason": reason, "segment_reason": reason,
"identity_key": identity_key, "identity_key": identity_key,
"identity_degraded": identity_degraded, "identity_degraded": identity_degraded,
"segment_id": segment_id, "segment_id": segment_id,
"sample_id": cursor.lastrowid,
} }
def run_collection( def _observation_time(sample: Dict[str, Any]) -> datetime:
smartctl_data: Dict[str, Any], """Read an observation's original timestamp for ordered recovery."""
sysfs_path: Path, observed_at = datetime.fromisoformat(sample["ts"])
config: Dict[str, Any], if observed_at.tzinfo is None:
clock, return observed_at.replace(tzinfo=timezone.utc)
) -> Dict[str, Any]: return observed_at.astimezone(timezone.utc)
"""Run one collection run.
This is the main entry point for the collector. def _publish_observation(
Returns the run outcome. conn: sqlite3.Connection,
""" sample: Dict[str, Any],
try: identity: Dict[str, Any],
# Acquire counters and thermal evidence tz_name: str,
counters = acquire_from_smartctl(smartctl_data) ) -> None:
"""Publish one staged sample and all dependent evidence in caller transaction."""
# Acquire controller identity observed_at = _observation_time(sample)
identity = acquire_from_sysfs(sysfs_path) sample["local_tz"] = tz_name
validate_sample_invariants(sample, conn)
# Build sample with injected clock ensure_period_open(conn, observed_at)
sample = { seg_info = write_sample(sample, identity, conn, observed_at)
"ts": clock.utcnow().isoformat(),
"device": config["device"], current_id = seg_info["sample_id"]
**counters, prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id)
**identity, if prev is not None:
current = {
"id": current_id,
"ts": sample["ts"],
"bytes_written": sample["bytes_written"],
"bytes_read": sample["bytes_read"],
"power_on_hours": sample["power_on_hours"],
"temperature_c": sample["temperature_c"],
"data_units_written": sample["data_units_written"],
"data_units_read": sample["data_units_read"],
"local_tz": tz_name,
} }
derive_hours_from_interval(conn, prev, current)
# Initialize store if needed from .local_day import record_local_activity_interval
record_local_activity_interval(
conn,
prev,
current,
start_sample_id=prev["id"],
end_sample_id=current_id,
segment_id=seg_info.get("segment_id"),
)
else:
previous_any_segment = find_previous_sample(conn, None, current_id)
if previous_any_segment is not None:
from .local_day import mark_local_activity_gap
mark_local_activity_gap(
conn,
sample["ts"],
tz_name,
previous_any_segment,
)
from .day_aggregate import derive_all_days, persist_day_aggregate
for aggregate in derive_all_days(conn):
persist_day_aggregate(conn, aggregate)
from .local_day import derive_local_day_summary, persist_local_day
local_summary = derive_local_day_summary(conn, tz_name, observed_at)
if local_summary is not None:
persist_local_day(conn, local_summary)
def _pending_count(conn: sqlite3.Connection) -> int:
return conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0]
def _stage_observation(
conn: sqlite3.Connection,
sample: Dict[str, Any],
identity: Dict[str, Any],
tz_name: str,
) -> int:
"""Durably stage valid acquired evidence before attempting publication."""
payload = json.dumps(
{"sample": sample, "identity": identity, "tz_name": tz_name},
separators=(",", ":"),
sort_keys=True,
)
cursor = conn.execute(
"INSERT INTO pending_publications (sample_ts, payload) VALUES (?, ?)",
(sample["ts"], payload),
)
return cursor.lastrowid
def _recover_pending(conn: sqlite3.Connection) -> int:
"""Publish pending observations oldest first; stop at first failure."""
recovered = 0
while True:
conn.execute("BEGIN IMMEDIATE")
try:
row = conn.execute(
"SELECT id, payload FROM pending_publications ORDER BY id LIMIT 1"
).fetchone()
if row is None:
conn.rollback()
return recovered
pending_id, payload = row
observation = json.loads(payload)
_publish_observation(
conn,
observation["sample"],
observation["identity"],
observation["tz_name"],
)
conn.execute("DELETE FROM pending_publications WHERE id = ?", (pending_id,))
conn.commit()
recovered += 1
except Exception:
conn.rollback()
raise
def _recover_pending_for_collection(conn: sqlite3.Connection) -> int:
"""Retry queued work and report exhausted capacity without masking store faults."""
try:
return _recover_pending(conn)
except Exception as exc:
if isinstance(exc, sqlite3.Error) and not isinstance(exc, sqlite3.IntegrityError):
raise
if isinstance(exc, InvariantViolationError):
raise
try:
capacity_full = _pending_count(conn) >= PENDING_PUBLICATION_LIMIT
except sqlite3.Error:
raise exc
if capacity_full:
raise RuntimeError(
"pending publication capacity full "
f"({PENDING_PUBLICATION_LIMIT} observations); no new observation acquired; "
f"recovery failed: {exc}"
) from exc
raise
def run_collection(
smartctl_data: Optional[Dict[str, Any]] = None,
sysfs_path: Optional[Path] = None,
config: Optional[Dict[str, Any]] = None,
clock=None,
*,
acquire: Optional[Callable[[], tuple[Dict[str, Any], Path]]] = None,
) -> Dict[str, Any]:
"""Recover old work, acquire one observation, then publish it atomically.
Production callers pass ``acquire`` so recovery and capacity checks run
before device interrogation. Direct sample arguments remain useful for
deterministic collector tests.
"""
conn = None
try:
if config is None or clock is None:
raise ValueError("config and clock are required")
store_path = get_store_path(config) store_path = get_store_path(config)
conn = init_store(store_path) conn = init_store(store_path)
# Run legacy import if needed (idempotent)
from .legacy import import_legacy_history from .legacy import import_legacy_history
history_path = Path(config.get("data_dir", ".")) / "history.jsonl" history_path = Path(config.get("data_dir", ".")) / "history.jsonl"
if history_path.exists(): if history_path.exists():
import_legacy_history(conn, history_path, clock=clock) import_legacy_history(conn, history_path, clock=clock)
published_count = _recover_pending_for_collection(conn)
try:
# Ensure monitoring period is open (issue #73 AC2) # Hold the writer reservation across the capacity check and acquisition.
ensure_period_open(conn, clock.utcnow()) # A concurrent collector will recheck pending work before it acquires.
while True:
# Validate invariants conn.execute("BEGIN IMMEDIATE")
validate_sample_invariants(sample, conn) waiting = _pending_count(conn)
if waiting:
# Write sample and get segment info conn.rollback()
seg_info = write_sample(sample, identity, conn, clock) published_count += _recover_pending_for_collection(conn)
continue
# Derive hour observations from interval with previous sample
try: from .tz_util import detect_system_tz
# Find the sample we just wrote tz_name = detect_system_tz()
cursor = conn.execute("SELECT id FROM samples ORDER BY id DESC LIMIT 1") if acquire is not None:
current_id = cursor.fetchone()[0] smartctl_data, sysfs_path = acquire()
if smartctl_data is None or sysfs_path is None:
prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id) raise AcquisitionError("No acquired SMART data or sysfs identity")
if prev is not None:
# Build current sample dict for derivation counters = acquire_from_smartctl(smartctl_data)
current = { identity = acquire_from_sysfs(sysfs_path)
"id": current_id, sample = {
"ts": sample["ts"], "ts": clock.utcnow().isoformat(),
"bytes_written": sample["bytes_written"], "device": config["device"],
"bytes_read": sample["bytes_read"], **counters,
"power_on_hours": sample["power_on_hours"], **identity,
"temperature_c": sample["temperature_c"],
"data_units_written": sample["data_units_written"],
"data_units_read": sample["data_units_read"],
}
derive_hours_from_interval(conn, prev, current)
except Exception:
# Derivation failure must not prevent sample persistence (issue #73 AC6)
pass
return {
"ok": True,
"sample_count": 1,
"store_path": str(store_path),
} }
finally: validate_sample_invariants(sample, conn)
conn.close() _stage_observation(conn, sample, identity, tz_name)
conn.commit()
except (AcquisitionError, InvariantViolationError) as e: break
published_count += _recover_pending_for_collection(conn)
retention_error = None
try:
if _pending_count(conn) == 0:
from .pruning import prune_old_samples
prune_old_samples(conn, clock.utcnow())
except Exception as exc: # noqa: BLE001 - publication already committed.
# Publication already committed. Keep collection successful and
# retry atomic retention on the next normal collection.
conn.rollback()
retention_error = str(exc)
return {
"ok": True,
"sample_count": published_count,
"retention_error": retention_error,
"store_path": str(store_path),
}
except Exception as exc:
if conn is not None:
conn.rollback()
return { return {
"ok": False, "ok": False,
"error": str(e), "error": str(exc),
"error_type": type(e).__name__, "error_type": type(exc).__name__,
} }
finally:
if conn is not None:
conn.close()
+38
View File
@@ -163,3 +163,41 @@ def derive_all_days(conn: sqlite3.Connection) -> list[DayAggregate]:
if agg is not None: if agg is not None:
results.append(agg) results.append(agg)
return results return results
def persist_day_aggregate(conn: sqlite3.Connection, agg: DayAggregate) -> None:
"""Upsert a derived day aggregate into the day_aggregates table.
Merges attributed bytes from hour observations with any existing
unattributed cross-hour evidence already stored for this day.
Caller must manage transactions and commits.
"""
existing = conn.execute(
"SELECT id FROM day_aggregates WHERE day = ?",
(agg.day,),
).fetchone()
if existing is None:
conn.execute(
"INSERT INTO day_aggregates "
"(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
" bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(agg.day, agg.seconds_active, agg.seconds_idle,
agg.seconds_powered_off, agg.seconds_unknown,
agg.bytes_written_delta, agg.bytes_read_delta,
agg.sample_count, agg.coverage),
)
else:
conn.execute(
"UPDATE day_aggregates "
"SET active_seconds = ?, idle_seconds = ?, powered_off_seconds = ?, "
" unknown_seconds = ?, bytes_written_delta = ?, bytes_read_delta = ?, "
" sample_count = ?, coverage = ? "
"WHERE id = ?",
(agg.seconds_active, agg.seconds_idle,
agg.seconds_powered_off, agg.seconds_unknown,
agg.bytes_written_delta, agg.bytes_read_delta,
agg.sample_count, agg.coverage,
existing[0]),
)
+35 -31
View File
@@ -29,7 +29,7 @@ def find_previous_sample(
if segment_id is not None: if segment_id is not None:
cursor = conn.execute( cursor = conn.execute(
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, " "SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
" temperature_c, data_units_written, data_units_read " " temperature_c, data_units_written, data_units_read, local_tz "
"FROM samples WHERE id < ? AND segment_id = ? " "FROM samples WHERE id < ? AND segment_id = ? "
"ORDER BY id DESC LIMIT 1", "ORDER BY id DESC LIMIT 1",
(current_sample_id, segment_id), (current_sample_id, segment_id),
@@ -37,7 +37,7 @@ def find_previous_sample(
else: else:
cursor = conn.execute( cursor = conn.execute(
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, " "SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
" temperature_c, data_units_written, data_units_read " " temperature_c, data_units_written, data_units_read, local_tz "
"FROM samples WHERE id < ? " "FROM samples WHERE id < ? "
"ORDER BY id DESC LIMIT 1", "ORDER BY id DESC LIMIT 1",
(current_sample_id,), (current_sample_id,),
@@ -49,7 +49,7 @@ def find_previous_sample(
"id": row[0], "ts": row[1], "bytes_written": row[2], "id": row[0], "ts": row[1], "bytes_written": row[2],
"bytes_read": row[3], "power_on_hours": row[4], "bytes_read": row[3], "power_on_hours": row[4],
"temperature_c": row[5], "data_units_written": row[6], "temperature_c": row[5], "data_units_written": row[6],
"data_units_read": row[7], "data_units_read": row[7], "local_tz": row[8],
} }
@@ -96,6 +96,7 @@ def derive_hours_from_interval(
"""Derive hour observations from a sample pair interval. """Derive hour observations from a sample pair interval.
Returns list of hour observation dicts that were written/updated. Returns list of hour observation dicts that were written/updated.
Caller owns the transaction.
""" """
prev_ts = _parse_ts(prev_sample["ts"]) prev_ts = _parse_ts(prev_sample["ts"])
next_ts = _parse_ts(next_sample["ts"]) next_ts = _parse_ts(next_sample["ts"])
@@ -175,7 +176,8 @@ def _upsert_hour_observation(
"""Insert or update an hour observation.""" """Insert or update an hour observation."""
# Check if hour exists # Check if hour exists
existing = conn.execute( existing = conn.execute(
"SELECT id, bytes_written_delta, sample_count FROM hour_observations WHERE hour = ?", "SELECT id, bytes_written_delta, bytes_read_delta, sample_count "
"FROM hour_observations WHERE hour = ?",
(hour_key,), (hour_key,),
).fetchone() ).fetchone()
@@ -198,14 +200,14 @@ def _upsert_hour_observation(
else: else:
# Merge: accumulate bytes and sample count # Merge: accumulate bytes and sample count
new_bw = existing[1] + bw_delta new_bw = existing[1] + bw_delta
new_samples = existing[2] + sample_count new_br = existing[2] + br_delta
new_samples = existing[3] + sample_count
conn.execute( conn.execute(
"UPDATE hour_observations SET bytes_written_delta = ?, sample_count = ? WHERE id = ?", "UPDATE hour_observations "
(new_bw, new_samples, existing[0]), "SET bytes_written_delta = ?, bytes_read_delta = ?, sample_count = ? "
"WHERE id = ?",
(new_bw, new_br, new_samples, existing[0]),
) )
conn.commit()
def _add_unattributed_bytes( def _add_unattributed_bytes(
conn: sqlite3.Connection, conn: sqlite3.Connection,
prev_ts: datetime, prev_ts: datetime,
@@ -213,25 +215,27 @@ def _add_unattributed_bytes(
bw_delta: int, bw_delta: int,
br_delta: int, br_delta: int,
) -> None: ) -> None:
"""Add unattributed byte deltas to day aggregates for each day touched.""" """Store unattributed byte deltas once as shared boundary evidence.
prev_day = prev_ts.strftime("%Y-%m-%d")
next_day = next_ts.strftime("%Y-%m-%d")
days = {prev_day, next_day} A cross-midnight interval's delta is preserved on the day where it
for day in days: STARTS (the earlier day). It is not duplicated into both days;
existing = conn.execute( the spec requires preserving the measured volume once as shared
"SELECT id FROM day_aggregates WHERE day = ?", (day,) unallocated boundary evidence (issue #88).
).fetchone() """
if existing is None: day = prev_ts.strftime("%Y-%m-%d")
conn.execute(
"INSERT INTO day_aggregates (day, unattributed_bytes_written, unattributed_bytes_read) " existing = conn.execute(
"VALUES (?, ?, ?)", "SELECT id FROM day_aggregates WHERE day = ?", (day,)
(day, bw_delta, br_delta), ).fetchone()
) if existing is None:
else: conn.execute(
conn.execute( "INSERT INTO day_aggregates (day, unattributed_bytes_written, unattributed_bytes_read) "
"UPDATE day_aggregates SET unattributed_bytes_written = unattributed_bytes_written + ?, " "VALUES (?, ?, ?)",
"unattributed_bytes_read = unattributed_bytes_read + ? WHERE day = ?", (day, bw_delta, br_delta),
(bw_delta, br_delta, day), )
) else:
conn.commit() conn.execute(
"UPDATE day_aggregates SET unattributed_bytes_written = unattributed_bytes_written + ?, "
"unattributed_bytes_read = unattributed_bytes_read + ? WHERE day = ?",
(bw_delta, br_delta, day),
)
+2 -2
View File
@@ -10,13 +10,13 @@ responsibility shared by the privileged control path and read-only status
composition. composition.
Runit service layout: Runit service layout:
/etc/sv/fenris-collect/run — scheduler (sleep 120; loop { collect; sleep 300 }) /etc/sv/fenris-collect/run — scheduler (sleep 120; loop { collect; sleep 180 })
/etc/sv/fenris-collect/log/run — logger to /var/log/fenris-collect/ /etc/sv/fenris-collect/log/run — logger to /var/log/fenris-collect/
/var/service/fenris-collect — symlink to enable /var/service/fenris-collect — symlink to enable
/etc/sv/fenris-collect/down — marker for dormant install /etc/sv/fenris-collect/down — marker for dormant install
Runit guarantees: Runit guarantees:
- Completion-relative 5-minute cadence (sleep 300 after each collect) - Completion-relative 3-minute cadence (sleep 180 after each collect)
- Initial 2-minute boot delay (sleep 120 before first collect) - Initial 2-minute boot delay (sleep 120 before first collect)
- Bounded execution (90s timeout via timeout(1)) - Bounded execution (90s timeout via timeout(1))
- No catch-up (service sleeps fixed interval, no Persistent= flag) - No catch-up (service sleeps fixed interval, no Persistent= flag)
File diff suppressed because it is too large Load Diff
+1
View File
@@ -69,6 +69,7 @@ def cmd_enable(args: argparse.Namespace) -> None:
open_period = get_open_period(conn) open_period = get_open_period(conn)
if open_period is None: if open_period is None:
ensure_period_open(conn, now) ensure_period_open(conn, now)
conn.commit()
print("Monitoring period opened at", now.isoformat()) print("Monitoring period opened at", now.isoformat())
else: else:
print("Monitoring period already open (id=%d)" % open_period["id"]) print("Monitoring period already open (id=%d)" % open_period["id"])
+1 -2
View File
@@ -16,7 +16,7 @@ def ensure_period_open(conn: sqlite3.Connection, run_time: datetime) -> None:
"""Ensure a monitoring period is open. If none exists, open one at run_time. """Ensure a monitoring period is open. If none exists, open one at run_time.
Spec §9.8: A collection run finding no open monitoring period opens one Spec §9.8: A collection run finding no open monitoring period opens one
at the run moment, never backdated. at the run moment, never backdated. Caller owns the transaction.
""" """
if get_open_period(conn) is not None: if get_open_period(conn) is not None:
return # Already open — no-op return # Already open — no-op
@@ -26,7 +26,6 @@ def ensure_period_open(conn: sqlite3.Connection, run_time: datetime) -> None:
"INSERT INTO monitoring_periods (started_at) VALUES (?)", "INSERT INTO monitoring_periods (started_at) VALUES (?)",
(ts,), (ts,),
) )
conn.commit()
def close_period( def close_period(
+3 -3
View File
@@ -19,8 +19,8 @@ from typing import Any, Dict
PREFERENCE_FILE_NAME = "preferences.json" PREFERENCE_FILE_NAME = "preferences.json"
VALID_THEMES = {"amber", "nord", "high_contrast"} VALID_THEMES = {"chalktone", "amber", "nord", "high_contrast"}
DEFAULT_THEME = "amber" DEFAULT_THEME = "chalktone"
DEFAULT_REDUCED_MOTION = False DEFAULT_REDUCED_MOTION = False
@@ -46,7 +46,7 @@ def load_preferences() -> Dict[str, Any]:
reduced_motion: bool reduced_motion: bool
If the file is missing, corrupt, unreadable, or contains invalid If the file is missing, corrupt, unreadable, or contains invalid
values, returns safe defaults (Amber theme, normal motion). values, returns safe defaults (Chalktone theme, normal motion).
""" """
path = get_preference_path() path = get_preference_path()
+50 -1
View File
@@ -187,7 +187,7 @@ def _wall_clock_in_range(conn, start, end):
def _resolve_baseline(conn, current_segment): def _resolve_baseline(conn, current_segment):
baseline = _get_baseline(conn) baseline = _get_baseline(conn)
facts = [] facts: list[str] = []
if baseline is None: if baseline is None:
return BaselineTier.NONE, None, "no baseline", facts return BaselineTier.NONE, None, "no baseline", facts
@@ -483,6 +483,31 @@ def _build_pu_context_line(conn, rate, days, clock_now):
return "Percentage Used: %d%%" % pu return "Percentage Used: %d%%" % pu
# ---------------------------------------------------------------------------
# Complete observation day gate (issue #94)
# ---------------------------------------------------------------------------
def _has_complete_local_day(conn):
"""Check if at least one complete local observation day exists.
A complete local day is a full midnight-to-midnight calendar day
within a monitoring period that has usable observation evidence.
This is the prerequisite for showing an endurance outlook.
"""
return _count_complete_local_days(conn) > 0
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 "
"AND activity_precision IN ('measured', 'coarse') "
"AND activity_intervals > 0"
).fetchone()
return row[0] if row else 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main projection function # Main projection function
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -499,6 +524,30 @@ def compute_projection(conn, clock_now):
tier, baseline, baseline_label, baseline_facts = _resolve_baseline(conn, current_segment) tier, baseline, baseline_label, baseline_facts = _resolve_baseline(conn, current_segment)
facts.extend(baseline_facts) facts.extend(baseline_facts)
# --- Complete observation day gate (issue #94) ---
# An endurance outlook requires at least one complete local
# midnight-to-midnight calendar day with usable observation evidence.
has_complete_day = _has_complete_local_day(conn)
if not has_complete_day:
facts.append("waiting for a full local observation day")
return ProjectionResult(
confidence_state=ConfidenceState.UNSUPPORTED,
contributing_facts=facts,
headline_remaining_seconds=None,
scenario_range=None,
pu_context_line="Percentage Used: unknown" if _get_latest_pu(conn) is None else "Percentage Used: %d%%" % (_get_latest_pu(conn) or 0),
disclosure_text=list(DISCLOSURES),
baseline_tier=tier,
baseline_label=baseline_label,
regime_days=None,
habit_change_fact=None,
warming_fact=None,
staleness_fact=None,
degraded_identity_fact=None,
zero_rate_fact=None,
qualifying_days_progress=None,
)
segment_days = _get_days_in_segment(conn, current_segment["opened_at"]) if current_segment else _get_all_days(conn) segment_days = _get_days_in_segment(conn, current_segment["opened_at"]) if current_segment else _get_all_days(conn)
all_days = _get_all_days(conn) all_days = _get_all_days(conn)
+360 -139
View File
@@ -1,125 +1,332 @@
"""Raw sample pruning per spec §3.4, ST-5. """Expire detail only after UTC and local_days replacement evidence is durable.
Raw samples are pruned opportunistically to 14 days. The 14-day cutoff never overrides local-day preservation, shared boundary
Hour observations and day aggregates are retained indefinitely. evidence, publication recovery, or the newest sample's successor-anchor role.
Boundary anchors required for successor evidence are retained.
""" """
import sqlite3 import sqlite3
from datetime import datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
# Spec §3.4: Raw-sample retention
RAW_SAMPLE_RETENTION_DAYS = 14 RAW_SAMPLE_RETENTION_DAYS = 14
def _utc_datetime(value: datetime) -> datetime:
"""Normalize a clock value to aware UTC."""
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _parse_sample_time(value: str) -> datetime | None:
"""Parse old timestamps conservatively; malformed legacy values stay."""
try:
parsed = datetime.fromisoformat(value)
except (TypeError, ValueError):
return None
return _utc_datetime(parsed)
def _sample_rows(conn: sqlite3.Connection) -> list[tuple]:
return conn.execute(
"SELECT id, ts, segment_id, bytes_written, bytes_read, local_tz "
"FROM samples ORDER BY id"
).fetchall()
def _required_hour_window(
start: datetime,
end: datetime,
) -> tuple[datetime, datetime, int] | None:
"""Return first hour, exclusive end and count for [start, end)."""
if end <= start:
return None
first_hour = start.replace(minute=0, second=0, microsecond=0)
last_hour = end.replace(minute=0, second=0, microsecond=0)
try:
exclusive_end = last_hour if end == last_hour else last_hour + timedelta(hours=1)
except OverflowError:
return None
count = int((exclusive_end - first_hour).total_seconds() // 3600)
return first_hour, exclusive_end, count
def _has_utc_replacement(
conn: sqlite3.Connection,
previous: tuple,
current: tuple,
start: datetime,
end: datetime,
) -> bool:
window = _required_hour_window(start, end)
if window is None:
return False
first_hour, exclusive_end, expected_hour_count = window
actual_hour_count = conn.execute(
"SELECT COUNT(*) FROM hour_observations WHERE hour >= ? AND hour < ?",
(
first_hour.strftime("%Y-%m-%dT%H:00:00+00:00"),
exclusive_end.strftime("%Y-%m-%dT%H:00:00+00:00"),
),
).fetchone()[0]
if actual_hour_count < expected_hour_count:
return False
start_written, end_written = previous[3], current[3]
start_read, end_read = previous[4], current[4]
if None in (start_written, end_written, start_read, end_read):
return False
if end_written < start_written or end_read < start_read:
return False
bytes_written = end_written - start_written
bytes_read = end_read - start_read
if expected_hour_count == 1:
totals = conn.execute(
"SELECT bytes_written_delta, bytes_read_delta FROM hour_observations "
"WHERE hour = ?",
(first_hour.strftime("%Y-%m-%dT%H:00:00+00:00"),),
).fetchone()
else:
totals = conn.execute(
"SELECT unattributed_bytes_written, unattributed_bytes_read "
"FROM day_aggregates WHERE day = ?",
(start.date().isoformat(),),
).fetchone()
if totals is None or totals[0] < bytes_written or totals[1] < bytes_read:
return False
last_day = (exclusive_end - timedelta(microseconds=1)).date()
expected_day_count = (last_day - first_hour.date()).days + 1
actual_day_count = conn.execute(
"SELECT COUNT(*) FROM day_aggregates WHERE day >= ? AND day <= ?",
(first_hour.date().isoformat(), last_day.isoformat()),
).fetchone()[0]
return actual_day_count >= expected_day_count
def _local_day_row_exists(
conn: sqlite3.Connection,
local_date: str,
tz_name: str,
) -> bool:
return conn.execute(
"SELECT 1 FROM local_days WHERE local_date = ? AND tz_name = ?",
(local_date, tz_name),
).fetchone() is not None
def _local_day_bounds(
conn: sqlite3.Connection,
local_date: str,
tz_name: str,
) -> tuple[datetime, datetime] | None:
row = conn.execute(
"SELECT utc_start, utc_end FROM local_days "
"WHERE local_date = ? AND tz_name = ?",
(local_date, tz_name),
).fetchone()
if row is None:
return None
start = _parse_sample_time(row[0])
end = _parse_sample_time(row[1])
if start is None or end is None or end <= start:
return None
return start, end
def _contains_instant(
conn: sqlite3.Connection,
local_date: str,
tz_name: str,
instant: datetime,
) -> bool:
bounds = _local_day_bounds(conn, local_date, tz_name)
return bounds is not None and bounds[0] <= instant < bounds[1]
def _monitoring_period_covers(
conn: sqlite3.Connection,
start: datetime,
end: datetime,
) -> bool:
"""Require continuous monitoring before a local-day total replaces detail."""
for started_at, ended_at in conn.execute(
"SELECT started_at, ended_at FROM monitoring_periods"
):
period_start = _parse_sample_time(started_at)
period_end = _parse_sample_time(ended_at) if ended_at is not None else None
if period_start is None or period_start > start:
continue
if ended_at is None or (period_end is not None and end <= period_end):
return True
return False
def _has_local_replacement(
conn: sqlite3.Connection,
previous: tuple,
current: tuple,
start: datetime,
end: datetime,
) -> bool:
start_tz = previous[5]
end_tz = current[5]
if not end_tz:
return False
start_id, end_id = previous[0], current[0]
segment_id = current[2]
if (
start_tz
and start_tz == end_tz
and segment_id is not None
and _monitoring_period_covers(conn, start, end)
):
known_days = conn.execute(
"SELECT local_days.utc_start, local_days.utc_end "
"FROM local_days JOIN local_day_segment_totals "
" ON local_day_segment_totals.local_day_id = local_days.id "
"WHERE local_days.tz_name = ? "
" AND local_days.activity_precision IN ('measured', 'coarse') "
" AND local_days.last_sample_id >= ? "
" AND local_days.activity_intervals > 0 "
" AND local_day_segment_totals.segment_id = ? "
" AND local_day_segment_totals.activity_intervals > 0",
(end_tz, end_id, segment_id),
).fetchall()
for row in known_days:
bounds_start = _parse_sample_time(row[0])
bounds_end = _parse_sample_time(row[1])
if (
bounds_start is not None
and bounds_end is not None
and bounds_start <= start
and end < bounds_end
):
return True
evidence = conn.execute(
"SELECT bytes_written, bytes_read, reason, start_local_date, end_local_date, "
" start_tz_name, end_tz_name, started_at, ended_at "
"FROM local_day_unallocated_evidence "
"WHERE start_sample_id = ? AND end_sample_id = ?",
(start_id, end_id),
).fetchone()
if evidence is None:
return False
start_written, end_written = previous[3], current[3]
start_read, end_read = previous[4], current[4]
if None in (start_written, end_written, start_read, end_read):
return False
if end_written < start_written or end_read < start_read:
return False
# The source IDs uniquely identify this interval. Confirm its durable
# deltas and retain every recorded local-day boundary it touches.
if (end_written - start_written, end_read - start_read) != evidence[:2]:
return False
if evidence[5] != start_tz or evidence[6] != end_tz:
return False
if evidence[2] not in {
"counter_discontinuity",
"legacy_timezone_unknown",
"timezone_change",
"monitoring_period",
"local_midnight",
"segment_unknown",
}:
return False
if _parse_sample_time(evidence[7]) != start or _parse_sample_time(evidence[8]) != end:
return False
affected_days: set[tuple[str, str]] = set()
if start_tz:
affected_days.add((evidence[3], start_tz))
affected_days.add((evidence[4], end_tz))
if start_tz and start_tz == end_tz and evidence[3] < evidence[4]:
day = date.fromisoformat(evidence[3]) + timedelta(days=1)
last_day = date.fromisoformat(evidence[4])
while day < last_day:
affected_days.add((day.isoformat(), end_tz))
day += timedelta(days=1)
if not all(
_local_day_row_exists(conn, local_date, zone)
for local_date, zone in affected_days
):
return False
if start_tz and not _contains_instant(conn, evidence[3], start_tz, start):
return False
return _contains_instant(conn, evidence[4], end_tz, end)
def _interval_has_replacement(
conn: sqlite3.Connection,
previous: tuple,
current: tuple,
) -> bool:
start = _parse_sample_time(previous[1])
end = _parse_sample_time(current[1])
if start is None or end is None or end <= start:
return False
try:
return (
_has_utc_replacement(conn, previous, current, start, end)
and _has_local_replacement(conn, previous, current, start, end)
)
except (sqlite3.Error, ValueError, OverflowError, TypeError):
# Missing or unreadable derived evidence never authorizes deletion.
return False
def _sample_needs_preservation(
conn: sqlite3.Connection,
rows: list[tuple],
index: int,
newest_id: int,
) -> bool:
sample = rows[index]
segment_id = sample[2]
if segment_id is None or conn.execute(
"SELECT 1 FROM controller_segments WHERE id = ?",
(segment_id,),
).fetchone() is None:
return True
if sample[0] == newest_id:
# Keep newest sample as the source anchor for the next collection.
return True
pairs = []
if index > 0 and rows[index - 1][2] == segment_id:
pairs.append((rows[index - 1], sample))
if index + 1 < len(rows) and rows[index + 1][2] == segment_id:
pairs.append((sample, rows[index + 1]))
return any(
not _interval_has_replacement(conn, previous, current)
for previous, current in pairs
)
def needs_boundary_anchor( def needs_boundary_anchor(
conn: sqlite3.Connection, conn: sqlite3.Connection,
sample_ts: str, sample_ts: str,
now: datetime, now: datetime,
) -> bool: ) -> bool:
"""Check if a sample is needed as a boundary anchor for derivation. """Return whether an old sample still carries unreplaced evidence."""
sample_time = _parse_sample_time(sample_ts)
A sample is a boundary anchor if: cutoff = _utc_datetime(now) - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
1. It's older than retention_days (strictly before cutoff) if sample_time is None:
2. It has a next sample that forms an interval spanning the retention boundary return True
3. The interval hasn't been derived yet if sample_time >= cutoff:
The interval spans the boundary if:
- The sample is before the cutoff, AND
- The next sample is strictly after the cutoff (or within retention)
"""
from .derive import _parse_ts
sample_dt = _parse_ts(sample_ts)
retention_cutoff = now - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
# If sample is within retention (strictly after cutoff), not an anchor
if sample_dt > retention_cutoff:
return False return False
# Check if this sample has a next sample rows = _sample_rows(conn)
cursor = conn.execute( matching = [index for index, row in enumerate(rows) if row[1] == sample_ts]
"""SELECT ts, segment_id FROM samples WHERE ts > ? ORDER BY ts LIMIT 1""", if not matching:
(sample_ts,), return False
newest_id = rows[-1][0] if rows else -1
return any(
_sample_needs_preservation(conn, rows, index, newest_id)
for index in matching
) )
next_row = cursor.fetchone()
if next_row is None:
# No next sample - this is the last sample
# It's not needed for derivation (no interval to derive)
return False
next_ts_str = next_row[0]
next_segment_id = next_row[1]
next_dt = _parse_ts(next_ts_str)
# Check if the next sample is strictly after the cutoff (i.e., interval spans boundary)
if next_dt > retention_cutoff:
# The interval spans the retention boundary
# Check if the interval needs derivation
# Get current sample's segment_id
cursor = conn.execute(
"SELECT segment_id FROM samples WHERE ts = ?",
(sample_ts,),
)
current_segment_row = cursor.fetchone()
current_segment_id = current_segment_row[0] if current_segment_row else None
# If different segments, no interval to derive
if current_segment_id != next_segment_id:
return False
# Check if the interval [sample_ts, next_ts] needs derivation
# It needs derivation if any hour in the span lacks an observation
current_hour = sample_dt.replace(minute=0, second=0, microsecond=0)
end_hour = next_dt.replace(minute=0, second=0, microsecond=0)
while current_hour <= end_hour:
cursor = conn.execute(
"SELECT id FROM hour_observations WHERE hour = ?",
(current_hour.isoformat(),),
)
if cursor.fetchone() is None:
# This hour lacks an observation - interval needs derivation
return True
current_hour += timedelta(hours=1)
# All hours in the span have observations - interval is derived
return False
else:
# The interval doesn't span the boundary (both samples are old)
# Check if the interval needs derivation
# Get current sample's segment_id
cursor = conn.execute(
"SELECT segment_id FROM samples WHERE ts = ?",
(sample_ts,),
)
current_segment_row = cursor.fetchone()
current_segment_id = current_segment_row[0] if current_segment_row else None
# If different segments, no interval to derive
if current_segment_id != next_segment_id:
return False
# Check if the interval [sample_ts, next_ts] needs derivation
current_hour = sample_dt.replace(minute=0, second=0, microsecond=0)
end_hour = next_dt.replace(minute=0, second=0, microsecond=0)
while current_hour <= end_hour:
cursor = conn.execute(
"SELECT id FROM hour_observations WHERE hour = ?",
(current_hour.isoformat(),),
)
if cursor.fetchone() is None:
# This hour lacks an observation - interval needs derivation
# But only keep if the interval is significant (spans multiple hours)
# or if the next sample is the last sample before a gap
gap = (next_dt - sample_dt).total_seconds()
if gap > 24 * 3600: # Significant gap (> 24 hours)
return True
current_hour += timedelta(hours=1)
# All hours in the span have observations or gap is not significant
return False
def prune_old_samples( def prune_old_samples(
@@ -127,36 +334,50 @@ def prune_old_samples(
now: datetime, now: datetime,
retention_days: int = RAW_SAMPLE_RETENTION_DAYS, retention_days: int = RAW_SAMPLE_RETENTION_DAYS,
) -> int: ) -> int:
"""Remove raw samples older than retention_days. """Delete expired samples only after every dependent fact is durable.
Retains boundary anchors required for successor evidence. Pending publications are stored separately and never selected here. A
sample stays when it is the newest successor anchor, has an unpublishable
Args: neighbour interval, lacks UTC or local-day replacement evidence, or has a
conn: Connection to the observation store. timestamp that cannot be safely interpreted.
now: Current UTC time.
retention_days: Number of days to retain (default 14).
Returns:
Number of samples removed.
""" """
cutoff = (now - timedelta(days=retention_days)).isoformat() cutoff = _utc_datetime(now) - timedelta(days=retention_days)
owns_transaction = not conn.in_transaction
# Get all samples older than cutoff savepoint = "fenris_sample_retention"
cursor = conn.execute( if owns_transaction:
"SELECT id, ts FROM samples WHERE ts < ? ORDER BY ts", conn.execute("BEGIN IMMEDIATE")
(cutoff,), else:
) conn.execute(f"SAVEPOINT {savepoint}")
old_samples = cursor.fetchall()
try:
removed = 0 rows = _sample_rows(conn)
for sample_id, sample_ts in old_samples: if not rows:
# Check if this sample is a boundary anchor if owns_transaction:
if needs_boundary_anchor(conn, sample_ts, now): conn.commit()
continue # Skip - it's a boundary anchor else:
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
# Remove the sample return 0
conn.execute("DELETE FROM samples WHERE id = ?", (sample_id,))
removed += 1 newest_id = rows[-1][0]
expired = []
conn.commit() for index, row in enumerate(rows):
return removed sample_time = _parse_sample_time(row[1])
if sample_time is None or sample_time >= cutoff:
continue
if _sample_needs_preservation(conn, rows, index, newest_id):
continue
expired.append(row[0])
conn.executemany("DELETE FROM samples WHERE id = ?", ((sample_id,) for sample_id in expired))
if owns_transaction:
conn.commit()
else:
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
return len(expired)
except Exception:
if owns_transaction:
conn.rollback()
else:
conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
raise
+26 -26
View File
@@ -1,8 +1,9 @@
"""Historical repair and retention (issue #74). """Historical repair and retention (issue #74, #99).
Re-derives hour observations and day aggregates from surviving raw samples, Re-derives hour observations and day aggregates from surviving raw samples,
with idempotent and interruption-safe guarantees. Preserves import markers, rebuilds legacy local-day evidence from surviving samples or trustworthy UTC
boundary anchors, and valid historical summaries. hours, and preserves import markers, boundary anchors, and valid summaries.
Repair is idempotent and interruption-safe.
Contracts: Contracts:
- Idempotent: running repair multiple times produces no duplicates - Idempotent: running repair multiple times produces no duplicates
@@ -71,7 +72,6 @@ def _set_repair_in_progress(conn: sqlite3.Connection, in_progress: bool) -> None
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)", "INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
("repair_in_progress", "true" if in_progress else "false"), ("repair_in_progress", "true" if in_progress else "false"),
) )
conn.commit()
def _update_repair_status(conn: sqlite3.Connection, result: RepairResult) -> None: def _update_repair_status(conn: sqlite3.Connection, result: RepairResult) -> None:
@@ -100,8 +100,6 @@ def _update_repair_status(conn: sqlite3.Connection, result: RepairResult) -> Non
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)", "INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
("days_derived", str(days)), ("days_derived", str(days)),
) )
conn.commit()
def get_repair_status(conn: sqlite3.Connection) -> RepairStatus: def get_repair_status(conn: sqlite3.Connection) -> RepairStatus:
@@ -364,20 +362,16 @@ def repair_derivation(
elif hasattr(clock, 'utcnow'): elif hasattr(clock, 'utcnow'):
clock = clock.utcnow() clock = clock.utcnow()
# Check if repair is already in progress
if is_repair_in_progress(conn):
return RepairResult(
ok=False,
error="Repair already in progress",
)
# Mark repair as in progress
_set_repair_in_progress(conn, True)
result = RepairResult(ok=True) result = RepairResult(ok=True)
try: try:
# Begin transaction # A committed in-progress marker may be stale after process death.
# SQLite's write lock serializes active repairs; retry idempotently.
if conn.in_transaction:
conn.commit()
conn.execute("BEGIN IMMEDIATE")
_set_repair_in_progress(conn, True)
conn.commit()
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
# 1. Find and derive intervals from sample pairs # 1. Find and derive intervals from sample pairs
@@ -407,7 +401,12 @@ def repair_derivation(
result.days_created += 1 result.days_created += 1
else: else:
result.days_updated += 1 result.days_updated += 1
# Rebuild only legacy or unavailable local-day activity. This shares
# the migration derivation and leaves trustworthy published totals intact.
from .local_day import repair_legacy_local_day_evidence
repair_legacy_local_day_evidence(conn)
# 3. Count boundary anchors retained # 3. Count boundary anchors retained
now = clock if isinstance(clock, datetime) else datetime.now(timezone.utc) now = clock if isinstance(clock, datetime) else datetime.now(timezone.utc)
cursor = conn.execute("SELECT ts FROM samples ORDER BY ts") cursor = conn.execute("SELECT ts FROM samples ORDER BY ts")
@@ -428,21 +427,22 @@ def repair_derivation(
) )
result.legacy_summaries_preserved = cursor.fetchone()[0] result.legacy_summaries_preserved = cursor.fetchone()[0]
# Commit transaction # Publish derived rows and completion status together. A process
conn.commit() # interruption rolls back the in-progress marker with the repair.
# Update repair status
_update_repair_status(conn, result) _update_repair_status(conn, result)
_set_repair_in_progress(conn, False)
conn.commit()
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
try:
_set_repair_in_progress(conn, False)
conn.commit()
except sqlite3.Error:
conn.rollback()
logger.error("Repair failed: %s", e) logger.error("Repair failed: %s", e)
return RepairResult( return RepairResult(
ok=False, ok=False,
error=str(e), error=str(e),
) )
finally:
# Mark repair as complete
_set_repair_in_progress(conn, False)
return result return result
+2 -2
View File
@@ -35,9 +35,9 @@ from .init_system import (
# Freshness constants (§8.9, §8.2) # Freshness constants (§8.9, §8.2)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CADENCE_DEFAULT_S = 300 # 5 min CADENCE_DEFAULT_S = 180 # 3 min
ACCURACY_SEC = 30 ACCURACY_SEC = 30
FRESH_THRESHOLD_S = 2 * CADENCE_DEFAULT_S + ACCURACY_SEC + 60 # 690 s FRESH_THRESHOLD_S = 2 * CADENCE_DEFAULT_S + ACCURACY_SEC + 60 # 450 s
STALENESS_THRESHOLD_S = 48 * 3600 # 48 h STALENESS_THRESHOLD_S = 48 * 3600 # 48 h
+17
View File
@@ -115,6 +115,7 @@ class StatusComposition:
# Sample counts for waiting explanations # Sample counts for waiting explanations
sample_count: int = 0 sample_count: int = 0
day_count: int = 0 day_count: int = 0
pending_publication_count: int = 0
# Whether the status dot should blink (only Monitoring) # Whether the status dot should blink (only Monitoring)
should_blink: bool = False should_blink: bool = False
@@ -286,6 +287,7 @@ def compose_status(
freshness_age_s = None freshness_age_s = None
sample_count = 0 sample_count = 0
day_count = 0 day_count = 0
pending_publication_count = 0
deliberately_paused = False deliberately_paused = False
if conn is None and store_fault is None and newer_schema is None: if conn is None and store_fault is None and newer_schema is None:
@@ -315,6 +317,10 @@ def compose_status(
sample_count = cursor.fetchone()[0] sample_count = cursor.fetchone()[0]
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates") cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
day_count = cursor.fetchone()[0] day_count = cursor.fetchone()[0]
schema_version = conn.execute("PRAGMA user_version").fetchone()[0]
if schema_version >= 4:
cursor = conn.execute("SELECT COUNT(*) FROM pending_publications")
pending_publication_count = cursor.fetchone()[0]
except sqlite3.Error as exc: except sqlite3.Error as exc:
store_fault = str(exc) store_fault = str(exc)
@@ -328,6 +334,7 @@ def compose_status(
freshness = "unknown" freshness = "unknown"
freshness_age_s = None freshness_age_s = None
sample_count = day_count = 0 sample_count = day_count = 0
pending_publication_count = 0
deliberately_paused = False deliberately_paused = False
# --- External stop detection --- # --- External stop detection ---
@@ -417,6 +424,7 @@ def compose_status(
overlay_base=overlay_base, overlay_base=overlay_base,
sample_count=sample_count, sample_count=sample_count,
day_count=day_count, day_count=day_count,
pending_publication_count=pending_publication_count,
continuity=continuity, continuity=continuity,
paused_lines=paused_lines, paused_lines=paused_lines,
) )
@@ -459,6 +467,8 @@ def render_status_cli(comp: StatusComposition) -> str:
facts.append("timer: %s" % ( facts.append("timer: %s" % (
"unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive" "unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive"
)) ))
if comp.pending_publication_count:
facts.append(_pending_publication_text(comp.pending_publication_count))
if facts: if facts:
lines.append(" · ".join(facts)) lines.append(" · ".join(facts))
@@ -524,6 +534,8 @@ def render_status_tui(comp: StatusComposition) -> str:
facts.append("Timer: %s" % ( facts.append("Timer: %s" % (
"unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive" "unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive"
)) ))
if comp.pending_publication_count:
facts.append(_pending_publication_text(comp.pending_publication_count).capitalize())
if facts: if facts:
lines.append(" · ".join(facts)) lines.append(" · ".join(facts))
@@ -539,3 +551,8 @@ def render_status_tui(comp: StatusComposition) -> str:
lines.append(pl[:1].upper() + pl[1:]) lines.append(pl[:1].upper() + pl[1:])
return "\n".join(lines) return "\n".join(lines)
def _pending_publication_text(count: int) -> str:
noun = "observation" if count == 1 else "observations"
return f"pending publication: {count} {noun} retained for retry"
+213 -34
View File
@@ -3,8 +3,7 @@
This module handles: This module handles:
- Store initialization with WAL mode - Store initialization with WAL mode
- Schema versioning with PRAGMA user_version - Schema versioning with PRAGMA user_version
- The six entities: samples, hour_observations, day_aggregates, - Observation records, derived activity, publication state, and metadata
monitoring_periods, controller_segments, endurance_baseline
""" """
import sqlite3 import sqlite3
from pathlib import Path from pathlib import Path
@@ -12,7 +11,7 @@ from typing import Optional
# Schema version - increment on each migration # Schema version - increment on each migration
SCHEMA_VERSION = 2 SCHEMA_VERSION = 6
# Packaged default placement (spec §8.3). The config may override it, but a # Packaged default placement (spec §8.3). The config may override it, but a
@@ -33,7 +32,7 @@ def get_store_path(config: dict) -> Path:
def init_store(store_path: Path) -> sqlite3.Connection: def init_store(store_path: Path) -> sqlite3.Connection:
"""Initialize the observation store if not present. """Initialize the observation store if not present.
Creates the database with WAL mode and all six entities. Creates the observation schema, including private pending-publication storage.
Returns a connection to the store. Returns a connection to the store.
""" """
conn = sqlite3.connect(str(store_path)) conn = sqlite3.connect(str(store_path))
@@ -73,9 +72,11 @@ def init_store(store_path: Path) -> sqlite3.Connection:
) )
elif current_version < SCHEMA_VERSION: elif current_version < SCHEMA_VERSION:
# Older version - apply migrations # Older version - apply migrations
_apply_migrations(conn, current_version) try:
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") _apply_migrations(conn, current_version)
conn.commit() except Exception:
conn.close()
raise
return conn return conn
@@ -107,7 +108,8 @@ def _create_schema(conn: sqlite3.Connection):
bytes_written INTEGER, bytes_written INTEGER,
bytes_read INTEGER, bytes_read INTEGER,
critical_warning INTEGER, critical_warning INTEGER,
segment_id INTEGER segment_id INTEGER,
local_tz TEXT
) )
""") """)
@@ -193,6 +195,34 @@ 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,
activity_seconds INTEGER NOT NULL DEFAULT 0,
activity_intervals INTEGER NOT NULL DEFAULT 0,
activity_incomplete BOOLEAN NOT NULL DEFAULT 0,
activity_precision TEXT NOT NULL DEFAULT 'measured',
last_sample_id INTEGER,
UNIQUE(local_date, tz_name)
)
""")
_create_local_day_shared_evidence(conn)
_create_local_day_segment_totals(conn)
# Metadata table for store state (e.g., legacy import marker) # Metadata table for store state (e.g., legacy import marker)
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS store_metadata ( CREATE TABLE IF NOT EXISTS store_metadata (
@@ -201,34 +231,181 @@ def _create_schema(conn: sqlite3.Connection):
) )
""") """)
_create_pending_publications(conn)
def _create_pending_publications(conn: sqlite3.Connection) -> None:
"""Create private staging for valid observations awaiting derivation."""
conn.execute("""
CREATE TABLE IF NOT EXISTS pending_publications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sample_ts TEXT NOT NULL,
payload TEXT NOT NULL
)
""")
def _create_local_day_shared_evidence(conn: sqlite3.Connection) -> None:
"""Create once-only local activity evidence that cannot be day-allocated."""
conn.execute("""
CREATE TABLE IF NOT EXISTS local_day_unallocated_evidence (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_sample_id INTEGER NOT NULL,
end_sample_id INTEGER NOT NULL,
start_local_date TEXT NOT NULL,
end_local_date TEXT NOT NULL,
start_tz_name TEXT,
end_tz_name TEXT,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
bytes_written INTEGER NOT NULL DEFAULT 0,
bytes_read INTEGER NOT NULL DEFAULT 0,
reason TEXT NOT NULL,
segment_id INTEGER,
UNIQUE(start_sample_id, end_sample_id)
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS local_day_evidence_start "
"ON local_day_unallocated_evidence(start_local_date, start_tz_name)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS local_day_evidence_end "
"ON local_day_unallocated_evidence(end_local_date, end_tz_name)"
)
def _create_local_day_segment_totals(conn: sqlite3.Connection) -> None:
"""Retain the controller-segment provenance behind known day totals."""
conn.execute("""
CREATE TABLE IF NOT EXISTS local_day_segment_totals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
local_day_id INTEGER NOT NULL,
segment_id INTEGER NOT NULL,
bytes_written INTEGER NOT NULL DEFAULT 0,
bytes_read INTEGER NOT NULL DEFAULT 0,
activity_seconds INTEGER NOT NULL DEFAULT 0,
activity_intervals INTEGER NOT NULL DEFAULT 0,
UNIQUE(local_day_id, segment_id)
)
""")
def _apply_migrations(conn: sqlite3.Connection, current_version: int): def _apply_migrations(conn: sqlite3.Connection, current_version: int):
"""Apply forward-only migrations from current_version to SCHEMA_VERSION. """Apply forward-only migrations from current_version to SCHEMA_VERSION.
Each migration step is a transactional block. Add new steps as sequential Commit each version transition independently. A failed step rolls back in
elif branches when SCHEMA_VERSION increases. full while earlier successful steps remain versioned and retryable.
Spec: §3.6, §10.2
""" """
# Migration 1→2: add segment_id provenance to samples, migrations = {
# unattributed byte tracking to day_aggregates (issue #73) 2: _migrate_1_to_2,
if current_version < 2: 3: _migrate_2_to_3,
# Defensive: only ALTER if table exists (handles minimal v1 stores) 4: _migrate_3_to_4,
tables = {row[0] for row in conn.execute( 5: _migrate_4_to_5,
"SELECT name FROM sqlite_master WHERE type='table'" 6: _migrate_5_to_6,
}
while current_version < SCHEMA_VERSION:
target_version = current_version + 1
migration = migrations.get(target_version)
if migration is None:
raise ValueError(f"No migration registered for schema {target_version}")
conn.execute("BEGIN IMMEDIATE")
try:
migration(conn)
conn.execute(f"PRAGMA user_version={target_version}")
conn.commit()
except Exception:
conn.rollback()
raise
current_version = target_version
def _migrate_1_to_2(conn: sqlite3.Connection) -> None:
"""Add segment provenance and unattributed UTC byte tracking."""
tables = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "samples" in tables:
cols = {row[1] for row in conn.execute(
"PRAGMA table_info(samples)"
).fetchall()} ).fetchall()}
if "samples" in tables: if "segment_id" not in cols:
# Check if column already exists (idempotent) conn.execute("ALTER TABLE samples ADD COLUMN segment_id INTEGER")
cols = {row[1] for row in conn.execute("PRAGMA table_info(samples)").fetchall()} if "day_aggregates" in tables:
if "segment_id" not in cols: cols = {row[1] for row in conn.execute(
conn.execute("ALTER TABLE samples ADD COLUMN segment_id INTEGER") "PRAGMA table_info(day_aggregates)"
if "day_aggregates" in tables: ).fetchall()}
cols = {row[1] for row in conn.execute("PRAGMA table_info(day_aggregates)").fetchall()} for column in ("unattributed_bytes_written", "unattributed_bytes_read"):
if "unattributed_bytes_written" not in cols: if column not in cols:
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_written INTEGER DEFAULT 0") conn.execute(
if "unattributed_bytes_read" not in cols: f"ALTER TABLE day_aggregates ADD COLUMN {column} INTEGER DEFAULT 0"
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0") )
current_version = 2
def _migrate_2_to_3(conn: sqlite3.Connection) -> None:
"""Add local-day activity summaries."""
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 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)
)
""")
def _migrate_3_to_4(conn: sqlite3.Connection) -> None:
"""Add private publication staging for acquired observations."""
_create_pending_publications(conn)
def _migrate_4_to_5(conn: sqlite3.Connection) -> None:
"""Add measured local-day evidence storage and mark old totals legacy."""
tables = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "samples" in tables:
sample_cols = {row[1] for row in conn.execute(
"PRAGMA table_info(samples)"
).fetchall()}
if "local_tz" not in sample_cols:
conn.execute("ALTER TABLE samples ADD COLUMN local_tz TEXT")
if "local_days" in tables:
local_cols = {row[1] for row in conn.execute(
"PRAGMA table_info(local_days)"
).fetchall()}
for column, declaration in (
("activity_seconds", "INTEGER NOT NULL DEFAULT 0"),
("activity_intervals", "INTEGER NOT NULL DEFAULT 0"),
("activity_incomplete", "BOOLEAN NOT NULL DEFAULT 0"),
("activity_precision", "TEXT NOT NULL DEFAULT 'legacy'"),
("last_sample_id", "INTEGER"),
):
if column not in local_cols:
conn.execute(
f"ALTER TABLE local_days ADD COLUMN {column} {declaration}"
)
_create_local_day_shared_evidence(conn)
_create_local_day_segment_totals(conn)
def _migrate_5_to_6(conn: sqlite3.Connection) -> None:
"""Rebuild local-day summaries from surviving trustworthy evidence."""
from .local_day import repair_legacy_local_day_evidence
repair_legacy_local_day_evidence(conn)
def migrate_to_latest(store_path: Path) -> int: def migrate_to_latest(store_path: Path) -> int:
@@ -265,9 +442,11 @@ def migrate_to_latest(store_path: Path) -> int:
return SCHEMA_VERSION return SCHEMA_VERSION
steps = SCHEMA_VERSION - current_version steps = SCHEMA_VERSION - current_version
_apply_migrations(conn, current_version) try:
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") _apply_migrations(conn, current_version)
conn.commit() except Exception:
conn.close()
raise
conn.close() conn.close()
return steps return steps
+20 -4
View File
@@ -1,6 +1,6 @@
"""Fenris theme presets (issue #80). """Fenris theme presets (issue #80).
Three accessible colour presets: Amber (default), Nord, and High Contrast. Chalktone-inspired default plus Amber, Nord, and High Contrast.
Themes style chrome, borders, accents, muted text, and graph roles; Themes style chrome, borders, accents, muted text, and graph roles;
status semantic colours/glyphs/text always win. status semantic colours/glyphs/text always win.
@@ -126,7 +126,22 @@ _HIGH_CONTRAST = Theme(
# Theme registry # Theme registry
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_CHALKTONE = Theme(
name="fenris-chalktone",
primary="#abc4b3", secondary="#c9b69a", accent="#dfc49a",
warning="#e9bc79", error="#e58d89", success="#acd29c",
foreground="#e0d8c5", background="#202426", surface="#202426",
panel="#252a2c", boost="#333b3d", dark=True,
variables={
"graph-allocated": "#abc4b3", "graph-unallocated": "#dfc49a",
"graph-gap": "#e58d89", "graph-zero": "#a7b1a9",
"graph-partial": "#e9bc79", "graph-selection": "#f3dbb0",
"border-default": "#606d6a", "muted-text": "#a7b1a9",
},
)
THEMES = { THEMES = {
"chalktone": _CHALKTONE,
"amber": _AMBER, "amber": _AMBER,
"nord": _NORD, "nord": _NORD,
"high_contrast": _HIGH_CONTRAST, "high_contrast": _HIGH_CONTRAST,
@@ -138,16 +153,16 @@ THEME_NAMES = set(THEMES.keys())
def get_theme(name: str) -> Theme: def get_theme(name: str) -> Theme:
"""Return a registered theme by preset name. """Return a registered theme by preset name.
Unknown names fall back to Amber. Unknown names fall back to Chalktone.
""" """
return THEMES.get(name, _AMBER) return THEMES.get(name, _CHALKTONE)
def get_graph_colors(theme_name: str) -> Dict[str, str]: def get_graph_colors(theme_name: str) -> Dict[str, str]:
"""Return the graph colour roles for a theme preset. """Return the graph colour roles for a theme preset.
Returns a dict with keys: allocated, unallocated, gap, zero, partial, Returns a dict with keys: allocated, unallocated, gap, zero, partial,
selection. Falls back to Amber for unknown names. selection and muted text. Falls back to Chalktone for unknown names.
""" """
theme = get_theme(theme_name) theme = get_theme(theme_name)
variables = theme.variables or {} variables = theme.variables or {}
@@ -158,4 +173,5 @@ def get_graph_colors(theme_name: str) -> Dict[str, str]:
"zero": variables.get("graph-zero", "#665533"), "zero": variables.get("graph-zero", "#665533"),
"partial": variables.get("graph-partial", "#aa8822"), "partial": variables.get("graph-partial", "#aa8822"),
"selection": variables.get("graph-selection", "#ffd54f"), "selection": variables.get("graph-selection", "#ffd54f"),
"muted": variables.get("muted-text", "#a7b1a9"),
} }
+1049 -395
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
"""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_path = Path(os.readlink(str(localtime)))
if not target_path.is_absolute():
target_path = localtime.parent / target_path
target = target_path.resolve().as_posix()
marker = "/zoneinfo/"
marker_index = target.find(marker)
if marker_index >= 0:
return target[marker_index + len(marker):]
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)
+37 -1
View File
@@ -109,12 +109,40 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start)) ensure_period_open(conn, datetime.fromisoformat(start))
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
utc_start=None, utc_end=None, bw=1024*1024*100,
br=0, coverage=0.95, samples=24, complete=True):
"""Insert a local_days row (issue #94 gate prerequisite)."""
if utc_start is None:
utc_start = local_date + "T00:00:00+00:00"
if utc_end is None:
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
conn.commit()
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
"""Insert multiple complete local days to satisfy the issue #94 gate."""
for i in range(count):
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_local_day(conn, d, bw=bw)
def _setup_full_store(conn, *, baseline=True, segment=True, days=30, def _setup_full_store(conn, *, baseline=True, segment=True, days=30,
bw=1024*1024*100, coverage=0.95, samples_per_day=24, bw=1024*1024*100, coverage=0.95, samples_per_day=24,
sample_ts="2026-09-30T10:00:00+00:00", sample_ts="2026-09-30T10:00:00+00:00",
period_start="2026-09-01T00:00:00+00:00", period_start="2026-09-01T00:00:00+00:00",
segment_opened="2026-09-01T00:00:00+00:00", segment_opened="2026-09-01T00:00:00+00:00",
baseline_kw=None, segment_kw=None): baseline_kw=None, segment_kw=None,
local_days=True):
if baseline: if baseline:
_insert_baseline(conn, **(baseline_kw or {})) _insert_baseline(conn, **(baseline_kw or {}))
if segment: if segment:
@@ -125,6 +153,9 @@ def _setup_full_store(conn, *, baseline=True, segment=True, days=30,
_insert_day(conn, d, bw=bw, coverage=coverage, samples=samples_per_day) _insert_day(conn, d, bw=bw, coverage=coverage, samples=samples_per_day)
if sample_ts: if sample_ts:
_insert_sample(conn, sample_ts) _insert_sample(conn, sample_ts)
# Issue #94: satisfy the complete-observation-day gate
if local_days and days > 0:
_insert_complete_local_days(conn, "2026-09-29", 1, bw=bw)
# =================================================================== # ===================================================================
@@ -217,6 +248,7 @@ class TestCI1StateMatrix:
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100) _insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00") _insert_sample(conn, "2026-09-30T10:00:00+00:00")
_insert_complete_local_days(conn, "2026-09-29", 1)
proj = compute_projection(conn, _clock()) proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.LIMITED assert proj.confidence_state == ConfidenceState.LIMITED
conn.close() conn.close()
@@ -230,6 +262,7 @@ class TestCI1StateMatrix:
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24) _insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
_insert_sample(conn, "2026-09-30T10:00:00+00:00") _insert_sample(conn, "2026-09-30T10:00:00+00:00")
_insert_complete_local_days(conn, "2026-09-29", 1)
proj = compute_projection(conn, _clock()) proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.LIMITED assert proj.confidence_state == ConfidenceState.LIMITED
assert proj.warming_fact is not None assert proj.warming_fact is not None
@@ -245,6 +278,7 @@ class TestCI1StateMatrix:
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24) _insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
stale_ts = (_clock() - timedelta(days=5)).isoformat() stale_ts = (_clock() - timedelta(days=5)).isoformat()
_insert_sample(conn, stale_ts) _insert_sample(conn, stale_ts)
_insert_complete_local_days(conn, "2026-08-30", 1)
proj = compute_projection(conn, _clock()) proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.LIMITED assert proj.confidence_state == ConfidenceState.LIMITED
assert proj.staleness_fact is not None assert proj.staleness_fact is not None
@@ -259,6 +293,7 @@ class TestCI1StateMatrix:
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24) _insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
_insert_sample(conn, "2026-09-30T10:00:00+00:00") _insert_sample(conn, "2026-09-30T10:00:00+00:00")
_insert_complete_local_days(conn, "2026-09-29", 1)
proj = compute_projection(conn, _clock()) proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.LIMITED assert proj.confidence_state == ConfidenceState.LIMITED
assert proj.degraded_identity_fact is not None assert proj.degraded_identity_fact is not None
@@ -273,6 +308,7 @@ class TestCI1StateMatrix:
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=0) _insert_day(conn, d, bw=0)
_insert_sample(conn, "2026-09-30T10:00:00+00:00") _insert_sample(conn, "2026-09-30T10:00:00+00:00")
_insert_complete_local_days(conn, "2026-09-29", 1)
proj = compute_projection(conn, _clock()) proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.UNSUPPORTED assert proj.confidence_state == ConfidenceState.UNSUPPORTED
assert proj.zero_rate_fact is not None assert proj.zero_rate_fact is not None
+52
View File
@@ -0,0 +1,52 @@
"""Visible geometry and evidence boundaries of the terminal volume plot."""
from fenris.activity_plot import VolumePoint, volume_plot
from fenris.themes import get_graph_colors
def plot(points, selected=-1, width=48, height=10):
return volume_plot(points, width, height, selected, get_graph_colors("chalktone"))
def test_trace_fits_viewport_and_uses_time_not_sample_index():
points = [VolumePoint(t, v, str(t)) for t, v in ((0, 0), (3, 50), (30, 100))]
text, columns, unit = plot(points)
assert unit == "B"
assert len(text.plain.splitlines()) == 10
assert all(len(line) <= 48 for line in text.plain.splitlines())
assert columns[1] - columns[0] < (columns[2] - columns[0]) / 5
assert any(0x2801 <= ord(c) <= 0x28ff for c in text.plain)
assert not any(c in text.plain for c in "█▒░")
assert "100.00" in text.plain and "0.00" in text.plain
def test_gap_breaks_trace_while_measured_zero_stays_on_axis():
text, columns, _ = plot([
VolumePoint(0, 0, "00:00"), VolumePoint(1, None, "00:03", "gap"),
VolumePoint(2, 100, "00:06"),
])
rows = text.plain.splitlines()
assert "?" in rows[-2]
# No invented intermediate dots on either side of the missing measurement.
for row in rows[:-2]:
assert all(c == " " for c in row[columns[0] + 1:columns[-1]])
assert 0x2801 <= ord(rows[-3][columns[0]]) <= 0x28ff
def test_partial_and_unallocated_totals_are_isolated_and_labelled():
text, columns, unit = plot([
VolumePoint(0, 1_000_000, "01", "partial"),
VolumePoint(1, 2_000_000, "02", "unallocated"),
VolumePoint(2, 3_000_000, "03"),
])
assert unit == "MB"
assert "~" in text.plain and "u" in text.plain
for row in text.plain.splitlines()[:-2]:
assert all(c == " " for c in row[columns[0] + 1:columns[1]])
def test_many_intervals_and_selection_fit_small_plot_without_losing_points():
points = [VolumePoint(i, i % 9 * 1_000_000, str(i)) for i in range(180)]
text, columns, _ = plot(points, selected=100, width=32, height=6)
assert len(columns) == len(points)
assert text.plain.splitlines()[-2][columns[100]] == "▼"
assert all(len(row) == 32 for row in text.plain.splitlines())
+14
View File
@@ -79,6 +79,20 @@ def test_release_footer_verifies_the_clearsigned_checksum_asset():
assert "sha256sum -c SHA256SUMS" in footer assert "sha256sum -c SHA256SUMS" in footer
def test_release_footer_configures_the_xbps_url_as_a_repository():
footer = (REPO_ROOT / "packaging" / "release-footer.md").read_text(
encoding="utf-8"
)
repository = (
"repository=https://git.bongbetic.com/xavierk/Fenris-xbps/"
"raw/branch/stable/x86_64"
)
assert repository in footer
assert "sudo xbps-install -M -S fenris" in footer
assert "sudo xbps-install -S https://git.bongbetic.com" not in footer
@pytest.mark.parametrize( @pytest.mark.parametrize(
("changelog", "expected_error"), ("changelog", "expected_error"),
[ [
+162 -1
View File
@@ -154,7 +154,7 @@ class TestSchemaMigration:
# Migrate # Migrate
steps = migrate_to_latest(db) steps = migrate_to_latest(db)
assert steps == 1 assert steps == 5 # v1→v2→v3→v4→v5→v6
# Verify data preserved # Verify data preserved
conn = sqlite3.connect(str(db)) conn = sqlite3.connect(str(db))
@@ -356,6 +356,167 @@ class TestCollectorDerivation:
assert count_after == 2 assert count_after == 2
class TestCollectionAtomicity:
"""Collection exposes sample and derived evidence as one publication."""
@pytest.mark.asyncio
async def test_collection_is_visible_through_cli_and_tui_readers(
self, config_fixture, smartctl_fixture, sysfs_fixture_tree, monkeypatch
):
"""Ordinary readers observe matching published sample and activity evidence."""
from fenris.status import get_status, read_status
from fenris.tui import FenrisTuiApp
monkeypatch.setenv("TZ", "UTC")
service = {
"boot_enabled": True,
"timer_active": True,
"last_collect_ok": True,
}
monkeypatch.setattr("fenris.status.query_service_state", lambda: service)
now = datetime.now(timezone.utc).replace(
minute=35, second=0, microsecond=0,
)
first = {
**smartctl_fixture,
"nvme_smart_health_information_log": {
**smartctl_fixture["nvme_smart_health_information_log"],
"data_units_written": 12345678,
"data_units_read": 9876543,
},
}
second = {
**smartctl_fixture,
"nvme_smart_health_information_log": {
**smartctl_fixture["nvme_smart_health_information_log"],
"data_units_written": 12345698,
"data_units_read": 9876553,
},
}
sysfs_path = sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0"
assert run_collection(
first, sysfs_path, config_fixture, FakeClock(now - timedelta(minutes=5))
)["ok"] is True
assert run_collection(
second, sysfs_path, config_fixture, FakeClock(now)
)["ok"] is True
store_path = Path(config_fixture["store_path"])
with read_status(store_path, now, query_services=False) as (reader, composition):
assert reader is not None
assert composition.sample_count == 2
assert composition.day_count == 1
utc_day = reader.execute(
"SELECT bytes_written_delta, bytes_read_delta FROM day_aggregates"
).fetchone()
local_day = reader.execute(
"SELECT bytes_written, bytes_read FROM local_days"
).fetchone()
assert tuple(utc_day) == (10_240_000, 5_120_000)
assert tuple(local_day) == (10_240_000, 5_120_000)
cli_output = get_status(
store_path, now, query_services=True, query_journal=False
)
assert "Monitoring" in cli_output
app = FenrisTuiApp(store_path=store_path)
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
live_readout = str(app.query_one("#live-readout").render())
assert "W 0.010 GB" in live_readout
assert "R 0.005 GB" in live_readout
repeated_result = run_collection(
second, sysfs_path, config_fixture, FakeClock(now + timedelta(minutes=5))
)
assert repeated_result["ok"] is True
with read_status(
store_path, now + timedelta(minutes=5), query_services=False
) as (reader, composition):
assert reader is not None
assert composition.sample_count == 3
utc_day = reader.execute(
"SELECT bytes_written_delta, bytes_read_delta FROM day_aggregates"
).fetchone()
local_day = reader.execute(
"SELECT bytes_written, bytes_read FROM local_days"
).fetchone()
assert tuple(utc_day) == (10_240_000, 5_120_000)
assert tuple(local_day) == (10_240_000, 5_120_000)
def test_failed_local_day_publication_keeps_previous_publication(
self, config_fixture, smartctl_fixture, sysfs_fixture_tree, monkeypatch
):
"""A failed final derivation step leaves all prior reader state intact."""
from fenris.status import read_status
monkeypatch.setenv("TZ", "UTC")
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
first_clock = FakeClock(now)
first = {
**smartctl_fixture,
"nvme_smart_health_information_log": {
**smartctl_fixture["nvme_smart_health_information_log"],
"data_units_written": 12345678,
"data_units_read": 9876543,
},
}
first_result = run_collection(
first,
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture,
first_clock,
)
assert first_result["ok"] is True, first_result
writer = sqlite3.connect(config_fixture["store_path"])
writer.execute(
"CREATE TRIGGER fail_local_day_publication "
"BEFORE INSERT ON local_days "
"BEGIN SELECT RAISE(ABORT, 'injected local-day publication failure'); END"
)
writer.commit()
writer.close()
next_time = now + timedelta(minutes=5)
second = {
**smartctl_fixture,
"nvme_smart_health_information_log": {
**smartctl_fixture["nvme_smart_health_information_log"],
"data_units_written": 12345698,
"data_units_read": 9876548,
},
}
failed_result = run_collection(
second,
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture,
FakeClock(next_time),
)
assert failed_result["ok"] is False
assert "injected local-day publication failure" in failed_result["error"]
with read_status(
Path(config_fixture["store_path"]), next_time, query_services=False
) as (reader, composition):
assert reader is not None
assert composition.sample_count == 1
public_counts = reader.execute(
"SELECT (SELECT COUNT(*) FROM samples), "
"(SELECT COUNT(*) FROM controller_segments), "
"(SELECT COUNT(*) FROM monitoring_periods), "
"(SELECT COUNT(*) FROM hour_observations), "
"(SELECT COUNT(*) FROM day_aggregates), "
"(SELECT COUNT(*) FROM local_days)"
).fetchone()
assert tuple(public_counts) == (1, 1, 1, 0, 0, 0)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Display states: awaiting first sample, awaiting another sample # Display states: awaiting first sample, awaiting another sample
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+6 -2
View File
@@ -232,7 +232,7 @@ def test_store_initialization(config_fixture: Dict[str, Any]):
# Initialize store # Initialize store
conn = init_store(store_path) conn = init_store(store_path)
# Verify all six entities exist # Verify the observation, derived-history, and metadata tables exist.
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'") cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = {row[0] for row in cursor.fetchall()} tables = {row[0] for row in cursor.fetchall()}
@@ -244,10 +244,14 @@ def test_store_initialization(config_fixture: Dict[str, Any]):
"controller_segments", "controller_segments",
"endurance_baseline", "endurance_baseline",
} }
# sqlite_sequence is a system table created by AUTOINCREMENT # sqlite_sequence is a system table created by AUTOINCREMENT
expected_tables.add("sqlite_sequence") expected_tables.add("sqlite_sequence")
expected_tables.add("store_metadata") expected_tables.add("store_metadata")
expected_tables.add("local_days")
expected_tables.add("pending_publications")
expected_tables.add("local_day_unallocated_evidence")
expected_tables.add("local_day_segment_totals")
assert expected_tables == tables assert expected_tables == tables
conn.close() conn.close()
+299
View File
@@ -0,0 +1,299 @@
"""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, activity_intervals) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
(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)
def test_complete_legacy_day_without_trusted_activity_does_not_open_gate(
self, store,
):
"""A complete flag cannot make unavailable local activity qualify."""
_insert_baseline(store)
_insert_segment(store)
_open_period(store)
for i in range(3):
day = (datetime(2026, 9, 27) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, day, 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)
store.execute(
"UPDATE local_days SET activity_precision = 'measured', activity_intervals = 0 "
"WHERE local_date = '2026-09-29'"
)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert any("full local observation day" in fact
for fact in result.contributing_facts)
def test_partial_but_trusted_local_activity_opens_gate(self, store):
"""Known local intervals can coexist with an incomplete day total."""
_insert_baseline(store)
_insert_segment(store)
_open_period(store)
for i in range(3):
day = (datetime(2026, 9, 27) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, day, 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)
store.execute(
"UPDATE local_days SET activity_incomplete = 1 "
"WHERE local_date = '2026-09-29'"
)
result = compute_projection(store, _clock())
assert not any("full local observation day" in fact
for fact 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
+145
View File
@@ -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()
+176
View File
@@ -0,0 +1,176 @@
"""User-facing navigation, zoom, and plotted volume in the redesigned dashboard."""
from datetime import datetime, timedelta, timezone
from xml.etree import ElementTree
import pytest
from test_tui import (
_insert_baseline,
_insert_day,
_insert_local_day,
_insert_segment,
_open_period,
)
from fenris.store import init_store
from fenris.tui import FenrisTuiApp
NOW = datetime(2026, 9, 19, 12, tzinfo=timezone.utc)
@pytest.fixture
def dashboard(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "prefs"))
class Clock(datetime):
@classmethod
def now(cls, tz=None):
return NOW
monkeypatch.setattr("fenris.tui.datetime", Clock)
monkeypatch.setattr("fenris.status.query_service_state", lambda: {
"boot_enabled": True, "timer_active": True, "last_collect_ok": True,
"last_collect_age_s": 0, "last_collect_reason": None,
})
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_insert_baseline(conn)
_open_period(conn)
for offset in range(18):
date = (NOW - timedelta(days=offset)).date().isoformat()
_insert_day(conn, date, bw=(offset + 1) * 1_000_000_000)
_insert_local_day(conn, date, bw=(offset + 1) * 1_000_000_000, br=2_000_000_000)
for index in range(61):
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id) VALUES (?, ?, ?, ?, ?)",
((NOW - timedelta(minutes=(60 - index) * 3)).isoformat(),
"/dev/test", index * 1_000_000, index * 2_000_000, 1),
)
conn.execute(
"INSERT INTO hour_observations (hour, bytes_written_delta, bytes_read_delta, coverage, sample_count) "
"VALUES (?, ?, ?, ?, ?)", ("2026-09-18T12:00:00+00:00", 4_000_000, 8_000_000, 1, 20),
)
conn.commit()
conn.close()
return FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
def visible(app):
return " ".join("".join(ElementTree.fromstring(app.export_screenshot()).itertext()).split())
@pytest.mark.asyncio
@pytest.mark.parametrize("size", [(140, 44), (80, 24)])
async def test_live_plot_inspection_zoom_refresh_and_resize(dashboard, size):
app = dashboard
async with app.run_test(size=size) as pilot:
app.on_refresh_tick()
await pilot.pause()
assert app.theme == "fenris-chalktone"
graph = app.query_one("#live-activity")
assert app.focused is graph
await pilot.press("left", "w")
selected = str(app.query_one("#live-readout").render())
assert "11:54" in selected and "W 0.001 GB" in selected and "R 0.002 GB" in selected
assert "Reads" in str(app.query_one("#live-legend").render())
await pilot.press("z")
app.on_refresh_tick()
await pilot.pause()
assert str(app.query_one("#live-readout").render()) == selected
assert app.query_one("#activity-panel").region.width == size[0]
for fact in ("Freshness:", "Last collect:", "Boot:", "Timer:", "q Quit TUI"):
assert fact in visible(app)
await pilot.resize_terminal(100, 30)
await pilot.press("escape")
assert str(app.query_one("#live-readout").render()) == selected
assert app._zoomed_panel is None
assert app.query_one("#action-rail").region.bottom <= 30
@pytest.mark.asyncio
async def test_tabs_date_entry_and_hourly_inspection_keep_context(dashboard):
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
await pilot.click("#view-history")
await pilot.press("left", "enter")
assert app.query_one("#activity-tabs").active == "view-day"
assert "2026-09-18" in str(app.query_one("#local-day").render())
# Hour 23 is selected initially; move to the known hour 12.
await pilot.press(*(["left"] * 11))
readout = str(app.query_one("#bar-readout").render())
assert "12:00 UTC" in readout and "W 0.004 GB" in readout
await pilot.press("z", "w")
app.on_refresh_tick()
await pilot.pause()
assert str(app.query_one("#bar-readout").render()) == readout
await pilot.press("g")
await pilot.press(*list("2026-09-17"))
await pilot.press("escape")
assert str(app.query_one("#bar-readout").render()) == readout
assert app._zoomed_panel == "activity-panel"
await pilot.press("escape", "t")
assert app.query_one("#activity-tabs").active == "view-live"
@pytest.mark.asyncio
async def test_mouse_inspection_matches_time_axis(dashboard):
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
await pilot.pause()
graph = app.query_one("#live-activity")
x = graph._point_columns[0]
await pilot.click("#live-render", offset=(x, 1))
text = str(app.query_one("#live-readout").render())
assert "09:00 → 09:03 UTC" in text
@pytest.mark.asyncio
async def test_theme_control_persists_choice_and_quit_never_pauses(dashboard, monkeypatch):
calls = []
monkeypatch.setattr(dashboard, "_run_helper", lambda *args: calls.append(args))
async with dashboard.run_test(size=(80, 24)) as pilot:
await pilot.press("s")
assert dashboard.theme == "fenris-amber"
await pilot.press("q")
assert calls == []
from fenris.preferences import load_preferences
assert load_preferences()["theme"] == "amber"
@pytest.mark.asyncio
@pytest.mark.parametrize("size", [(100, 36), (70, 20)])
async def test_unallocated_volume_survives_day_with_missing_coverage(dashboard, size):
async with dashboard.run_test(size=size) as pilot:
await pilot.click("#view-history")
graph = dashboard.query_one("#usage-history")
graph.set_data([{
"day": "2026-09-18", "total_bytes": 2_000_000_000,
"unallocated_bytes": 2_000_000_000, "is_gap": True,
"is_partial": True,
}])
readout = str(dashboard.query_one("#bar-readout").render())
assert "W 2.000 GB unallocated" in readout
assert "R unavailable" in readout and "gap" in readout
if size[0] >= 80:
plot = str(dashboard.query_one("#bar-render").render())
assert any(0x2801 <= ord(c) <= 0x28ff for c in plot)
graph.measure = "read"
graph._refresh()
plot = str(dashboard.query_one("#bar-render").render())
assert not any(0x2801 <= ord(c) <= 0x28ff for c in plot)
@pytest.mark.asyncio
@pytest.mark.parametrize("state", ["gap", "future"])
async def test_small_terminal_hour_readout_does_not_invent_zero(dashboard, state):
async with dashboard.run_test(size=(70, 20)) as pilot:
await pilot.click("#view-day")
graph = dashboard.query_one("#usage-history")
graph.set_hour_data([{
"hour": "2026-09-18T12:00:00+00:00", "local_label": "12",
"is_gap": state == "gap", "is_future": state == "future",
}])
graph._hourly_selected = 0
graph._refresh_hourly()
readout = str(dashboard.query_one("#bar-readout").render())
assert "W unavailable · R unavailable" in readout
assert state in readout and "12:00 UTC" in readout
+8
View File
@@ -634,6 +634,14 @@ class TestMonitorIntegration:
cmd_enable(args) cmd_enable(args)
mock_enable.assert_called_once_with(True) mock_enable.assert_called_once_with(True)
conn = sqlite3.connect(store_path)
try:
assert conn.execute(
"SELECT COUNT(*) FROM monitoring_periods WHERE ended_at IS NULL"
).fetchone()[0] == 1
finally:
conn.close()
def test_cmd_disable_uses_init_system(self, tmp_path): def test_cmd_disable_uses_init_system(self, tmp_path):
"""cmd_disable uses init_system.disable_timer.""" """cmd_disable uses init_system.disable_timer."""
from fenris.monitor import cmd_disable from fenris.monitor import cmd_disable
+341
View File
@@ -0,0 +1,341 @@
"""Normal-collection retention acceptance tests for issue #100."""
import sqlite3
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.collector import run_collection
from fenris.local_day import query_local_day_summary
from fenris.pruning import prune_old_samples
from fenris.status import read_status
from fenris.store import init_store
@pytest.fixture
def sysfs_controller(tmp_path):
controller = tmp_path / "sys" / "class" / "nvme" / "nvme0"
controller.mkdir(parents=True)
(controller / "subsysnqn").write_text("nqn.example:drive-1\n")
(controller / "model").write_text("Fenris Test Drive\n")
(controller / "serial").write_text("drive-1\n")
(controller / "firmware_rev").write_text("1.0\n")
transport = controller / "transport"
transport.mkdir()
(transport / "address").write_text("0000:00:01.0\n")
(transport / "trstring").write_text("pcie\n")
return controller
class FakeClock:
def __init__(self, now):
self.now = now
def utcnow(self):
return self.now
def _smartctl(written, read):
return {
"nvme_smart_health_information_log": {
"critical_warning": 0,
"temperature": 35,
"available_spare": 100,
"percentage_used": 1,
"data_units_written": written,
"data_units_read": read,
"power_on_hours": 100,
"power_cycles": 1,
"unsafe_shutdowns": 0,
"media_errors": 0,
},
"user_capacity": {"bytes": 1_000_000_000_000},
"model_name": "Fenris Test Drive",
"serial_number": "drive-1",
"firmware_version": "1.0",
}
def _collect(config, controller, now, written, read):
return run_collection(
smartctl_data=_smartctl(written, read),
sysfs_path=controller,
config=config,
clock=FakeClock(now),
)
def test_normal_collection_expires_detail_and_keeps_local_day_evidence(
tmp_path, sysfs_controller, monkeypatch,
):
monkeypatch.setenv("TZ", "UTC")
store_path = tmp_path / "observations.db"
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
first_time = datetime(2026, 9, 1, 0, 0, tzinfo=timezone.utc)
second_time = first_time + timedelta(days=15)
first = _collect(config, sysfs_controller, first_time, 1000, 2000)
assert first["ok"] is True, first
second = _collect(config, sysfs_controller, second_time, 1001, 2003)
assert second["ok"] is True, second
with sqlite3.connect(store_path) as conn:
assert conn.execute("SELECT ts FROM samples ORDER BY id").fetchall() == [
(second_time.isoformat(),)
]
assert conn.execute(
"SELECT bytes_written, bytes_read, reason "
"FROM local_day_unallocated_evidence"
).fetchall() == [(512_000, 1_536_000, "local_midnight")]
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0] == 1
assert conn.execute("SELECT COUNT(*) FROM day_aggregates").fetchone()[0] == 15
monkeypatch.setenv("TZ", "Asia/Kolkata")
with read_status(store_path, second_time, query_services=False) as (reader, state):
assert reader is not None
assert state.store_fault is None
old_day = query_local_day_summary(
reader, "2026-09-01", "UTC", second_time,
)
assert old_day["utc_start"] == "2026-09-01T00:00:00+00:00"
assert old_day["utc_end"] == "2026-09-02T00:00:00+00:00"
assert old_day["bytes_written"] is None
assert old_day["shared_bytes_written"] == 512_000
assert old_day["shared_bytes_read"] == 1_536_000
assert old_day["shared_evidence_count"] == 1
def test_pruning_keeps_samples_when_replacement_evidence_is_missing(tmp_path):
store_path = tmp_path / "incomplete.db"
conn = init_store(store_path)
now = datetime(2026, 9, 30, tzinfo=timezone.utc)
old_time = now - timedelta(days=20)
next_time = now - timedelta(days=1)
conn.execute(
"INSERT INTO controller_segments (id, opened_at, identity_key) "
"VALUES (1, ?, 'test')",
(old_time.isoformat(),),
)
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id, local_tz) "
"VALUES (?, '/dev/nvme0', 100, 100, 1, 'UTC')",
(old_time.isoformat(),),
)
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id, local_tz) "
"VALUES (?, '/dev/nvme0', 200, 200, 1, 'UTC')",
(next_time.isoformat(),),
)
conn.commit()
assert prune_old_samples(conn, now) == 0
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 2
conn.close()
def test_later_same_day_activity_does_not_replace_gap_interval(tmp_path):
store_path = tmp_path / "unrelated-local-activity.db"
conn = init_store(store_path)
gap_start = datetime(2026, 9, 1, 0, 0, tzinfo=timezone.utc)
gap_end = gap_start + timedelta(minutes=10)
later_end = gap_start + timedelta(minutes=20)
now = datetime(2026, 9, 30, tzinfo=timezone.utc)
conn.execute(
"INSERT INTO controller_segments (id, opened_at, identity_key) "
"VALUES (1, ?, 'test')",
(gap_start.isoformat(),),
)
for sample_time, written, read in (
(gap_start, 1000, 100),
(gap_end, 2000, 200),
(later_end, 2100, 210),
):
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id, local_tz) "
"VALUES (?, '/dev/nvme0', ?, ?, 1, 'UTC')",
(sample_time.isoformat(), written, read),
)
# UTC aggregates can replace the old counter pair, but the local-day
# summary contains only the later B->C interval, after monitoring resumed.
conn.execute(
"INSERT INTO hour_observations (hour, bytes_written_delta, bytes_read_delta) "
"VALUES ('2026-09-01T00:00:00+00:00', 1000, 100)"
)
conn.execute(
"INSERT INTO day_aggregates (day) VALUES ('2026-09-01')"
)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at) VALUES (?, ?)",
(gap_end.isoformat(), later_end.isoformat()),
)
last_sample_id = conn.execute("SELECT MAX(id) FROM samples").fetchone()[0]
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, "
" bytes_read, activity_intervals, activity_precision, last_sample_id) "
"VALUES ('2026-09-01', 'UTC', '+00:00', ?, ?, 100, 10, 1, 'measured', ?)",
(
gap_start.isoformat(),
(gap_start + timedelta(days=1)).isoformat(),
last_sample_id,
),
)
local_day_id = conn.execute("SELECT MAX(id) FROM local_days").fetchone()[0]
conn.execute(
"INSERT INTO local_day_segment_totals "
"(local_day_id, segment_id, bytes_written, bytes_read, activity_intervals) "
"VALUES (?, 1, 100, 10, 1)",
(local_day_id,),
)
conn.commit()
assert prune_old_samples(conn, now) == 0
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 3
conn.close()
def test_pruning_keeps_oldest_unpaired_sample_as_successor_anchor(tmp_path):
store_path = tmp_path / "anchor.db"
conn = init_store(store_path)
now = datetime(2026, 9, 30, tzinfo=timezone.utc)
old_time = now - timedelta(days=20)
conn.execute(
"INSERT INTO controller_segments (id, opened_at, identity_key) "
"VALUES (1, ?, 'test')",
(old_time.isoformat(),),
)
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id, local_tz) "
"VALUES (?, '/dev/nvme0', 100, 100, 1, 'UTC')",
(old_time.isoformat(),),
)
conn.commit()
assert prune_old_samples(conn, now) == 0
assert conn.execute("SELECT ts FROM samples").fetchone()[0] == old_time.isoformat()
conn.close()
def test_pruning_never_expires_pending_publications(tmp_path):
store_path = tmp_path / "pending.db"
conn = init_store(store_path)
old_time = datetime(2026, 9, 1, tzinfo=timezone.utc)
conn.execute(
"INSERT INTO pending_publications (sample_ts, payload) VALUES (?, '{}')",
(old_time.isoformat(),),
)
conn.commit()
assert prune_old_samples(
conn, datetime(2026, 9, 30, tzinfo=timezone.utc),
) == 0
assert conn.execute(
"SELECT sample_ts FROM pending_publications"
).fetchone()[0] == old_time.isoformat()
conn.close()
def test_pruning_preserves_unparseable_legacy_timestamp(tmp_path):
store_path = tmp_path / "legacy.db"
conn = init_store(store_path)
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read) "
"VALUES ('legacy timestamp', '/dev/nvme0', 100, 100)"
)
conn.commit()
assert prune_old_samples(conn, datetime(2026, 9, 30, tzinfo=timezone.utc)) == 0
assert conn.execute("SELECT ts FROM samples").fetchone()[0] == "legacy timestamp"
conn.close()
def test_collection_defers_interrupted_pruning_and_retries_next_run(
tmp_path, sysfs_controller, monkeypatch,
):
monkeypatch.setenv("TZ", "UTC")
store_path = tmp_path / "interrupted.db"
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
first_time = datetime(2026, 9, 1, 0, 0, tzinfo=timezone.utc)
second_time = first_time + timedelta(days=15)
assert _collect(config, sysfs_controller, first_time, 1000, 2000)["ok"] is True
writer = sqlite3.connect(store_path)
writer.execute(
"CREATE TRIGGER stop_retention BEFORE DELETE ON samples "
"BEGIN SELECT RAISE(ABORT, 'simulated retention interruption'); END"
)
writer.commit()
writer.close()
second = _collect(config, sysfs_controller, second_time, 1001, 2001)
assert second["ok"] is True, second
assert "simulated retention interruption" in second["retention_error"]
with sqlite3.connect(store_path) as conn:
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 2
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM local_day_unallocated_evidence").fetchone()[0] == 1
writer = sqlite3.connect(store_path)
writer.execute("DROP TRIGGER stop_retention")
writer.commit()
writer.close()
third_time = second_time + timedelta(days=15)
third = _collect(config, sysfs_controller, third_time, 1002, 2002)
assert third["ok"] is True, third
assert third["retention_error"] is None
with sqlite3.connect(store_path) as conn:
assert conn.execute("SELECT ts FROM samples ORDER BY id").fetchall() == [
(third_time.isoformat(),)
]
def test_collection_recovers_old_pending_work_before_pruning(
tmp_path, sysfs_controller, monkeypatch,
):
monkeypatch.setenv("TZ", "UTC")
store_path = tmp_path / "pending-recovery.db"
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
first_time = datetime(2026, 9, 1, 0, 0, tzinfo=timezone.utc)
second_time = first_time + timedelta(days=15)
assert _collect(config, sysfs_controller, first_time, 1000, 2000)["ok"] is True
writer = sqlite3.connect(store_path)
writer.execute(
"CREATE TRIGGER interrupt_publication "
"BEFORE INSERT ON local_day_unallocated_evidence "
"BEGIN SELECT RAISE(ABORT, 'simulated publication interruption'); END"
)
writer.commit()
writer.close()
failed = _collect(config, sysfs_controller, second_time, 1001, 2001)
assert failed["ok"] is False
assert "simulated publication interruption" in failed["error"]
with sqlite3.connect(store_path) as conn:
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 1
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 1
writer = sqlite3.connect(store_path)
writer.execute("DROP TRIGGER interrupt_publication")
writer.commit()
writer.close()
third_time = second_time + timedelta(minutes=5)
recovered = _collect(config, sysfs_controller, third_time, 1002, 2002)
assert recovered["ok"] is True, recovered
assert recovered["retention_error"] is None
with sqlite3.connect(store_path) as conn:
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
assert conn.execute("SELECT ts FROM samples ORDER BY id").fetchall() == [
(second_time.isoformat(),),
(third_time.isoformat(),),
]
assert conn.execute(
"SELECT COUNT(*) FROM local_day_unallocated_evidence"
).fetchone()[0] == 1
+376
View File
@@ -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, activity_intervals) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
(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()
+35
View File
@@ -86,6 +86,33 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
conn.commit() conn.commit()
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
utc_start=None, utc_end=None, bw=1024*1024*100,
br=0, coverage=0.95, samples=24, complete=True):
"""Insert a local_days row (issue #94 gate prerequisite)."""
if utc_start is None:
utc_start = local_date + "T00:00:00+00:00"
if utc_end is None:
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
conn.commit()
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
"""Insert multiple complete local days to satisfy the issue #94 gate."""
for i in range(count):
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_local_day(conn, d, bw=bw)
class TestHonestQualifyingProgress: class TestHonestQualifyingProgress:
"""Issue #77: Show honest qualifying-day progress and confidence.""" """Issue #77: Show honest qualifying-day progress and confidence."""
@@ -103,6 +130,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=bw, coverage=cov) _insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
# After warming, qualifying_days_progress shows honest count # After warming, qualifying_days_progress shows honest count
@@ -125,6 +153,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=bw, samples=samples) _insert_day(store, d, bw=bw, samples=samples)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
# After warming, qualifying_days_progress shows honest count # After warming, qualifying_days_progress shows honest count
@@ -146,6 +175,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=bw, coverage=0.95) _insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
# Should be warming # Should be warming
@@ -164,6 +194,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=0, coverage=0.95) _insert_day(store, d, bw=0, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
# Zero rate should be UNSUPPORTED # Zero rate should be UNSUPPORTED
@@ -185,6 +216,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=bw, coverage=cov) _insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
# Should still be warming (3 days below coverage > WARMING_MAX_LOW_COVERAGE=2) # Should still be warming (3 days below coverage > WARMING_MAX_LOW_COVERAGE=2)
@@ -205,6 +237,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=bw, samples=samples) _insert_day(store, d, bw=bw, samples=samples)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
# Should still be warming # Should still be warming
@@ -225,6 +258,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=bw, coverage=cov) _insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
# Should not be warming (14 total, 2 below coverage <= WARMING_MAX_LOW_COVERAGE) # Should not be warming (14 total, 2 below coverage <= WARMING_MAX_LOW_COVERAGE)
@@ -247,6 +281,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=bw, coverage=0.95) _insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
# Should not be warming # Should not be warming
+7 -7
View File
@@ -95,7 +95,7 @@ class TestFenrisIdentity:
) )
async with app.run_test(size=(120, 24)) as pilot: async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
assert "🐺 Fenris by Bongbetic" in headline assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_titlebox_fallback_without_wolf(self, tmp_path): async def test_titlebox_fallback_without_wolf(self, tmp_path):
@@ -108,7 +108,7 @@ class TestFenrisIdentity:
async with app.run_test(size=(60, 24)) as pilot: async with app.run_test(size=(60, 24)) as pilot:
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
# Either shows wolf or fallback - both are acceptable # Either shows wolf or fallback - both are acceptable
assert "Fenris by Bongbetic" in headline assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_wolf_never_shows_tofu(self, tmp_path): async def test_wolf_never_shows_tofu(self, tmp_path):
@@ -140,7 +140,7 @@ class TestFenrisIdentity:
async with app.run_test(size=(120, 24)) as pilot: async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
# Identity appears once, lifespan is separate data # Identity appears once, lifespan is separate data
assert "🐺 Fenris by Bongbetic" in headline assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
assert "remaining" in headline.lower() or "projection" in headline.lower() assert "remaining" in headline.lower() or "projection" in headline.lower()
@@ -174,7 +174,7 @@ class TestSingleMakerCredit:
) )
async with app.run_test(size=(120, 24)) as pilot: async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
assert "Fenris by Bongbetic" in headline assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_empty_store_no_maker_credit_in_strip(self, tmp_path): async def test_empty_store_no_maker_credit_in_strip(self, tmp_path):
@@ -275,7 +275,7 @@ class TestPreservedBehavior:
"""Separate q Quit TUI rail preserved.""" """Separate q Quit TUI rail preserved."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(120, 24)) as pilot: async with app.run_test(size=(120, 24)) as pilot:
rail = str(app.query_one("#quit-rail").render()) rail = str(app.query_one("#action-rail").render())
assert "q Quit TUI" in rail assert "q Quit TUI" in rail
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -287,11 +287,11 @@ class TestPreservedBehavior:
) )
async with app.run_test(size=(120, 24)) as pilot: async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
assert "polkit" in headline.lower() assert "polkit" in str(app.query_one("#auth-notice").render()).lower()
# Should clear after first tick # Should clear after first tick
await pilot.pause(0.25) await pilot.pause(0.25)
headline_after = str(app.query_one("#headline-band").render()) headline_after = str(app.query_one("#headline-band").render())
assert "polkit" not in headline_after.lower() assert not app.query_one("#auth-notice").display
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deliberate_pause_block_preserved(self, tmp_path): async def test_deliberate_pause_block_preserved(self, tmp_path):
+23 -16
View File
@@ -93,7 +93,7 @@ class TestPresetLoading:
"""Themes load from preferences and apply to the TUI.""" """Themes load from preferences and apply to the TUI."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tui_applies_amber_theme_by_default(self, tmp_path): async def test_tui_applies_chalktone_theme_by_default(self, tmp_path):
"""TUI starts with the Amber theme when no preferences exist.""" """TUI starts with the Amber theme when no preferences exist."""
from fenris.tui import FenrisTuiApp from fenris.tui import FenrisTuiApp
@@ -102,7 +102,7 @@ class TestPresetLoading:
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot: async with app.run_test() as pilot:
# Theme should be fenris-amber # Theme should be fenris-amber
assert app.theme == "fenris-amber" assert app.theme == "fenris-chalktone"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tui_applies_nord_theme_from_prefs(self, tmp_path): async def test_tui_applies_nord_theme_from_prefs(self, tmp_path):
@@ -171,8 +171,8 @@ class TestKeyBindings:
assert "m" in binding_keys assert "m" in binding_keys
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_t_cycles_through_themes(self, tmp_path): async def test_t_goes_to_today(self, tmp_path):
"""Pressing t cycles through the three presets.""" """Pressing t returns to today's live view (issue #91)."""
from fenris.tui import FenrisTuiApp from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path) config_home = _make_prefs_dir(tmp_path)
@@ -180,14 +180,19 @@ class TestKeyBindings:
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot: async with app.run_test() as pilot:
# Start at amber # Start at amber
assert app.theme == "fenris-amber" assert app.theme == "fenris-chalktone"
# Press t to cycle # Enter drill mode on the graph
graph = app.query_one("#usage-history")
graph.view_mode = "hourly"
graph.drill_day = "2026-09-30"
# Press t to go back to today
await pilot.press("t") await pilot.press("t")
await pilot.pause() await pilot.pause()
# Should be nord or high_contrast now # Should be back in daily mode
assert app.theme in ("fenris-nord", "fenris-high-contrast") assert graph.view_mode == "daily"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_m_toggles_reduced_motion(self, tmp_path): async def test_m_toggles_reduced_motion(self, tmp_path):
@@ -226,21 +231,23 @@ class TestPersistence:
async def test_theme_survives_restart(self, tmp_path): async def test_theme_survives_restart(self, tmp_path):
"""Theme preference persists across TUI restart.""" """Theme preference persists across TUI restart."""
from fenris.tui import FenrisTuiApp from fenris.tui import FenrisTuiApp
from fenris.preferences import save_preferences
config_home = _make_prefs_dir(tmp_path) config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}): with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
# First run: change theme # First run: change theme via preferences API
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app1.run_test() as pilot: async with app1.run_test() as pilot:
await pilot.press("t") save_preferences(theme="nord", reduced_motion=False)
await pilot.pause() app1._current_theme_name = "nord"
theme_after_t = app1.theme app1.theme = "fenris-nord"
assert theme_after_t != "fenris-amber" theme_after = app1.theme
assert theme_after == "fenris-nord"
# Second run: theme should persist # Second run: theme should persist
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app2.run_test() as pilot: async with app2.run_test() as pilot:
assert app2.theme == theme_after_t assert app2.theme == theme_after
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reduced_motion_survives_restart(self, tmp_path): async def test_reduced_motion_survives_restart(self, tmp_path):
@@ -375,7 +382,7 @@ class TestSafePersistence:
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot: async with app.run_test() as pilot:
# TUI should start with default theme # TUI should start with default theme
assert app.theme == "fenris-amber" assert app.theme == "fenris-chalktone"
# Dashboard should be functional # Dashboard should be functional
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
assert headline is not None assert headline is not None
@@ -394,7 +401,7 @@ class TestSafePersistence:
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot: async with app.run_test() as pilot:
# TUI should start without crash # TUI should start without crash
assert app.theme in ("fenris-amber", "fenris-nord", "fenris-high-contrast") assert app.theme in ("fenris-chalktone", "fenris-amber", "fenris-nord", "fenris-high-contrast")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+596
View File
@@ -0,0 +1,596 @@
"""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,
HistoryGraph,
_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
class MockDatetime(_real_datetime):
@classmethod
def now(cls, tz=None):
return clock
mock_dt = MockDatetime
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")
await pilot.press("v", "v")
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.press("v", "v")
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.press("v", "v")
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.press("v", "v")
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
+603
View File
@@ -0,0 +1,603 @@
"""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)
# The handcrafted totals lack replacement provenance, so retain samples.
assert pruned == 0
# 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="America/New_York", 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 == "America/New_York"
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: UTC-hour totals never become fabricated local-day totals."""
def test_hourly_bytes_are_unavailable_at_local_precision(self, tmp_path):
"""UTC-day hour bytes cannot be split across local-day boundaries."""
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
assert summary_16.bytes_written == 0
assert summary_17.bytes_written == 0
assert summary_16.activity_precision == "unavailable"
assert summary_17.activity_precision == "unavailable"
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 == 0
cursor = conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 29
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
+293
View File
@@ -0,0 +1,293 @@
"""Pending publication behavior from issue #97."""
import sqlite3
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris import collector
from fenris.projection import compute_projection
from fenris.status import _query_drive_facts, get_status, read_status
from fenris.status_composition import render_status_tui
from fenris.store import init_store
class FakeClock:
def __init__(self, now):
self.now = now
def utcnow(self):
return self.now
@pytest.fixture
def sysfs_fixture_tree(tmp_path):
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
ctrl_dir.mkdir(parents=True)
(ctrl_dir / "subsysnqn").write_text("nqn.test:drive\n")
(ctrl_dir / "model").write_text("Test NVMe\n")
(ctrl_dir / "serial").write_text("test-serial\n")
(ctrl_dir / "firmware_rev").write_text("1.0\n")
transport_dir = ctrl_dir / "transport"
transport_dir.mkdir()
(transport_dir / "trstring").write_text("pcie\n")
return tmp_path
def _smartctl(unit_written, unit_read=500):
return {
"nvme_smart_health_information_log": {
"critical_warning": 0,
"temperature": 35,
"available_spare": 100,
"percentage_used": 5,
"data_units_written": unit_written,
"data_units_read": unit_read,
"power_on_hours": 100,
},
"user_capacity": {"bytes": 1_024_000_000_000},
"model_name": "Test NVMe",
"serial_number": "test-serial",
"firmware_version": "1.0",
}
def _acquirer(sysfs_path, unit_written, unit_read=500):
return lambda: (_smartctl(unit_written, unit_read), sysfs_path)
def _public_snapshot(store_path, clock_now):
with read_status(store_path, clock_now) as (conn, comp):
assert conn is not None
return {
"state": comp.state,
"freshness": comp.freshness,
"sample_count": comp.sample_count,
"day_count": comp.day_count,
"boot_enabled": comp.boot_enabled,
"timer_active": comp.timer_active,
"last_collect_ok": comp.last_collect_ok,
"latest_sample": tuple(conn.execute(
"SELECT ts, subnqn, mn, sn, fr, capacity_bytes, temperature_c, "
"available_spare, data_units_written, bytes_written, critical_warning, "
"media_errors, unsafe_shutdowns "
"FROM samples ORDER BY id DESC LIMIT 1"
).fetchone()),
"drive_facts": tuple(_query_drive_facts(conn)),
"projection": repr(compute_projection(conn, clock_now)),
"segments": conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0],
"periods": conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0],
"hours": tuple(conn.execute(
"SELECT hour, bytes_written_delta, bytes_read_delta, sample_count "
"FROM hour_observations ORDER BY hour"
).fetchall()),
"days": tuple(conn.execute(
"SELECT day, bytes_written_delta, bytes_read_delta "
"FROM day_aggregates ORDER BY day"
).fetchall()),
"local_days": tuple(conn.execute(
"SELECT local_date, bytes_written, bytes_read, sample_count "
"FROM local_days ORDER BY local_date, tz_name"
).fetchall()),
"pending": comp.pending_publication_count,
}
def test_failed_publication_survives_restart_and_readers_keep_last_consistent_view(
tmp_path, sysfs_fixture_tree, monkeypatch
):
"""A failed derivation stays private, visible as pending, and replays in order."""
monkeypatch.setenv("TZ", "UTC")
service = {"boot_enabled": True, "timer_active": True, "last_collect_ok": True}
monkeypatch.setattr("fenris.status.query_service_state", lambda: service)
now = datetime.now(timezone.utc).replace(
minute=35, second=0, microsecond=0,
)
store_path = tmp_path / "observations.db"
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
sysfs_path = sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0"
for units, at in ((1000, now - timedelta(minutes=10)), (1010, now - timedelta(minutes=5))):
result = collector.run_collection(
config=config,
clock=FakeClock(at),
acquire=_acquirer(sysfs_path, units),
)
assert result["ok"] is True, result
previous = _public_snapshot(store_path, now)
baseline_cli = get_status(store_path, now, query_services=False, query_journal=False)
original_derive = collector.derive_hours_from_interval
reader_during_write = {}
def fail_derivation(conn, previous_sample, next_sample):
# Read while writer has inserted the unpublished sample but not committed.
with read_status(store_path, now, query_services=False) as (reader, comp):
assert reader is not None
reader_during_write["samples"] = reader.execute(
"SELECT COUNT(*) FROM samples"
).fetchone()[0]
reader_during_write["pending"] = comp.pending_publication_count
reader_during_write["latest"] = tuple(reader.execute(
"SELECT data_units_written FROM samples ORDER BY id DESC LIMIT 1"
).fetchone())
raise RuntimeError("injected non-invariant derivation failure")
monkeypatch.setattr(collector, "derive_hours_from_interval", fail_derivation)
failed = collector.run_collection(
config=config,
clock=FakeClock(now),
acquire=_acquirer(sysfs_path, 1025),
)
assert failed["ok"] is False
assert "injected non-invariant derivation failure" in failed["error"]
assert reader_during_write == {"samples": 2, "pending": 1, "latest": (1010,)}
after_failure = _public_snapshot(store_path, now)
assert {**after_failure, "pending": previous["pending"]} == previous
assert after_failure["pending"] == 1
cli_text = get_status(store_path, now, query_services=False, query_journal=False)
pending_fact = " · pending publication: 1 observation retained for retry"
assert pending_fact in cli_text
assert cli_text.replace(pending_fact, "", 1) == baseline_cli
with read_status(store_path, now) as (_, comp):
assert comp.state == previous["state"]
assert comp.boot_enabled is True
assert comp.timer_active is True
assert "pending publication: 1 observation retained for retry" in render_status_tui(comp).lower()
called = False
def should_wait_for_recovery():
nonlocal called
called = True
return _smartctl(1030), sysfs_path
blocked = collector.run_collection(
config=config,
clock=FakeClock(now + timedelta(minutes=5)),
acquire=should_wait_for_recovery,
)
assert blocked["ok"] is False
assert called is False
assert _public_snapshot(store_path, now)["pending"] == 1
monkeypatch.setattr(collector, "derive_hours_from_interval", original_derive)
monkeypatch.setenv("TZ", "Asia/Kolkata")
recovered = collector.run_collection(
config=config,
clock=FakeClock(now + timedelta(minutes=5)),
acquire=_acquirer(sysfs_path, 1040),
)
assert recovered["ok"] is True, recovered
conn = sqlite3.connect(store_path)
try:
assert conn.execute(
"SELECT data_units_written FROM samples ORDER BY id"
).fetchall() == [(1000,), (1010,), (1025,), (1040,)]
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
assert conn.execute(
"SELECT SUM(bytes_written_delta) FROM day_aggregates"
).fetchone()[0] == (1040 - 1000) * 512_000
assert conn.execute(
"SELECT sample_count FROM local_days WHERE tz_name = 'UTC'"
).fetchone()[0] == 4
assert conn.execute(
"SELECT COUNT(*) FROM local_days WHERE tz_name = 'Asia/Kolkata'"
).fetchone()[0] == 1
finally:
conn.close()
def test_invalid_sample_writes_no_sample_segment_period_or_pending_row(tmp_path, sysfs_fixture_tree):
"""Sample invariants run before staging or public monitoring changes."""
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
store_path = tmp_path / "observations.db"
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
sysfs_path = sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0"
result = collector.run_collection(
config=config,
clock=FakeClock(now),
acquire=_acquirer(sysfs_path, -1),
)
assert result["ok"] is False
assert result["error_type"] == "InvariantViolationError"
conn = sqlite3.connect(store_path)
try:
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
finally:
conn.close()
def test_pending_capacity_retries_before_skipping_acquisition(tmp_path, sysfs_fixture_tree, monkeypatch):
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
store_path = tmp_path / "observations.db"
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
sysfs_path = sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0"
first = collector.run_collection(
config=config,
clock=FakeClock(now - timedelta(minutes=5)),
acquire=_acquirer(sysfs_path, 1000),
)
assert first["ok"] is True, first
monkeypatch.setattr(collector, "PENDING_PUBLICATION_LIMIT", 1)
def fail_derivation(*_):
raise RuntimeError("derivation still unavailable")
monkeypatch.setattr(collector, "derive_hours_from_interval", fail_derivation)
failed = collector.run_collection(
config=config,
clock=FakeClock(now),
acquire=_acquirer(sysfs_path, 1010),
)
assert failed["ok"] is False
assert "capacity full (1 observations)" in failed["error"]
called = False
def must_not_acquire():
nonlocal called
called = True
return _smartctl(1020), sysfs_path
retry = collector.run_collection(
config=config,
clock=FakeClock(now + timedelta(minutes=5)),
acquire=must_not_acquire,
)
assert retry["ok"] is False
assert "capacity full (1 observations)" in retry["error"]
assert called is False
def test_v3_readers_ignore_pending_table_until_store_migrates(tmp_path):
store_path = tmp_path / "observations.db"
conn = init_store(store_path)
conn.execute("DROP TABLE pending_publications")
conn.execute("PRAGMA user_version=3")
conn.commit()
conn.close()
with read_status(store_path, datetime.now(timezone.utc), query_services=False) as (reader, comp):
assert reader is not None
assert comp.store_fault is None
assert comp.pending_publication_count == 0
migrated = init_store(store_path)
try:
assert migrated.execute("PRAGMA user_version").fetchone()[0] == 6
assert migrated.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
finally:
migrated.close()
+520
View File
@@ -0,0 +1,520 @@
"""Historical local-day migration and repair acceptance tests for issue #99."""
import sqlite3
import sys
from datetime import datetime, timezone
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.local_day import (
query_local_day_summary,
record_local_activity_interval,
)
from fenris.status import read_status
from fenris.store import init_store
SOURCE_SCHEMA_VERSION = 5
LOCAL_DATE = "2026-09-02"
TIMEZONE = "Asia/Kolkata"
UTC_START = "2026-09-01T18:30:00+00:00"
UTC_END = "2026-09-02T18:30:00+00:00"
def _legacy_store(path: Path) -> sqlite3.Connection:
"""Build a v5 store with one old coarse local-day summary."""
conn = init_store(path)
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, "
" bytes_read, coverage, sample_count, complete, activity_incomplete, "
" activity_precision) "
"VALUES (?, ?, '+05:30', ?, ?, 999999, 888888, 1.0, 24, 1, 0, 'legacy')",
(LOCAL_DATE, TIMEZONE, UTC_START, UTC_END),
)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at) VALUES (?, ?)",
(UTC_START, "2026-09-03T00:00:00+00:00"),
)
conn.execute(
"INSERT INTO controller_segments "
"(opened_at, identity_key, identity_degraded) VALUES (?, ?, 0)",
(UTC_START, "controller-a"),
)
conn.execute(
"INSERT INTO hour_observations "
"(hour, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES ('2026-09-02T00:00:00+00:00', 123, 45, 2, 1.0)"
)
conn.execute(
"INSERT INTO day_aggregates "
"(day, bytes_written_delta, bytes_read_delta, coverage) "
"VALUES ('2026-09-02', 678, 90, 1.0)"
)
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, power_on_hours, "
" segment_id, local_tz) VALUES (?, '/dev/nvme0', 1000, 2000, 100, 1, ?)",
("2026-09-01T19:00:00+00:00", TIMEZONE),
)
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, power_on_hours, "
" segment_id, local_tz) VALUES (?, '/dev/nvme0', 11000, 5000, 115, 1, ?)",
("2026-09-02T10:00:00+00:00", TIMEZONE),
)
conn.execute(f"PRAGMA user_version={SOURCE_SCHEMA_VERSION}")
conn.commit()
return conn
def test_upgrade_rebuilds_legacy_day_from_samples_and_keeps_recorded_bounds(
tmp_path, monkeypatch,
):
db = tmp_path / "legacy.db"
conn = _legacy_store(db)
conn.close()
monkeypatch.setenv("TZ", "UTC")
conn = init_store(db)
conn.close()
with read_status(
db, datetime(2026, 9, 4, tzinfo=timezone.utc), query_services=False,
) as (reader, composition):
assert reader is not None
assert composition.store_fault is None
summary = query_local_day_summary(
reader, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary is not None
assert summary["local_date"] == LOCAL_DATE
assert summary["tz_name"] == TIMEZONE
assert summary["tz_offset"] == "+05:30"
assert summary["utc_start"] == UTC_START
assert summary["utc_end"] == UTC_END
assert summary["bytes_written"] == 10_000
assert summary["bytes_read"] == 3_000
assert summary["activity_precision"] == "measured"
assert summary["segments"] == [{
"segment_id": 1,
"bytes_written": 10_000,
"bytes_read": 3_000,
"activity_seconds": 54_000,
"activity_intervals": 1,
}]
assert summary["activity_state"] == "incomplete"
assert reader.execute(
"SELECT bytes_written_delta FROM hour_observations"
).fetchone()[0] == 123
assert reader.execute(
"SELECT bytes_written_delta FROM day_aggregates"
).fetchone()[0] == 678
conn = init_store(db)
conn.close()
with read_status(
db, datetime(2026, 9, 4, tzinfo=timezone.utc), query_services=False,
) as (reader, _composition):
assert reader is not None
summary = query_local_day_summary(
reader, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 10_000
assert summary["bytes_read"] == 3_000
def test_repair_rebuilds_legacy_local_day_once_across_restarts(tmp_path):
from fenris.repair import repair_derivation
db = tmp_path / "repair.db"
conn = _legacy_store(db)
first = repair_derivation(conn)
assert first.ok
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 10_000
assert summary["bytes_read"] == 3_000
conn.close()
conn = sqlite3.connect(db)
second = repair_derivation(conn)
assert second.ok
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 10_000
assert summary["bytes_read"] == 3_000
assert conn.execute(
"SELECT bytes_written, bytes_read, activity_intervals "
"FROM local_day_segment_totals"
).fetchall() == [(10_000, 3_000, 1)]
conn.close()
def test_interrupted_repair_keeps_local_day_rebuild_retryable(tmp_path):
from fenris.repair import repair_derivation
db = tmp_path / "repair-interrupted.db"
conn = _legacy_store(db)
conn.execute(
"CREATE TRIGGER interrupt_repair_local_rebuild "
"BEFORE INSERT ON local_day_segment_totals "
"BEGIN SELECT RAISE(ABORT, 'simulated repair interruption'); END"
)
conn.commit()
failed = repair_derivation(conn)
assert not failed.ok
assert query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)["bytes_written"] is None
assert conn.execute(
"SELECT activity_precision, bytes_written FROM local_days"
).fetchone() == ("legacy", 999_999)
conn.execute("DROP TRIGGER interrupt_repair_local_rebuild")
conn.commit()
conn.close()
conn = sqlite3.connect(db)
retried = repair_derivation(conn)
assert retried.ok
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 10_000
assert summary["bytes_read"] == 3_000
conn.close()
def test_stale_repair_marker_does_not_block_crash_retry(tmp_path):
from fenris.repair import repair_derivation
conn = _legacy_store(tmp_path / "stale-repair.db")
conn.execute(
"INSERT OR REPLACE INTO store_metadata (key, value) "
"VALUES ('repair_in_progress', 'true')"
)
conn.commit()
result = repair_derivation(conn)
assert result.ok
assert conn.execute(
"SELECT value FROM store_metadata WHERE key = 'repair_in_progress'"
).fetchone() == ("false",)
assert query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)["bytes_written"] == 10_000
conn.close()
def test_upgrade_uses_whole_utc_hours_but_skips_midnight_straddling_hours(
tmp_path,
):
db = tmp_path / "coarse.db"
conn = _legacy_store(db)
conn.execute("DELETE FROM samples")
conn.execute(
"INSERT INTO hour_observations "
"(hour, bytes_written_delta, bytes_read_delta, sample_count, "
" active_seconds, coverage) "
"VALUES ('2026-09-01T18:00:00+00:00', 99999, 9999, 2, 3600, 1.0)"
)
conn.execute(
"INSERT INTO hour_observations "
"(hour, bytes_written_delta, bytes_read_delta, sample_count, "
" active_seconds, coverage) "
"VALUES ('2026-09-02T18:00:00+00:00', 88888, 8888, 2, 3600, 1.0)"
)
conn.commit()
conn.close()
conn = init_store(db)
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 123
assert summary["bytes_read"] == 45
assert summary["activity_precision"] == "coarse"
assert summary["activity_state"] == "incomplete"
assert summary["utc_start"] == UTC_START
assert summary["utc_end"] == UTC_END
conn.execute(
"INSERT INTO samples "
"(id, ts, device, bytes_written, bytes_read, segment_id, local_tz) "
"VALUES (10, '2026-09-02T12:00:00+00:00', '/dev/nvme0', 1000, 2000, 1, ?), "
" (11, '2026-09-02T12:05:00+00:00', '/dev/nvme0', 2000, 2500, 1, ?)",
(TIMEZONE, TIMEZONE),
)
conn.commit()
assert record_local_activity_interval(
conn,
{
"id": 10, "ts": "2026-09-02T12:00:00+00:00",
"bytes_written": 1_000, "bytes_read": 2_000,
"local_tz": TIMEZONE,
},
{
"id": 11, "ts": "2026-09-02T12:05:00+00:00",
"bytes_written": 2_000, "bytes_read": 2_500,
"local_tz": TIMEZONE,
},
start_sample_id=10, end_sample_id=11, segment_id=1,
) == "known"
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 1_000
assert summary["bytes_read"] == 500
assert summary["activity_precision"] == "measured"
assert summary["segments"] == [{
"segment_id": 1,
"bytes_written": 1_000,
"bytes_read": 500,
"activity_seconds": 300,
"activity_intervals": 1,
}]
conn.close()
def test_upgrade_does_not_double_count_coarse_hours_in_overlapping_days(tmp_path):
db = tmp_path / "overlapping-coarse-days.db"
conn = _legacy_store(db)
conn.execute("DELETE FROM samples")
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, "
" bytes_read, coverage, sample_count, complete, activity_precision) "
"VALUES ('2026-09-02', 'UTC', '+00:00', "
"'2026-09-02T00:00:00+00:00', '2026-09-03T00:00:00+00:00', "
"999999, 888888, 1.0, 24, 1, 'legacy')"
)
conn.commit()
conn.close()
conn = init_store(db)
for tz_name in (TIMEZONE, "UTC"):
summary = query_local_day_summary(
conn, LOCAL_DATE, tz_name,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] is None
assert summary["bytes_read"] is None
assert summary["activity_precision"] == "unavailable"
assert summary["activity_state"] == "unavailable"
assert conn.execute(
"SELECT bytes_written_delta, bytes_read_delta FROM hour_observations "
"WHERE hour = '2026-09-02T00:00:00+00:00'"
).fetchone() == (123, 45)
conn.close()
def test_upgrade_retains_local_midnight_interval_once_as_shared_evidence(tmp_path):
db = tmp_path / "shared.db"
conn = _legacy_store(db)
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, "
" bytes_read, coverage, sample_count, complete, activity_precision) "
"VALUES ('2026-09-01', ?, '+05:30', "
"'2026-08-31T18:30:00+00:00', '2026-09-01T18:30:00+00:00', "
"500, 600, 1.0, 24, 1, 'legacy')",
(TIMEZONE,),
)
conn.execute(
"UPDATE monitoring_periods SET started_at = '2026-08-31T18:00:00+00:00'"
)
conn.execute(
"UPDATE controller_segments SET opened_at = '2026-08-31T18:00:00+00:00'"
)
conn.execute(
"UPDATE samples SET ts = '2026-09-01T18:25:00+00:00', "
"bytes_written = 1000, bytes_read = 2000 WHERE id = 1"
)
conn.execute(
"UPDATE samples SET ts = '2026-09-01T18:35:00+00:00', "
"bytes_written = 1100, bytes_read = 2050 WHERE id = 2"
)
conn.commit()
conn.close()
conn = init_store(db)
shared = conn.execute(
"SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) "
"FROM local_day_unallocated_evidence"
).fetchone()
assert shared == (1, 100, 50)
for local_date in ("2026-09-01", "2026-09-02"):
summary = query_local_day_summary(
conn, local_date, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] is None
assert summary["shared_bytes_written"] == 100
assert summary["shared_bytes_read"] == 50
assert summary["activity_state"] == "incomplete"
from fenris.repair import repair_derivation
assert repair_derivation(conn).ok
assert conn.execute(
"SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) "
"FROM local_day_unallocated_evidence"
).fetchone() == (1, 100, 50)
conn.close()
def test_interrupted_upgrade_keeps_legacy_day_retryable(tmp_path):
db = tmp_path / "interrupted.db"
conn = _legacy_store(db)
conn.execute(
"CREATE TRIGGER interrupt_local_rebuild "
"BEFORE INSERT ON local_day_segment_totals "
"BEGIN SELECT RAISE(ABORT, 'simulated interruption'); END"
)
conn.commit()
conn.close()
with pytest.raises(sqlite3.IntegrityError, match="simulated interruption"):
init_store(db)
conn = sqlite3.connect(db)
assert conn.execute("PRAGMA user_version").fetchone() == (SOURCE_SCHEMA_VERSION,)
assert conn.execute(
"SELECT activity_precision, bytes_written FROM local_days"
).fetchone() == ("legacy", 999_999)
conn.execute("DROP TRIGGER interrupt_local_rebuild")
conn.commit()
conn.close()
conn = init_store(db)
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 10_000
assert summary["bytes_read"] == 3_000
conn.close()
def test_prior_migration_step_commits_before_local_day_repair(tmp_path):
db = tmp_path / "migration-steps.db"
conn = _legacy_store(db)
conn.execute("PRAGMA user_version=4")
conn.execute(
"CREATE TRIGGER interrupt_local_rebuild BEFORE UPDATE OF bytes_written "
"ON local_days BEGIN SELECT RAISE(ABORT, 'simulated interruption'); END"
)
conn.commit()
conn.close()
with pytest.raises(sqlite3.IntegrityError, match="simulated interruption"):
init_store(db)
conn = sqlite3.connect(db)
assert conn.execute("PRAGMA user_version").fetchone() == (5,)
assert conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' "
"AND name='local_day_segment_totals'"
).fetchone() is not None
assert conn.execute(
"SELECT bytes_written FROM local_days WHERE local_date=?",
(LOCAL_DATE,),
).fetchone() == (999_999,)
conn.close()
def test_upgrade_does_not_allocate_unzoned_interval_to_overlapping_timezones(
tmp_path,
):
db = tmp_path / "overlapping-timezones.db"
conn = _legacy_store(db)
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, "
" bytes_read, coverage, sample_count, complete, activity_precision) "
"VALUES ('2026-09-02', 'UTC', '+00:00', "
"'2026-09-02T00:00:00+00:00', '2026-09-03T00:00:00+00:00', "
"999999, 888888, 1.0, 24, 1, 'legacy')"
)
conn.execute(
"UPDATE samples SET ts = '2026-09-02T01:00:00+00:00', local_tz = NULL "
"WHERE id = 1"
)
conn.execute(
"UPDATE samples SET ts = '2026-09-02T10:00:00+00:00', local_tz = NULL "
"WHERE id = 2"
)
conn.commit()
conn.close()
conn = init_store(db)
for tz_name in (TIMEZONE, "UTC"):
summary = query_local_day_summary(
conn, LOCAL_DATE, tz_name,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] is None
assert summary["bytes_read"] is None
assert summary["activity_precision"] == "unavailable"
assert summary["activity_state"] == "unavailable"
conn.close()
def test_upgrade_rejects_interval_before_recorded_controller_segment(
tmp_path,
):
db = tmp_path / "segment-boundary.db"
conn = _legacy_store(db)
conn.execute(
"UPDATE controller_segments "
"SET opened_at = '2026-09-01T20:00:00+00:00' WHERE id = 1"
)
conn.commit()
conn.close()
conn = init_store(db)
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] is None
assert summary["bytes_read"] is None
assert summary["activity_precision"] == "unavailable"
conn.close()
def test_sample_at_local_midnight_does_not_hide_coarse_reconstruction(tmp_path):
db = tmp_path / "midnight-sample.db"
conn = _legacy_store(db)
conn.execute(
"UPDATE samples SET ts = '2026-09-01T17:00:00+00:00' WHERE id = 1"
)
conn.execute(
"UPDATE samples SET ts = ?, local_tz = ? WHERE id = 2",
(UTC_END, TIMEZONE),
)
conn.commit()
conn.close()
conn = init_store(db)
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 123
assert summary["bytes_read"] == 45
assert summary["activity_precision"] == "coarse"
assert summary["activity_state"] == "incomplete"
conn.close()
+324
View File
@@ -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 W/R" in str(app.query_one("#activity-tools").render())
# ---------------------------------------------------------------------------
# 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")
+924
View File
@@ -0,0 +1,924 @@
"""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,
record_local_activity_interval,
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()
def test_v4_totals_keep_values_but_are_not_treated_as_local_precision(
self, tmp_path,
):
db = tmp_path / "v4.db"
conn = sqlite3.connect(db)
conn.execute(
"CREATE TABLE samples (id INTEGER PRIMARY KEY, ts TEXT, device TEXT)"
)
conn.execute(
"CREATE TABLE pending_publications "
"(id INTEGER PRIMARY KEY, sample_ts TEXT, payload TEXT)"
)
conn.execute(
"CREATE TABLE local_days ("
"id INTEGER PRIMARY KEY, 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, sample_count INTEGER DEFAULT 0, "
"complete BOOLEAN DEFAULT 0, UNIQUE(local_date, tz_name))"
)
conn.execute(
"INSERT INTO local_days VALUES "
"(1, '2026-09-01', 'UTC', '+00:00', "
"'2026-09-01T00:00:00+00:00', '2026-09-02T00:00:00+00:00', "
"5120000, 2048000, 0.5, 10, 0)"
)
conn.execute("PRAGMA user_version=4")
conn.commit()
conn.close()
conn = init_store(db)
stored = conn.execute(
"SELECT bytes_written, activity_precision FROM local_days"
).fetchone()
assert stored == (5_120_000, "unavailable")
visible = query_local_day_summary(conn, "2026-09-01", "UTC")
assert visible["bytes_written"] is None
assert visible["activity_state"] == "unavailable"
assert "local_tz" in {
row[1] for row in conn.execute("PRAGMA table_info(samples)")
}
conn.close()
# ---------------------------------------------------------------------------
# Local-day coverage derivation from UTC hours
# ---------------------------------------------------------------------------
class TestDeriveLocalDay:
"""Derive coverage from UTC hours without allocating local-day bytes."""
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 == 0
assert summary.bytes_read == 0
assert summary.activity_precision == "unavailable"
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 == 0
assert summary.bytes_read == 0
assert summary.activity_precision == "unavailable"
conn.close()
def test_hourly_bytes_cannot_be_allocated_to_local_midnight(self, tmp_path):
"""UTC-hour totals do not establish exact local-day byte totals."""
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
assert summary.bytes_written == 0
assert summary.bytes_read == 0
assert summary.activity_precision == "unavailable"
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()
def test_interval_crossing_utc_hour_stays_wholly_in_local_day(
self, tmp_path, sysfs_tree, monkeypatch,
):
"""A measured interval inside one local date keeps its full delta."""
from fenris.collector import run_collection
monkeypatch.setenv("TZ", "Asia/Kolkata")
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
first_at = datetime(2026, 9, 1, 12, 59, tzinfo=timezone.utc)
second_at = datetime(2026, 9, 1, 13, 1, tzinfo=timezone.utc)
first = run_collection(
_make_smartctl(10_000_000, 8_000_000),
sysfs_nvme,
cfg,
_Clock(first_at),
)
second = run_collection(
_make_smartctl(10_000_010, 8_000_004),
sysfs_nvme,
cfg,
_Clock(second_at),
)
assert first["ok"] and second["ok"]
conn = sqlite3.connect(store)
summary = query_local_day_summary(conn, "2026-09-01")
assert summary is not None
assert summary["bytes_written"] == 5_120_000
assert summary["bytes_read"] == 2_048_000
assert summary["segments"] == [{
"segment_id": 1,
"bytes_written": 5_120_000,
"bytes_read": 2_048_000,
"activity_seconds": 120,
"activity_intervals": 1,
}]
segment_totals = conn.execute(
"SELECT segment_id, bytes_written, bytes_read, activity_intervals "
"FROM local_day_segment_totals"
).fetchone()
assert segment_totals == (1, 5_120_000, 2_048_000, 1)
samples = conn.execute(
"SELECT id, ts, bytes_written, bytes_read, local_tz "
"FROM samples ORDER BY id"
).fetchall()
first_sample, second_sample = samples
assert record_local_activity_interval(
conn,
dict(zip(("id", "ts", "bytes_written", "bytes_read", "local_tz"), first_sample)),
dict(zip(("id", "ts", "bytes_written", "bytes_read", "local_tz"), second_sample)),
start_sample_id=first_sample[0],
end_sample_id=second_sample[0],
segment_id=1,
) == "known"
repeated = query_local_day_summary(conn, "2026-09-01", "Asia/Kolkata")
assert repeated["bytes_written"] == 5_120_000
assert repeated["bytes_read"] == 2_048_000
assert conn.execute(
"SELECT SUM(bytes_written), SUM(bytes_read), SUM(activity_intervals) "
"FROM local_day_segment_totals"
).fetchone() == (5_120_000, 2_048_000, 1)
conn.close()
@pytest.mark.parametrize(
(
"first_at", "second_at", "local_date", "expected_hours",
"expected_start", "expected_end", "expected_offset",
),
[
(
datetime(2026, 3, 8, 6, 30, tzinfo=timezone.utc),
datetime(2026, 3, 8, 7, 30, tzinfo=timezone.utc),
"2026-03-08", 23,
"2026-03-08T05:00:00+00:00",
"2026-03-09T04:00:00+00:00", "-05:00",
),
(
datetime(2026, 11, 1, 5, 30, tzinfo=timezone.utc),
datetime(2026, 11, 1, 6, 30, tzinfo=timezone.utc),
"2026-11-01", 25,
"2026-11-01T04:00:00+00:00",
"2026-11-02T05:00:00+00:00", "-04:00",
),
],
ids=("spring-forward", "fall-back-repeated-hour"),
)
def test_collector_retains_exact_interval_on_dst_local_day(
self,
tmp_path,
sysfs_tree,
monkeypatch,
first_at,
second_at,
local_date,
expected_hours,
expected_start,
expected_end,
expected_offset,
):
"""Collection preserves exact activity on short and long local days."""
from fenris.collector import run_collection
zone = "America/New_York"
monkeypatch.setenv("TZ", zone)
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
assert run_collection(
_make_smartctl(10_000_000, 8_000_000),
sysfs_nvme,
cfg,
_Clock(first_at),
)["ok"]
assert run_collection(
_make_smartctl(10_000_001, 8_000_002),
sysfs_nvme,
cfg,
_Clock(second_at),
)["ok"]
conn = sqlite3.connect(store)
summary = query_local_day_summary(
conn, local_date, zone, second_at + timedelta(minutes=1)
)
assert summary is not None
assert summary["bytes_written"] == 512_000
assert summary["bytes_read"] == 1_024_000
assert summary["activity_seconds"] == 3_600
assert summary["utc_start"] == expected_start
assert summary["utc_end"] == expected_end
assert summary["tz_offset"] == expected_offset
assert (
datetime.fromisoformat(summary["utc_end"])
- datetime.fromisoformat(summary["utc_start"])
).total_seconds() == expected_hours * 3_600
conn.close()
@pytest.mark.asyncio
async def test_local_midnight_interval_is_shared_once(
self, tmp_path, sysfs_tree, monkeypatch,
):
"""A real counter interval crossing local midnight stays unallocated."""
from fenris.collector import run_collection
monkeypatch.setenv("TZ", "Asia/Kolkata")
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
first_at = datetime(2026, 9, 1, 18, 25, tzinfo=timezone.utc)
second_at = datetime(2026, 9, 1, 18, 35, tzinfo=timezone.utc)
first = run_collection(
_make_smartctl(10_000_000, 8_000_000),
sysfs_nvme,
cfg,
_Clock(first_at),
)
second = run_collection(
_make_smartctl(10_000_100, 8_000_060),
sysfs_nvme,
cfg,
_Clock(second_at),
)
assert first["ok"] and second["ok"]
conn = sqlite3.connect(store)
evidence = conn.execute(
"SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) "
"FROM local_day_unallocated_evidence"
).fetchone()
assert evidence == (1, 51_200_000, 30_720_000)
assert conn.execute(
"SELECT SUM(bytes_written), SUM(bytes_read) FROM local_days"
).fetchone() == (0, 0)
for local_date in ("2026-09-01", "2026-09-02"):
summary = query_local_day_summary(
conn, local_date, "Asia/Kolkata", second_at
)
assert summary is not None
assert summary["bytes_written"] is None
assert summary["shared_bytes_written"] == 51_200_000
assert summary["shared_bytes_read"] == 30_720_000
assert summary["activity_state"] in ("incomplete", "so_far")
conn.close()
from fenris.status import read_status
from fenris.tui import FenrisTuiApp
app = FenrisTuiApp(store_path=Path(store), refresh_interval_s=999)
async with app.run_test(size=(100, 32)):
app._browse_date = "2026-09-02"
with read_status(
Path(store), second_at, query_services=False
) as (reader, _):
assert reader is not None
app._render_local_day(reader)
visible = str(app.query_one("#local-day").render())
assert "2026-09-02" in visible
assert "Asia/Kolkata +05:30" in visible
assert "W unavailable" in visible
assert "shared at midnight W 0.051 GB" in visible
def test_synthetic_shared_bytes_conserve_one_hundred_bytes(self, tmp_path):
"""Shared evidence conserves byte values before display rounding."""
conn = init_store(tmp_path / "obs.db")
first_at = datetime(2026, 9, 1, 18, 25, tzinfo=timezone.utc)
second_at = datetime(2026, 9, 3, 18, 35, tzinfo=timezone.utc)
ensure_period_open(conn, first_at)
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, local_tz) "
"VALUES (?, ?, ?, ?, ?)",
(first_at.isoformat(), "/dev/nvme0", 1_000, 2_000, "Asia/Kolkata"),
)
first_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, local_tz) "
"VALUES (?, ?, ?, ?, ?)",
(second_at.isoformat(), "/dev/nvme0", 1_100, 2_050, "Asia/Kolkata"),
)
second_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
result = record_local_activity_interval(
conn,
{
"id": first_id,
"ts": first_at.isoformat(),
"bytes_written": 1_000,
"bytes_read": 2_000,
"local_tz": "Asia/Kolkata",
},
{
"id": second_id,
"ts": second_at.isoformat(),
"bytes_written": 1_100,
"bytes_read": 2_050,
"local_tz": "Asia/Kolkata",
},
start_sample_id=first_id,
end_sample_id=second_id,
segment_id=1,
)
assert result == "local_midnight"
stored = conn.execute(
"SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) "
"FROM local_day_unallocated_evidence"
).fetchone()
assert stored == (1, 100, 50)
record_local_activity_interval(
conn,
{
"id": first_id,
"ts": first_at.isoformat(),
"bytes_written": 1_000,
"bytes_read": 2_000,
"local_tz": "Asia/Kolkata",
},
{
"id": second_id,
"ts": second_at.isoformat(),
"bytes_written": 1_100,
"bytes_read": 2_050,
"local_tz": "Asia/Kolkata",
},
start_sample_id=first_id,
end_sample_id=second_id,
segment_id=1,
)
assert conn.execute(
"SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) "
"FROM local_day_unallocated_evidence"
).fetchone() == (1, 100, 50)
assert conn.execute(
"SELECT SUM(bytes_written), SUM(bytes_read) FROM local_days"
).fetchone() == (0, 0)
middle_day = query_local_day_summary(
conn, "2026-09-03", "Asia/Kolkata", second_at + timedelta(days=1)
)
assert middle_day["shared_bytes_written"] == 100
assert middle_day["shared_bytes_read"] == 50
assert middle_day["shared_evidence_count"] == 1
conn.close()
def test_timezone_change_keeps_interval_unallocated(
self, tmp_path, sysfs_tree, monkeypatch,
):
from fenris.collector import run_collection
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
first_at = datetime(2026, 9, 1, 22, 0, tzinfo=timezone.utc)
second_at = datetime(2026, 9, 1, 22, 5, tzinfo=timezone.utc)
monkeypatch.setenv("TZ", "UTC")
assert run_collection(
_make_smartctl(10_000_000, 8_000_000),
sysfs_nvme,
cfg,
_Clock(first_at),
)["ok"]
monkeypatch.setenv("TZ", "Asia/Kolkata")
assert run_collection(
_make_smartctl(10_000_010, 8_000_004),
sysfs_nvme,
cfg,
_Clock(second_at),
)["ok"]
conn = sqlite3.connect(store)
event = conn.execute(
"SELECT COUNT(*), SUM(bytes_written), MIN(reason) "
"FROM local_day_unallocated_evidence"
).fetchone()
assert event == (1, 5_120_000, "timezone_change")
old_zone = query_local_day_summary(conn, "2026-09-01", "UTC")
new_zone = query_local_day_summary(conn, "2026-09-02", "Asia/Kolkata")
assert old_zone["bytes_written"] is None
assert new_zone["bytes_written"] is None
assert old_zone["unallocated_bytes_written"] == 5_120_000
assert new_zone["unallocated_bytes_written"] == 5_120_000
conn.close()
def test_pause_boundary_keeps_interval_unallocated(
self, tmp_path, sysfs_tree, monkeypatch,
):
from fenris.collector import run_collection
monkeypatch.setenv("TZ", "UTC")
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
first_at = datetime(2026, 9, 1, 12, 0, tzinfo=timezone.utc)
second_at = datetime(2026, 9, 1, 12, 10, tzinfo=timezone.utc)
assert run_collection(
_make_smartctl(10_000_000, 8_000_000),
sysfs_nvme,
cfg,
_Clock(first_at),
)["ok"]
conn = sqlite3.connect(store)
close_period(conn, first_at + timedelta(minutes=5), "user_disabled")
conn.close()
assert run_collection(
_make_smartctl(10_000_010, 8_000_004),
sysfs_nvme,
cfg,
_Clock(second_at),
)["ok"]
conn = sqlite3.connect(store)
reason = conn.execute(
"SELECT reason FROM local_day_unallocated_evidence"
).fetchone()
summary = query_local_day_summary(conn, "2026-09-01", "UTC")
assert reason == ("monitoring_period",)
assert summary["bytes_written"] is None
assert summary["unallocated_bytes_written"] == 5_120_000
assert summary["shared_bytes_written"] == 0
conn.close()
def test_controller_segment_boundary_marks_local_activity_incomplete(
self, tmp_path, sysfs_tree, monkeypatch,
):
from fenris.collector import run_collection
monkeypatch.setenv("TZ", "UTC")
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
first_at = datetime(2026, 9, 1, 12, 0, tzinfo=timezone.utc)
second_at = datetime(2026, 9, 1, 12, 5, tzinfo=timezone.utc)
assert run_collection(
_make_smartctl(10_000_000, 8_000_000),
sysfs_nvme,
cfg,
_Clock(first_at),
)["ok"]
assert run_collection(
_make_smartctl(10, 8_000_004),
sysfs_nvme,
cfg,
_Clock(second_at),
)["ok"]
conn = sqlite3.connect(store)
summary = query_local_day_summary(
conn, "2026-09-01", "UTC", second_at
)
assert summary is not None
assert summary["bytes_written"] is None
assert summary["activity_state"] == "incomplete"
assert conn.execute(
"SELECT COUNT(*) FROM local_day_unallocated_evidence"
).fetchone()[0] == 0
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)
# New York 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, "America/New_York", 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 duration == 23 * 3600
conn.close()
def test_long_day_25_hours(self):
from fenris.local_day import local_day_boundaries
start, end, offset = local_day_boundaries(
"2026-11-01", "America/New_York"
)
assert (end - start).total_seconds() == 25 * 3600
assert offset == "-04:00"
def test_half_hour_offset_records_exact_local_boundaries(self):
from fenris.local_day import local_day_boundaries
start, end, offset = local_day_boundaries(
"2026-09-01", "Asia/Kolkata"
)
assert start.isoformat() == "2026-08-31T18:30:00+00:00"
assert end.isoformat() == "2026-09-01T18:30:00+00:00"
assert offset == "+05:30"
def test_repeated_local_clock_labels_remain_one_recorded_day(
self, tmp_path, sysfs_tree, monkeypatch,
):
from fenris.collector import run_collection
monkeypatch.setenv("TZ", "America/New_York")
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
# Both endpoints display as 01:30 locally, on opposite UTC offsets.
first_at = datetime(2026, 11, 1, 5, 30, tzinfo=timezone.utc)
second_at = datetime(2026, 11, 1, 6, 30, tzinfo=timezone.utc)
assert run_collection(
_make_smartctl(10_000_000, 8_000_000),
sysfs_nvme,
cfg,
_Clock(first_at),
)["ok"]
assert run_collection(
_make_smartctl(10_000_001, 8_000_002),
sysfs_nvme,
cfg,
_Clock(second_at),
)["ok"]
conn = sqlite3.connect(store)
summary = query_local_day_summary(
conn, "2026-11-01", "America/New_York", second_at
)
assert summary is not None
assert summary["bytes_written"] == 512_000
assert summary["bytes_read"] == 1_024_000
assert summary["utc_start"] == "2026-11-01T04:00:00+00:00"
assert summary["utc_end"] == "2026-11-02T05:00:00+00:00"
conn.close()
+551
View File
@@ -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()
+43
View File
@@ -0,0 +1,43 @@
"""Package directory permissions must not inherit a builder's private umask."""
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
@pytest.mark.parametrize("mask", ["077", "022"])
def test_stage_is_publicly_traversable_under_any_builder_umask(tmp_path, mask):
root = Path(__file__).resolve().parent.parent
for directory in ("packaging", "scripts", "src", "units", "polkit"):
shutil.copytree(root / directory, tmp_path / directory,
ignore=shutil.ignore_patterns("__pycache__", "*.egg-info"))
for filename in ("LICENSE", "requirements.txt"):
shutil.copy(root / filename, tmp_path / filename)
(tmp_path / "dist").mkdir()
(tmp_path / "dist/fenris-9.9.9-py3-none-any.whl").touch()
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
# Avoid downloading dependencies: emulate pip's target output while
# exercising the real staging script and its package directory layout.
pip_stub = bin_dir / "python3"
pip_stub.write_text(
f"#!{sys.executable}\n"
"import sys\nfrom pathlib import Path\n"
"target = Path(sys.argv[sys.argv.index('--target') + 1]) / 'fenris'\n"
"target.mkdir(parents=True)\n"
"(target / '__init__.py').write_text('')\n"
)
pip_stub.chmod(0o755)
subprocess.run(
["bash", "-c", f"umask {mask}; exec bash packaging/stage.sh 9.9.9"],
cwd=tmp_path, env={**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"]},
check=True, capture_output=True, text=True,
)
stage = tmp_path / "build/stage"
for directory in (stage, *(p for p in stage.rglob("*") if p.is_dir())):
assert directory.stat().st_mode & 0o777 == 0o755, directory
assert (stage / "usr/bin/fenris").stat().st_mode & 0o777 == 0o755
assert (stage / "opt/fenris/vendor/fenris/__init__.py").stat().st_mode & 0o444 == 0o444
+8 -8
View File
@@ -3,7 +3,7 @@
Covers: Covers:
- Preference load/save with safe defaults - Preference load/save with safe defaults
- XDG_CONFIG_HOME user-scoped persistence - XDG_CONFIG_HOME user-scoped persistence
- Amber default theme, normal-motion default - Chalktone default theme, normal-motion default
- Invalid/unreadable/unwritable preference data does not crash - Invalid/unreadable/unwritable preference data does not crash
- Theme presets: Amber, Nord, High Contrast - Theme presets: Amber, Nord, High Contrast
- Reduced motion preference persistence - Reduced motion preference persistence
@@ -72,9 +72,9 @@ class TestPreferencePath:
class TestDefaults: class TestDefaults:
"""When no preference file exists, defaults are returned.""" """When no preference file exists, defaults are returned."""
def test_default_theme_is_amber(self): def test_default_theme_is_chalktone(self):
"""Amber is the default theme preset.""" """Amber is the default theme preset."""
assert DEFAULT_THEME == "amber" assert DEFAULT_THEME == "chalktone"
def test_default_reduced_motion_is_false(self): def test_default_reduced_motion_is_false(self):
"""Normal motion is the default.""" """Normal motion is the default."""
@@ -82,13 +82,13 @@ class TestDefaults:
def test_valid_themes_are_all_presets(self): def test_valid_themes_are_all_presets(self):
"""Three valid presets exist.""" """Three valid presets exist."""
assert VALID_THEMES == {"amber", "nord", "high_contrast"} assert VALID_THEMES == {"chalktone", "amber", "nord", "high_contrast"}
def test_load_returns_defaults_when_no_file(self, tmp_path): def test_load_returns_defaults_when_no_file(self, tmp_path):
"""Missing preference file returns safe defaults.""" """Missing preference file returns safe defaults."""
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}): with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}):
prefs = load_preferences() prefs = load_preferences()
assert prefs["theme"] == "amber" assert prefs["theme"] == "chalktone"
assert prefs["reduced_motion"] is False assert prefs["reduced_motion"] is False
@@ -151,7 +151,7 @@ class TestSafeFailures:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}): with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences() prefs = load_preferences()
assert prefs["theme"] == "amber" assert prefs["theme"] == "chalktone"
assert prefs["reduced_motion"] is False assert prefs["reduced_motion"] is False
def test_unknown_theme_returns_default(self, tmp_path): def test_unknown_theme_returns_default(self, tmp_path):
@@ -165,7 +165,7 @@ class TestSafeFailures:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}): with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences() prefs = load_preferences()
assert prefs["theme"] == "amber" assert prefs["theme"] == "chalktone"
def test_missing_keys_get_defaults(self, tmp_path): def test_missing_keys_get_defaults(self, tmp_path):
"""Partial preference file fills in missing keys.""" """Partial preference file fills in missing keys."""
@@ -218,7 +218,7 @@ class TestSafeFailures:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}): with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences() prefs = load_preferences()
assert prefs["theme"] == "amber" assert prefs["theme"] == "chalktone"
def test_wrong_type_for_reduced_motion(self, tmp_path): def test_wrong_type_for_reduced_motion(self, tmp_path):
"""Non-boolean reduced_motion falls back to default.""" """Non-boolean reduced_motion falls back to default."""
+53
View File
@@ -88,6 +88,33 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start)) ensure_period_open(conn, datetime.fromisoformat(start))
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
utc_start=None, utc_end=None, bw=1024*1024*100,
br=0, coverage=0.95, samples=24, complete=True):
"""Insert a local_days row (issue #94 gate prerequisite)."""
if utc_start is None:
utc_start = local_date + "T00:00:00+00:00"
if utc_end is None:
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
conn.commit()
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
"""Insert multiple complete local days to satisfy the issue #94 gate."""
for i in range(count):
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_local_day(conn, d, bw=bw)
class TestPrecedence: class TestPrecedence:
def test_no_baseline_unavailable(self, store): def test_no_baseline_unavailable(self, store):
_insert_segment(store) _insert_segment(store)
@@ -127,6 +154,7 @@ class TestConfidenceRuleTable:
def test_unavailable_no_baseline(self, store): def test_unavailable_no_baseline(self, store):
_insert_segment(store) _insert_segment(store)
_open_period(store) _open_period(store)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert any("no applicable endurance baseline" in f for f in result.contributing_facts) assert any("no applicable endurance baseline" in f for f in result.contributing_facts)
@@ -138,6 +166,7 @@ class TestConfidenceRuleTable:
for i in range(20): for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=0) _insert_day(store, d, bw=0)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert any("no finite projection" in f for f in result.contributing_facts) assert any("no finite projection" in f for f in result.contributing_facts)
@@ -150,12 +179,14 @@ class TestConfidenceRuleTable:
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=1024*1024*100) _insert_day(store, d, bw=1024*1024*100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.LIMITED assert result.confidence_state == ConfidenceState.LIMITED
assert any("regime only" in f and "days old" in f for f in result.contributing_facts) assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
def test_limited_degraded_identity(self, store): def test_limited_degraded_identity(self, store):
_insert_baseline(store, tbw_tb=1.0, verified=True) _insert_baseline(store, tbw_tb=1.0, verified=True)
_insert_complete_local_days(store, "2026-09-29", 1)
_insert_segment(store, degraded=True) _insert_segment(store, degraded=True)
_open_period(store) _open_period(store)
for i in range(20): for i in range(20):
@@ -201,6 +232,7 @@ class TestZeroRate:
for i in range(20): for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=0) _insert_day(store, d, bw=0)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert any("no finite projection from this history" in f for f in result.contributing_facts) assert any("no finite projection from this history" in f for f in result.contributing_facts)
@@ -385,6 +417,7 @@ class TestSustainedRegimeRate:
d = (datetime(2026, 6, 1) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 6, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw) _insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-28T12:00:00+00:00", pu=5) _insert_sample(store, "2026-09-28T12:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=30, hour=12)) result = compute_projection(store, _clock(year=2026, month=9, day=30, hour=12))
# Regime should be capped at 90 days (from Jun 1 to Sep 30 = 90 days at cutoff) # Regime should be capped at 90 days (from Jun 1 to Sep 30 = 90 days at cutoff)
# The 90-day cutoff is Sep 30 - 90 = Jul 1, so regime starts Jul 1 # The 90-day cutoff is Sep 30 - 90 = Jul 1, so regime starts Jul 1
@@ -447,6 +480,7 @@ class TestHabitChange:
d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw_high) _insert_day(store, d, bw=bw_high)
_insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-07", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12)) result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12))
assert result.habit_change_fact is not None assert result.habit_change_fact is not None
assert "usage habit changed" in result.habit_change_fact assert "usage habit changed" in result.habit_change_fact
@@ -468,6 +502,7 @@ class TestHabitChange:
d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw_low) _insert_day(store, d, bw=bw_low)
_insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-07", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12)) result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12))
assert result.habit_change_fact is not None assert result.habit_change_fact is not None
assert "usage habit changed" in result.habit_change_fact assert "usage habit changed" in result.habit_change_fact
@@ -524,6 +559,7 @@ class TestHabitChange:
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw) _insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.LIMITED assert result.confidence_state == ConfidenceState.LIMITED
assert any("regime only" in f and "days old" in f for f in result.contributing_facts) assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
@@ -544,6 +580,7 @@ class TestWarmingGate:
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw, coverage=0.95) _insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
assert result.warming_fact is not None assert result.warming_fact is not None
assert "warming up" in result.warming_fact assert "warming up" in result.warming_fact
@@ -560,6 +597,7 @@ class TestWarmingGate:
cov = 0.30 if i < 3 else 0.95 cov = 0.30 if i < 3 else 0.95
_insert_day(store, d, bw=bw, coverage=cov) _insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
assert result.warming_fact is not None assert result.warming_fact is not None
assert "warming up" in result.warming_fact assert "warming up" in result.warming_fact
@@ -589,6 +627,7 @@ class TestWarmingGate:
cov = 0.30 if i < 3 else 0.95 cov = 0.30 if i < 3 else 0.95
_insert_day(store, d, bw=bw, coverage=cov) _insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
assert result.warming_fact is not None assert result.warming_fact is not None
@@ -602,6 +641,7 @@ class TestWarmingGate:
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw, coverage=0.95) _insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
# Should have warming fact but still render # Should have warming fact but still render
assert result.warming_fact is not None assert result.warming_fact is not None
@@ -614,6 +654,7 @@ class TestWarmingGate:
_insert_segment(store) _insert_segment(store)
_open_period(store) _open_period(store)
_insert_day(store, "2026-09-28", bw=100*1024*1024) _insert_day(store, "2026-09-28", bw=100*1024*1024)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.headline_remaining_seconds is None assert result.headline_remaining_seconds is None
@@ -626,6 +667,7 @@ class TestWarmingGate:
for i in range(20): for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=0) _insert_day(store, d, bw=0)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.headline_remaining_seconds is None assert result.headline_remaining_seconds is None
@@ -646,6 +688,7 @@ class TestStalenessDrop:
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw, coverage=0.95) _insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
# Clock is 3 days after last data → staleness > 48h # Clock is 3 days after last data → staleness > 48h
clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc) clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
result = compute_projection(store, clock) result = compute_projection(store, clock)
@@ -661,6 +704,7 @@ class TestStalenessDrop:
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw, coverage=0.95) _insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc) clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
result = compute_projection(store, clock) result = compute_projection(store, clock)
assert result.staleness_fact is not None assert result.staleness_fact is not None
@@ -703,6 +747,7 @@ class TestSegmentBreakProjection:
d = (datetime(2026, 9, 16) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 16) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw) _insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-20T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-20T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-19", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=20, hour=12)) result = compute_projection(store, _clock(year=2026, month=9, day=20, hour=12))
# Prior days exist in store but projection uses current segment # Prior days exist in store but projection uses current segment
# 5 days in segment → regime_days = 5 # 5 days in segment → regime_days = 5
@@ -727,6 +772,7 @@ class TestSegmentBreakProjection:
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw) _insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-27", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12)) result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12))
# Young regime (3 days) → Limited, not enough data for full confidence # Young regime (3 days) → Limited, not enough data for full confidence
assert result.confidence_state == ConfidenceState.LIMITED assert result.confidence_state == ConfidenceState.LIMITED
@@ -753,6 +799,7 @@ class TestSegmentBreakProjection:
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw) _insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-27", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12)) result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12))
# Prior history quarantined; only 3 days in new segment # Prior history quarantined; only 3 days in new segment
assert result.regime_days is not None assert result.regime_days is not None
@@ -771,6 +818,7 @@ class TestDegradedIdentity:
_insert_segment(store, identity_key=None, degraded=True) _insert_segment(store, identity_key=None, degraded=True)
_open_period(store) _open_period(store)
_insert_day(store, "2026-09-28", bw=100*1024*1024) _insert_day(store, "2026-09-28", bw=100*1024*1024)
_insert_complete_local_days(store, "2026-09-29", 1)
# No baseline → Unavailable # No baseline → Unavailable
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED assert result.confidence_state == ConfidenceState.UNSUPPORTED
@@ -787,6 +835,7 @@ class TestDegradedIdentity:
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw, coverage=0.95) _insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock()) result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.LIMITED assert result.confidence_state == ConfidenceState.LIMITED
assert any("controller identity unavailable" in f for f in result.contributing_facts) assert any("controller identity unavailable" in f for f in result.contributing_facts)
@@ -802,6 +851,7 @@ class TestDegradedIdentity:
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw, coverage=0.95) _insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
# Stale clock (>48h) # Stale clock (>48h)
clock = datetime(2026, 10, 5, 12, 0, 0, tzinfo=timezone.utc) clock = datetime(2026, 10, 5, 12, 0, 0, tzinfo=timezone.utc)
result = compute_projection(store, clock) result = compute_projection(store, clock)
@@ -857,6 +907,7 @@ class TestIdentityChangeBlankKeys:
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw) _insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-25", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12)) result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
# Prior history quarantined; only 5 days in new segment # Prior history quarantined; only 5 days in new segment
assert result.regime_days is not None assert result.regime_days is not None
@@ -880,6 +931,7 @@ class TestIdentityChangeBlankKeys:
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw) _insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-25", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12)) result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
assert result.regime_days is not None assert result.regime_days is not None
assert result.regime_days <= 5 assert result.regime_days <= 5
@@ -895,6 +947,7 @@ class TestIdentityChangeBlankKeys:
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw) _insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5) _insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-25", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12)) result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
# All 25 days in same segment (equal blanks continue) # All 25 days in same segment (equal blanks continue)
assert result.regime_days is not None assert result.regime_days is not None
+17 -15
View File
@@ -27,9 +27,14 @@ def store_conn(tmp_path: Path):
def _insert_sample(conn, ts_iso, device="/dev/nvme0"): def _insert_sample(conn, ts_iso, device="/dev/nvme0"):
conn.execute(
"INSERT OR IGNORE INTO controller_segments "
"(id, opened_at, identity_key) VALUES (1, '2026-01-01T00:00:00+00:00', 'test')"
)
conn.execute( conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, " "INSERT INTO samples (ts, device, data_units_written, data_units_read, "
" bytes_written, bytes_read, percentage_used) VALUES (?, ?, 0, 0, 0, 0, 0)", " bytes_written, bytes_read, percentage_used, segment_id, local_tz) "
"VALUES (?, ?, 0, 0, 0, 0, 0, 1, 'UTC')",
(ts_iso, device), (ts_iso, device),
) )
conn.commit() conn.commit()
@@ -50,33 +55,30 @@ class TestPruneOldSamples:
cursor = store_conn.execute("SELECT COUNT(*) FROM samples") cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 1 assert cursor.fetchone()[0] == 1
def test_removes_old_samples(self, store_conn): def test_keeps_old_samples_without_replacement_evidence(self, store_conn):
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc) now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
# Insert samples at 10, 14, and 15 days ago # Old samples stay until UTC and local-day evidence replace them.
# All three are before the cutoff (2026-09-01T12:00:00)
# The 15-day-old sample is not a boundary anchor because
# the next sample (14 days ago) is also before the cutoff
for days_ago in [10, 14, 15]: for days_ago in [10, 14, 15]:
ts = (now - timedelta(days=days_ago)).isoformat() ts = (now - timedelta(days=days_ago)).isoformat()
_insert_sample(store_conn, ts) _insert_sample(store_conn, ts)
pruned = prune_old_samples(store_conn, now, retention_days=14) pruned = prune_old_samples(store_conn, now, retention_days=14)
assert pruned == 1 # Only the 15-day-old sample removed assert pruned == 0
cursor = store_conn.execute("SELECT COUNT(*) FROM samples") cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 2 assert cursor.fetchone()[0] == 3
def test_removes_many_old_samples(self, store_conn): def test_keeps_many_old_samples_without_replacement_evidence(self, store_conn):
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc) now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
for days_ago in range(1, 30): for days_ago in range(1, 30):
ts = (now - timedelta(days=days_ago)).isoformat() ts = (now - timedelta(days=days_ago)).isoformat()
_insert_sample(store_conn, ts) _insert_sample(store_conn, ts)
pruned = prune_old_samples(store_conn, now, retention_days=14) pruned = prune_old_samples(store_conn, now, retention_days=14)
assert pruned == 15 # Days 15-29 removed assert pruned == 0
cursor = store_conn.execute("SELECT COUNT(*) FROM samples") cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 14 # Days 1-14 kept assert cursor.fetchone()[0] == 29
def test_empty_store_no_error(self, store_conn): def test_empty_store_no_error(self, store_conn):
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc) now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
@@ -130,8 +132,8 @@ class TestPruneOldSamples:
) )
assert cursor.fetchone()[0] == 1 assert cursor.fetchone()[0] == 1
def test_old_sample_with_derived_interval_removed(self, store_conn): def test_hour_rows_alone_do_not_replace_local_day_evidence(self, store_conn):
"""Old samples with fully derived intervals are removed.""" """An hour row alone cannot authorize source-sample deletion."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc) now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample with derived interval # Old sample with derived interval
@@ -148,8 +150,8 @@ class TestPruneOldSamples:
# Run pruning # Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14) pruned = prune_old_samples(store_conn, now, retention_days=14)
# Old sample should be removed (interval is derived) # Keep the source samples until local-day replacement evidence exists.
cursor = store_conn.execute( cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'" "SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
) )
assert cursor.fetchone()[0] == 0 assert cursor.fetchone()[0] == 1
+7 -8
View File
@@ -235,8 +235,8 @@ class TestBoundaryAnchorRetention:
# the interval spans the retention boundary # the interval spans the retention boundary
assert needs_boundary_anchor(store_conn, "2026-09-15T23:55:00+00:00", now) assert needs_boundary_anchor(store_conn, "2026-09-15T23:55:00+00:00", now)
def test_not_boundary_anchor_if_fully_derived(self, store_conn): def test_hour_only_derivation_does_not_replace_local_day_evidence(self, store_conn):
"""Sample that's fully derived is not a boundary anchor.""" """UTC hour evidence alone does not make a sample safe to prune."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc) now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Insert sample and fully derive its interval # Insert sample and fully derive its interval
@@ -247,8 +247,7 @@ class TestBoundaryAnchorRetention:
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00", _insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
bytes_written_delta=1000000) bytes_written_delta=1000000)
# Not a boundary anchor assert needs_boundary_anchor(store_conn, "2026-09-14T10:00:00+00:00", now)
assert not needs_boundary_anchor(store_conn, "2026-09-14T10:00:00+00:00", now)
def test_pruning_retains_boundary_anchors(self, store_conn): def test_pruning_retains_boundary_anchors(self, store_conn):
"""Pruning keeps samples needed as boundary anchors.""" """Pruning keeps samples needed as boundary anchors."""
@@ -274,8 +273,8 @@ class TestBoundaryAnchorRetention:
) )
assert cursor.fetchone()[0] == 1 assert cursor.fetchone()[0] == 1
def test_pruning_removes_old_sample_with_derived_interval(self, store_conn): def test_pruning_keeps_samples_without_local_day_replacement(self, store_conn):
"""Pruning removes old samples when interval is fully derived.""" """Pruning keeps samples when only the UTC-hour row exists."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc) now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample with derived interval # Old sample with derived interval
@@ -289,11 +288,11 @@ class TestBoundaryAnchorRetention:
# Run pruning # Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14) pruned = prune_old_samples(store_conn, now, retention_days=14)
# Old sample should be removed (interval is derived) # The source remains until local-day evidence is also durable.
cursor = store_conn.execute( cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'" "SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
) )
assert cursor.fetchone()[0] == 0 assert cursor.fetchone()[0] == 1
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+8 -8
View File
@@ -870,7 +870,7 @@ class TestRefreshTiming:
_insert_segment(conn) _insert_segment(conn)
_open_period(conn) _open_period(conn)
now = _clock() now = _clock()
ts = (now - timedelta(minutes=10)).isoformat() ts = (now - timedelta(minutes=4)).isoformat()
_insert_sample(conn, ts) _insert_sample(conn, ts)
for i in range(20): for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
@@ -878,27 +878,27 @@ class TestRefreshTiming:
svc = { svc = {
"boot_enabled": True, "timer_active": True, "boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 600, "last_collect_ok": True, "last_collect_age_s": 240,
"last_collect_reason": None, "last_collect_reason": None,
} }
# Sample is 10 min old (600s) → fresh (within 690s threshold) # Sample is 4 min old (240s) → fresh (within 450s threshold)
comp1 = compose_status(conn, svc, now, store_fault=None, newer_schema=None) comp1 = compose_status(conn, svc, now, store_fault=None, newer_schema=None)
assert comp1.freshness == "fresh" assert comp1.freshness == "fresh"
assert comp1.state == StatusState.MONITORING assert comp1.state == StatusState.MONITORING
# At now + 1 min → sample is 11 min old (660s) → still fresh # At now + 3 min → sample is 7 min old (420s) → still fresh
now2 = now + timedelta(minutes=1) now2 = now + timedelta(minutes=3)
comp2 = compose_status(conn, svc, now2, store_fault=None, newer_schema=None) comp2 = compose_status(conn, svc, now2, store_fault=None, newer_schema=None)
assert comp2.freshness == "fresh" assert comp2.freshness == "fresh"
# At now + 2 min → sample is 12 min old (720s) → missed (past 690s) # At now + 5 min → sample is 9 min old (540s) → missed (past 450s)
now3 = now + timedelta(minutes=2) now3 = now + timedelta(minutes=5)
comp3 = compose_status(conn, svc, now3, store_fault=None, newer_schema=None) comp3 = compose_status(conn, svc, now3, store_fault=None, newer_schema=None)
assert comp3.freshness == "missed" assert comp3.freshness == "missed"
assert comp3.state == StatusState.MONITORING assert comp3.state == StatusState.MONITORING
# At now + 49 hours → sample is 49h 10min old → stale # At now + 49 hours → sample is 49h 4min old → stale
now4 = now + timedelta(hours=49) now4 = now + timedelta(hours=49)
comp4 = compose_status(conn, svc, now4, store_fault=None, newer_schema=None) comp4 = compose_status(conn, svc, now4, store_fault=None, newer_schema=None)
assert comp4.freshness == "stale" assert comp4.freshness == "stale"
+1 -1
View File
@@ -67,7 +67,7 @@ async def test_cli_tui_acquisition_parity(tmp_path, kind, service):
assert not app.query_one("#main-grid").has_class("paused") assert not app.query_one("#main-grid").has_class("paused")
await pilot.resize_terminal(60, 18) await pilot.resize_terminal(60, 18)
await pilot.pause() await pilot.pause()
assert phrase in str(app.query_one("#constrained-summary").render()).lower() assert phrase in str(app.query_one("#live-render").render()).lower()
for text in (cli, strip): for text in (cli, strip):
assert ("boot: enabled" if service == ACTIVE else "boot: disabled" if service == DISABLED else "boot: unknown") in text 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 assert ("timer: active" if service == ACTIVE else "timer: inactive" if service == DISABLED else "timer: unknown") in text
+9 -9
View File
@@ -2,7 +2,7 @@
Covers: Covers:
- Three valid presets: Amber, Nord, High Contrast - Three valid presets: Amber, Nord, High Contrast
- Amber is the default with amber graph role - Chalktone is the default; Amber retains its amber graph role
- Theme roles for graph rendering (allocated, unallocated, gap, zero, partial) - Theme roles for graph rendering (allocated, unallocated, gap, zero, partial)
- Status semantic colours/glyphs/text always win over theme - Status semantic colours/glyphs/text always win over theme
- Global action reachability and focus contrast in every preset - Global action reachability and focus contrast in every preset
@@ -29,13 +29,13 @@ from fenris.themes import (
class TestThemeRegistry: class TestThemeRegistry:
"""All three presets are registered with correct names.""" """All three presets are registered with correct names."""
def test_three_themes_registered(self): def test_four_themes_registered(self):
"""Exactly three themes exist.""" """All four presets are registered."""
assert len(THEMES) == 3 assert len(THEMES) == 4
def test_theme_names(self): def test_theme_names(self):
"""Theme names are amber, nord, high_contrast.""" """Theme names are amber, nord, high_contrast."""
assert THEME_NAMES == {"amber", "nord", "high_contrast"} assert THEME_NAMES == {"chalktone", "amber", "nord", "high_contrast"}
def test_get_theme_valid(self): def test_get_theme_valid(self):
"""get_theme returns a Theme for each valid name.""" """get_theme returns a Theme for each valid name."""
@@ -44,14 +44,14 @@ class TestThemeRegistry:
theme = get_theme(name) theme = get_theme(name)
assert isinstance(theme, Theme) assert isinstance(theme, Theme)
def test_get_theme_invalid_returns_amber(self): def test_get_theme_invalid_returns_chalktone(self):
"""Unknown theme name returns the amber theme.""" """Unknown theme name returns the amber theme."""
theme = get_theme("nonexistent") theme = get_theme("nonexistent")
assert theme.name == "fenris-amber" assert theme.name == "fenris-chalktone"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Amber theme tests (default, amber graph role) # Amber theme tests (amber graph role)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAmberTheme: class TestAmberTheme:
@@ -179,7 +179,7 @@ class TestGraphColorRoles:
assert amber["allocated"] != nord["allocated"] or amber["allocated"] != hc["allocated"] assert amber["allocated"] != nord["allocated"] or amber["allocated"] != hc["allocated"]
def test_zero_role_is_dim(self): def test_zero_role_is_dim(self):
"""Zero-usage bars use a dim/subtle colour in all themes.""" """Zero-usage markers use a dim/subtle colour in all themes."""
for name in THEME_NAMES: for name in THEME_NAMES:
colors = get_graph_colors(name) colors = get_graph_colors(name)
# Zero should be distinct from allocated # Zero should be distinct from allocated
+176 -91
View File
@@ -40,19 +40,13 @@ from fenris.status import (
) )
from fenris.tui import ( from fenris.tui import (
FenrisTuiApp, FenrisTuiApp,
DailyBarGraph, HistoryGraph,
_format_remaining, _format_remaining,
_query_drive_health, _query_drive_health,
_query_daily_graph_data, _query_daily_graph_data,
_query_hourly_graph_data, _query_hourly_graph_data,
_RANGE_OPTIONS, _RANGE_OPTIONS,
_RANGE_DEFAULT, _RANGE_DEFAULT,
_BAR_HEIGHT,
_GLYPH_ALLOCATED,
_GLYPH_UNALLOCATED,
_GLYPH_GAP,
_GLYPH_ZERO,
_GLYPH_PARTIAL,
_MIN_WIDTH, _MIN_WIDTH,
_MIN_HEIGHT, _MIN_HEIGHT,
) )
@@ -115,6 +109,34 @@ def _insert_sample(conn, ts, pu=5, device="/dev/nvme0n1"):
def _open_period(conn, start="2026-09-01T00:00:00+00:00"): def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start)) ensure_period_open(conn, datetime.fromisoformat(start))
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, activity_intervals) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
(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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -184,6 +206,7 @@ class TestStateMatrix:
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100) _insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00") _insert_sample(conn, "2026-09-30T10:00:00+00:00")
_insert_complete_local_days(conn, "2026-09-29", 1)
proj = compute_projection(conn, _clock()) proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.LIMITED assert proj.confidence_state == ConfidenceState.LIMITED
assert proj.headline_remaining_seconds is not None assert proj.headline_remaining_seconds is not None
@@ -199,6 +222,7 @@ class TestStateMatrix:
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d") d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24) _insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
_insert_sample(conn, "2026-09-30T10:00:00+00:00") _insert_sample(conn, "2026-09-30T10:00:00+00:00")
_insert_complete_local_days(conn, "2026-09-29", 1)
proj = compute_projection(conn, _clock()) proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.SUPPORTED assert proj.confidence_state == ConfidenceState.SUPPORTED
assert proj.headline_remaining_seconds is not None assert proj.headline_remaining_seconds is not None
@@ -336,15 +360,15 @@ class TestDenseScreen:
}): }):
async with app.run_test(): async with app.run_test():
strip = str(app.query_one("#service-strip").render()).lower() strip = str(app.query_one("#service-strip").render()).lower()
rail = str(app.query_one("#quit-rail").render()) rail = str(app.query_one("#action-rail").render())
usage = app.query_one("#usage-history") usage = app.query_one("#activity-panel")
service = app.query_one("#service-strip") service = app.query_one("#service-strip")
quit_rail = app.query_one("#quit-rail") quit_rail = app.query_one("#action-rail")
assert "continuity" in strip assert "continuity" in strip
assert "monitoring: active in background · persists across reboots" in strip assert "monitoring: active in background · persists across reboots" in strip
assert "r resume — enable monitoring and future boots" in strip assert "r Resume" in str(app.query_one("#action-rail").render())
assert "q quit" not in strip assert "q quit" not in strip
assert rail == "q Quit TUI" assert "q Quit TUI" in rail
assert usage.region.y < service.region.y < quit_rail.region.y assert usage.region.y < service.region.y < quit_rail.region.y
assert usage.region.bottom <= service.region.y assert usage.region.bottom <= service.region.y
assert service.region.bottom <= quit_rail.region.y assert service.region.bottom <= quit_rail.region.y
@@ -376,13 +400,12 @@ class TestDenseScreen:
main_grid = app.query_one("#main-grid") main_grid = app.query_one("#main-grid")
assert str(main_grid.styles.layout) == "<grid>" assert str(main_grid.styles.layout) == "<grid>"
assert main_grid.has_class("paused") assert main_grid.has_class("paused")
assert len(main_grid.styles.grid_rows) == 5
banner = str(paused_banner.render()).lower() banner = str(paused_banner.render()).lower()
assert "monitoring: paused — deliberate disable" in banner assert "monitoring: paused — deliberate disable" in banner
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner
assert paused_banner.region.height >= 5 assert paused_banner.region.height >= 2
assert paused_banner.region.y < app.query_one("#usage-history").region.y assert paused_banner.region.y < app.query_one("#activity-panel").region.y
assert app.query_one("#usage-history").region.bottom <= app.query_one( assert app.query_one("#activity-panel").region.bottom <= app.query_one(
"#service-strip" "#service-strip"
).region.y ).region.y
screenshot = app.export_screenshot() screenshot = app.export_screenshot()
@@ -394,11 +417,6 @@ class TestDenseScreen:
assert "monitoring: does not start on next boot" in str( assert "monitoring: does not start on next boot" in str(
app.query_one("#service-strip").render() app.query_one("#service-strip").render()
).lower() ).lower()
dashboard_scroll = app.query_one("#dashboard-scroll")
assert dashboard_scroll.max_scroll_y > 0
dashboard_scroll.focus()
await pilot.press("end")
assert dashboard_scroll.scroll_y == dashboard_scroll.max_scroll_y
footer_text = " ".join( footer_text = " ".join(
"".join( "".join(
ElementTree.fromstring(app.export_screenshot()).itertext() ElementTree.fromstring(app.export_screenshot()).itertext()
@@ -461,23 +479,23 @@ class TestDenseScreen:
async with app.run_test() as pilot: async with app.run_test() as pilot:
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
# Identity now shows Fenris by Bongbetic (issue #79) # Identity now shows Fenris by Bongbetic (issue #79)
assert "Fenris by Bongbetic" in headline assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
assert auth_notice in headline assert auth_notice in str(app.query_one("#auth-notice").render())
# Maker credit removed from service strip (issue #79) # Maker credit removed from service strip (issue #79)
assert "by Bongbetic" not in str(app.query_one("#service-strip").render()) assert "by Bongbetic" not in str(app.query_one("#service-strip").render())
await pilot.pause(0.25) await pilot.pause(0.25)
assert auth_notice not in str(app.query_one("#headline-band").render()) assert not app.query_one("#auth-notice").display
await pilot.pause(0.25) await pilot.pause(0.25)
assert auth_notice not in str(app.query_one("#headline-band").render()) assert not app.query_one("#auth-notice").display
fresh_app = FenrisTuiApp( fresh_app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db", store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2, refresh_interval_s=0.2,
) )
async with fresh_app.run_test(): async with fresh_app.run_test():
assert auth_notice in str(fresh_app.query_one("#headline-band").render()) assert auth_notice in str(fresh_app.query_one("#auth-notice").render())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -571,7 +589,7 @@ class TestFirstRun:
strip = str(app.query_one("#service-strip").render()) strip = str(app.query_one("#service-strip").render())
assert "no observations yet" in headline.lower() assert "no observations yet" in headline.lower()
assert "r resume — enable monitoring and future boots" in headline.lower() assert "r resume — enable monitoring and future boots" in headline.lower()
assert "r resume — enable monitoring and future boots" in strip.lower() assert "r resume" in str(app.query_one("#action-rail").render()).lower()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -771,12 +789,29 @@ class TestQueryDailyGraphData:
assert result[0]["is_gap"] is True assert result[0]["is_gap"] is True
conn.close() conn.close()
def test_window_is_consecutive_utc_days_with_explicit_gaps(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_day(conn, "2026-09-14", bw=500)
result = _query_daily_graph_data(
conn, datetime(2026, 9, 16, 12, tzinfo=timezone.utc),
)
assert len(result) == 90
assert result[-3]["day"] == "2026-09-14"
assert result[-2]["day"] == "2026-09-15"
assert result[-2]["is_gap"] is True
assert result[-1]["day"] == "2026-09-16"
assert result[-1]["is_partial"] is True
conn.close()
class TestQueryHourlyGraphData: class TestQueryHourlyGraphData:
def test_empty_day(self, tmp_path): def test_empty_day(self, tmp_path):
conn = init_store(tmp_path / "test.db") conn = init_store(tmp_path / "test.db")
result = _query_hourly_graph_data(conn, "2026-09-20") result = _query_hourly_graph_data(
assert result == [] conn, "2026-09-20", datetime(2026, 9, 21, tzinfo=timezone.utc),
)
assert len(result) == 24
assert all(item["is_gap"] for item in result)
conn.close() conn.close()
def test_with_hours(self, tmp_path): def test_with_hours(self, tmp_path):
@@ -795,22 +830,38 @@ class TestQueryHourlyGraphData:
def test_hour_labels(self, tmp_path): def test_hour_labels(self, tmp_path):
conn = init_store(tmp_path / "test.db") conn = init_store(tmp_path / "test.db")
_insert_hour(conn, "2026-09-20T12:00:00+00:00", bw=1000) _insert_hour(conn, "2026-09-20T12:00:00+00:00", bw=1000)
result = _query_hourly_graph_data(conn, "2026-09-20") result = _query_hourly_graph_data(
assert len(result) == 1 conn, "2026-09-20", datetime(2026, 9, 21, tzinfo=timezone.utc),
assert result[0]["local_label"] == "12" )
assert len(result) == 24
assert result[12]["local_label"] == "12"
assert result[12]["bytes_written"] == 1000
conn.close()
def test_current_hour_is_partial_and_future_hours_are_not_gaps(self, tmp_path):
conn = init_store(tmp_path / "test.db")
result = _query_hourly_graph_data(
conn, "2026-09-20",
datetime(2026, 9, 20, 14, 30, tzinfo=timezone.utc),
)
assert result[13]["is_gap"] is True
assert result[14]["is_partial"] is True
assert result[14]["is_gap"] is True
assert result[15]["is_future"] is True
assert result[15]["is_gap"] is False
conn.close() conn.close()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# DailyBarGraph widget unit tests (issue #75) # HistoryGraph widget unit tests (issue #75)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDailyBarGraph: class TestHistoryGraph:
def test_empty_data(self, tmp_path): def test_empty_data(self, tmp_path):
"""Empty data shows awaiting message.""" """Empty data shows awaiting message."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
# We test the widget directly via the app's compose # We test the widget directly via the app's compose
graph = DailyBarGraph() graph = HistoryGraph()
# Simulate setting empty data # Simulate setting empty data
from textual.app import App as TextualApp from textual.app import App as TextualApp
class _TestApp(TextualApp): class _TestApp(TextualApp):
@@ -822,14 +873,14 @@ class TestDailyBarGraph:
def test_range_default(self): def test_range_default(self):
"""Default range is 14 days.""" """Default range is 14 days."""
graph = DailyBarGraph() graph = HistoryGraph()
assert graph.range_days == _RANGE_DEFAULT assert graph.range_days == _RANGE_DEFAULT
assert graph.selected_index == -1 assert graph.selected_index == -1
assert graph.view_mode == "daily" assert graph.view_mode == "daily"
def test_trim_to_range(self): def test_trim_to_range(self):
"""Data is trimmed to the selected range.""" """Data is trimmed to the selected range."""
graph = DailyBarGraph() graph = HistoryGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100} data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 31)] for d in range(1, 31)]
graph._all_day_data = data graph._all_day_data = data
@@ -840,7 +891,7 @@ class TestDailyBarGraph:
def test_range_all_fits(self): def test_range_all_fits(self):
"""When data fits within range, all days are shown.""" """When data fits within range, all days are shown."""
graph = DailyBarGraph() graph = HistoryGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100} data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 8)] for d in range(1, 8)]
graph._all_day_data = data graph._all_day_data = data
@@ -848,13 +899,24 @@ class TestDailyBarGraph:
graph._trim_to_range() graph._trim_to_range()
assert len(graph._day_data) == 7 assert len(graph._day_data) == 7
def test_scale_includes_allocated_and_unallocated_writes(self):
graph = HistoryGraph()
graph._all_day_data = [{
"day": "2026-09-16",
"total_bytes": 800,
"allocated_bytes": 500,
"unallocated_bytes": 300,
}]
graph._trim_to_range()
assert graph._max_bytes == 800
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Bar graph headless TUI tests (issue #75) # Bar graph headless TUI tests (issue #75)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestBarGraphTUI: class TestBarGraphTUI:
"""Headless tests for the interactive bar graph.""" """Headless tests for the interactive history plot."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_graph_renders_with_data(self, tmp_path): async def test_graph_renders_with_data(self, tmp_path):
@@ -870,13 +932,21 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph) assert isinstance(graph, HistoryGraph)
assert len(graph._day_data) > 0 assert len(graph._day_data) > 0
# Legend should be visible # Legend should be visible
legend = str(app.query_one("#bar-legend").render()) legend = str(app.query_one("#bar-legend").render())
assert "Alloc" in legend assert "Unalloc" in legend
assert "Zero" in legend assert "Zero" in legend
range_label = str(app.query_one("#bar-range").render())
assert "14 days" in range_label
assert "UTC" in range_label
plotted = str(app.query_one("#bar-render").render())
assert "Writes (" in legend
assert "UTC" in range_label
assert "─" in plotted
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_arrow_selection(self, tmp_path): async def test_arrow_selection(self, tmp_path):
@@ -892,25 +962,22 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
app.set_focus(graph) app.set_focus(graph)
await pilot.pause() await pilot.pause()
await pilot.pause() await pilot.pause()
# Right arrow selects first bar # The newest day is selected by default; left moves backward.
await pilot.press("right") initial = graph.selected_index
await pilot.pause()
assert graph.selected_index == 0
# Right again moves to second
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 1
# Left moves back
await pilot.press("left") await pilot.press("left")
await pilot.pause() await pilot.pause()
assert graph.selected_index == 0 assert graph.selected_index == initial - 1
# Right returns to the newest day.
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == initial
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_range_switching(self, tmp_path): async def test_range_switching(self, tmp_path):
@@ -926,6 +993,7 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
app.set_focus(graph) app.set_focus(graph)
await pilot.pause() await pilot.pause()
@@ -941,17 +1009,17 @@ class TestBarGraphTUI:
assert graph.range_days == 7 assert graph.range_days == 7
assert len(graph._day_data) == 7 assert len(graph._day_data) == 7
# Press 3 for 28-day range # Press 3 for 30-day range
await pilot.press("3") await pilot.press("3")
await pilot.pause() await pilot.pause()
assert graph.range_days == 28 assert graph.range_days == 30
assert len(graph._day_data) == 28 assert len(graph._day_data) == 30
# Press 4 for 90-day range (only 30 days available) # Exact windows retain gap slots instead of compressing time.
await pilot.press("4") await pilot.press("4")
await pilot.pause() await pilot.pause()
assert graph.range_days == 90 assert graph.range_days == 90
assert len(graph._day_data) == 30 assert len(graph._day_data) == 90
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_readout_updates_on_selection(self, tmp_path): async def test_readout_updates_on_selection(self, tmp_path):
@@ -967,17 +1035,19 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
app.set_focus(graph) app.set_focus(graph)
await pilot.pause() await pilot.pause()
await pilot.pause() await pilot.pause()
# No selection initially # Newest day is selected initially.
readout = str(app.query_one("#bar-readout").render()) readout = str(app.query_one("#bar-readout").render())
assert "select" in readout.lower() assert "UTC" in readout
assert "GB" in readout
# Select first bar # Moving left updates the selected-day readout.
await pilot.press("right") await pilot.press("left")
await pilot.pause() await pilot.pause()
readout = str(app.query_one("#bar-readout").render()) readout = str(app.query_one("#bar-readout").render())
assert "2026-09" in readout assert "2026-09" in readout
@@ -1002,15 +1072,14 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
app.set_focus(graph) app.set_focus(graph)
await pilot.pause() await pilot.pause()
await pilot.pause() await pilot.pause()
# Select a day # The newest day is selected automatically.
await pilot.press("right") assert graph.selected_index == len(graph._day_data) - 1
await pilot.pause()
assert graph.selected_index == 0
assert graph.view_mode == "daily" assert graph.view_mode == "daily"
# Enter drill-down # Enter drill-down
@@ -1020,6 +1089,14 @@ class TestBarGraphTUI:
assert graph.drill_day is not None assert graph.drill_day is not None
assert len(graph._hour_data) == 24 assert len(graph._hour_data) == 24
# Five-minute data refreshes retain the selected hour.
graph._hourly_selected = 12
selected_hour = graph._hour_data[12]["hour"]
app._refresh()
await pilot.pause()
assert graph.view_mode == "hourly"
assert graph._hour_data[graph._hourly_selected]["hour"] == selected_hour
# Esc returns to daily # Esc returns to daily
await pilot.press("escape") await pilot.press("escape")
await pilot.pause() await pilot.pause()
@@ -1031,8 +1108,9 @@ class TestBarGraphTUI:
"""Empty store shows awaiting message in graph.""" """Empty store shows awaiting message in graph."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph) assert isinstance(graph, HistoryGraph)
render = str(app.query_one("#bar-render").render()) render = str(app.query_one("#bar-render").render())
assert "awaiting" in render.lower() assert "awaiting" in render.lower()
@@ -1041,8 +1119,9 @@ class TestBarGraphTUI:
"""Graph pane has a border title.""" """Graph pane has a border title."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
assert graph.border_title == "Usage history" assert app.query_one("#activity-panel").border_title == "Drive activity"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_glyphs_in_legend(self, tmp_path): async def test_glyphs_in_legend(self, tmp_path):
@@ -1058,13 +1137,11 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
await pilot.pause() await pilot.pause()
legend = str(app.query_one("#bar-legend").render()) legend = str(app.query_one("#bar-legend").render())
assert _GLYPH_ALLOCATED in legend for meaning in ("Writes", "Unalloc", "Gap", "Zero", "Partial"):
assert _GLYPH_UNALLOCATED in legend assert meaning in legend
assert _GLYPH_GAP in legend
assert _GLYPH_ZERO in legend
assert _GLYPH_PARTIAL in legend
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1089,6 +1166,7 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
# All four regions should be visible # All four regions should be visible
assert app.query_one("#headline-band") is not None assert app.query_one("#headline-band") is not None
assert app.query_one("#usage-history") is not None assert app.query_one("#usage-history") is not None
@@ -1097,8 +1175,8 @@ class TestConstrainedLayout:
# Graph should not be in constrained mode # Graph should not be in constrained mode
assert not app._is_constrained_mode assert not app._is_constrained_mode
# Constrained summary should be hidden # Constrained summary should be hidden
summary = app.query_one("#constrained-summary") summary = app.query_one("#bar-render")
assert str(summary.styles.display) == "none" assert "Graph needs" not in str(summary.render())
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_constrained_below_80_width(self, tmp_path): async def test_constrained_below_80_width(self, tmp_path):
@@ -1115,17 +1193,18 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot: async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# Should be in constrained mode # Should be in constrained mode
assert app._is_constrained_mode assert app._is_constrained_mode
# Main grid should have constrained class # Main grid should have constrained class
main_grid = app.query_one("#main-grid") main_grid = app.query_one("#main-grid")
assert main_grid.has_class("constrained") assert main_grid.has_class("constrained")
# Constrained summary should be visible # Constrained summary should be visible
summary = app.query_one("#constrained-summary") summary = app.query_one("#bar-render")
summary_text = str(summary.render()) summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text assert "Graph needs ≥80×24" in summary_text
assert "days" in summary_text assert "days" in summary_text
assert "GB total" in summary_text assert "GB" in summary_text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_constrained_below_24_height(self, tmp_path): async def test_constrained_below_24_height(self, tmp_path):
@@ -1142,10 +1221,11 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 23)) as pilot: async with app.run_test(size=(80, 23)) as pilot:
await pilot.press("v", "v")
# Should be in constrained mode # Should be in constrained mode
assert app._is_constrained_mode assert app._is_constrained_mode
# Constrained summary should be visible # Constrained summary should be visible
summary = app.query_one("#constrained-summary") summary = app.query_one("#bar-render")
summary_text = str(summary.render()) summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text assert "Graph needs ≥80×24" in summary_text
@@ -1164,11 +1244,12 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot: async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# Start in constrained mode # Start in constrained mode
assert app._is_constrained_mode assert app._is_constrained_mode
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
# Select a day while constrained # Select a day through the visible graph while constrained.
graph.selected_index = 5 await pilot.press(*(["left"] * 8))
# Resize to normal — pilot.resize_terminal changes terminal size # Resize to normal — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(80, 24) await pilot.resize_terminal(80, 24)
@@ -1202,15 +1283,14 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
# Start in normal mode # Start in normal mode
assert not app._is_constrained_mode assert not app._is_constrained_mode
graph = app.query_one("#usage-history") graph = app.query_one("#usage-history")
# Focus and select a day # Focus and select a day
app.set_focus(graph) app.set_focus(graph)
await pilot.pause() await pilot.pause()
await pilot.press("right") assert graph.selected_index == len(graph._day_data) - 1
await pilot.pause()
assert graph.selected_index == 0
# Resize to constrained — pilot.resize_terminal changes terminal size # Resize to constrained — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(79, 24) await pilot.resize_terminal(79, 24)
@@ -1222,7 +1302,7 @@ class TestConstrainedLayout:
# Should be in constrained mode # Should be in constrained mode
assert app._is_constrained_mode assert app._is_constrained_mode
# Constrained summary should show selected day context # Constrained summary should show selected day context
summary = app.query_one("#constrained-summary") summary = app.query_one("#bar-render")
summary_text = str(summary.render()) summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text assert "Graph needs ≥80×24" in summary_text
# Selected day context should survive # Selected day context should survive
@@ -1233,8 +1313,9 @@ class TestConstrainedLayout:
"""Constrained summary shows awaiting message for empty store.""" """Constrained summary shows awaiting message for empty store."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(79, 24)) as pilot: async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
assert app._is_constrained_mode assert app._is_constrained_mode
summary = app.query_one("#constrained-summary") summary = app.query_one("#bar-render")
summary_text = str(summary.render()) summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text assert "Graph needs ≥80×24" in summary_text
assert "awaiting" in summary_text.lower() assert "awaiting" in summary_text.lower()
@@ -1254,24 +1335,25 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot: async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# All regions should exist # All regions should exist
assert app.query_one("#headline-band") is not None assert app.query_one("#headline-band") is not None
assert app.query_one("#drive-health") is not None assert app.query_one("#drive-health") is not None
assert app.query_one("#service-strip") is not None assert app.query_one("#service-strip") is not None
assert app.query_one("#quit-rail") is not None assert app.query_one("#action-rail") is not None
# Headline should contain projection # Headline should contain projection
headline = str(app.query_one("#headline-band").render()) headline = str(app.query_one("#headline-band").render())
assert "remaining" in headline.lower() or "projection" in headline.lower() assert "remaining" in headline.lower() or "projection" in headline.lower()
# Service strip should have actions # Service strip should have actions
strip = str(app.query_one("#service-strip").render()) strip = str(app.query_one("#action-rail").render())
assert "p Pause" in strip assert "p Pause" in strip
assert "r Resume" in strip assert "r Resume" in strip
assert "q quit" not in strip # Quit is in quit-rail assert "q Quit TUI" in strip
# Quit rail should be visible # Quit rail should be visible
rail = str(app.query_one("#quit-rail").render()) rail = str(app.query_one("#action-rail").render())
assert "Quit" in rail assert "Quit" in rail
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -1287,6 +1369,7 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot: async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
strip = str(app.query_one("#service-strip").render()) strip = str(app.query_one("#service-strip").render())
# Status should be visible even with long reasons # Status should be visible even with long reasons
assert "stale" in strip.lower() or "freshness" in strip.lower() assert "stale" in strip.lower() or "freshness" in strip.lower()
@@ -1306,6 +1389,7 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db") app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot: async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# Quit should still work # Quit should still work
await pilot.press("q") await pilot.press("q")
assert not app.is_running assert not app.is_running
@@ -1315,6 +1399,7 @@ class TestConstrainedLayout:
"""Theme and motion toggles work in constrained mode.""" """Theme and motion toggles work in constrained mode."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(79, 24)) as pilot: async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
assert app._is_constrained_mode assert app._is_constrained_mode
# Toggle theme # Toggle theme
await pilot.press("t") await pilot.press("t")
@@ -1334,8 +1419,8 @@ class TestConstrainedLayout:
assert _MIN_HEIGHT == 24 assert _MIN_HEIGHT == 24
def test_is_constrained_with_terminal_width(self, tmp_path): def test_is_constrained_with_terminal_width(self, tmp_path):
"""DailyBarGraph._is_constrained checks terminal width.""" """HistoryGraph._is_constrained checks terminal width."""
graph = DailyBarGraph() graph = HistoryGraph()
# Widget-level check (no terminal width) # Widget-level check (no terminal width)
assert graph._is_constrained() is False assert graph._is_constrained() is False
# Terminal width below minimum # Terminal width below minimum
+26
View File
@@ -0,0 +1,26 @@
"""Timezone detection tests."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris import tz_util
def test_relative_localtime_symlink_returns_zoneinfo_key(tmp_path, monkeypatch):
zoneinfo = tmp_path / "usr" / "share" / "zoneinfo" / "Asia" / "Kolkata"
zoneinfo.parent.mkdir(parents=True)
zoneinfo.write_bytes(b"zoneinfo")
localtime = tmp_path / "etc" / "localtime"
localtime.parent.mkdir()
localtime.symlink_to("../usr/share/zoneinfo/Asia/Kolkata")
real_path = tz_util.Path
monkeypatch.setattr(
tz_util,
"Path",
lambda path: localtime if path == "/etc/localtime" else real_path(path),
)
monkeypatch.delenv("TZ", raising=False)
assert tz_util.detect_system_tz() == "Asia/Kolkata"
+1 -1
View File
@@ -4,7 +4,7 @@ Documentation=https://git.bongbetic.com/xavierk/Fenris
[Timer] [Timer]
OnBootSec=2min OnBootSec=2min
OnUnitInactiveSec=5min OnUnitInactiveSec=3min
AccuracySec=30s AccuracySec=30s
Persistent=no Persistent=no
+2 -2
View File
@@ -9,7 +9,7 @@
# #
# Guarantees (must match systemd timer semantics): # Guarantees (must match systemd timer semantics):
# - Initial 2-minute boot delay (sleep 120 before first collect) # - Initial 2-minute boot delay (sleep 120 before first collect)
# - Completion-relative 5-minute cadence (sleep 300 after collect) # - Completion-relative 3-minute cadence (sleep 180 after collect)
# - Bounded execution (timeout 90s on each collect) # - Bounded execution (timeout 90s on each collect)
# - No catch-up (fixed sleep interval, no Persistent=) # - No catch-up (fixed sleep interval, no Persistent=)
# - Serialized runs (runsv does not restart until exit) # - Serialized runs (runsv does not restart until exit)
@@ -20,7 +20,7 @@ set -eu
COLLECT_TIMEOUT=90 COLLECT_TIMEOUT=90
BOOT_DELAY=120 BOOT_DELAY=120
CADENCE=300 CADENCE=180
LOCK_FILE=/var/lib/fenris/fenris-collect.lock LOCK_FILE=/var/lib/fenris/fenris-collect.lock
# Ensure lock directory exists # Ensure lock directory exists