Compare commits
15
Commits
v0.4.0
..
f780212a02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f780212a02 | ||
|
|
3e1ecc90e4 | ||
|
|
59d2dd2634 | ||
|
|
fd4db45ae1 | ||
|
|
5aeee7b6f1 | ||
|
|
d69753690a | ||
|
|
017a562566 | ||
|
|
7e392c4ea2 | ||
|
|
8a5ef05188 | ||
|
|
847bde1be0 | ||
|
|
0d45f263d2 | ||
|
|
7c21b044ba | ||
|
|
437ea6be73 | ||
|
|
4550dd5111 | ||
|
|
e49aac8569 |
@@ -9,6 +9,20 @@ 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
|
## [0.4.0] - 2026-09-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -252,6 +252,35 @@ is retried at the next interval; it never fabricates missing observations.
|
|||||||
|
|
||||||
## Reading the dashboard
|
## Reading the dashboard
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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 |
@@ -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).
|
||||||
@@ -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,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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fenris"
|
name = "fenris"
|
||||||
version = "0.4.0"
|
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"}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
"""Fenris: NVMe wear monitor with persistent TUI."""
|
"""Fenris: NVMe wear monitor with persistent TUI."""
|
||||||
__version__ = "0.4.0"
|
__version__ = "0.5.0"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""User choices for live and historical activity views."""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
HISTORY_RANGE_DEFAULT = 14
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IntervalIdentity:
|
||||||
|
"""Stable identity for one measured interval."""
|
||||||
|
|
||||||
|
start_ts: str
|
||||||
|
end_ts: str
|
||||||
|
|
||||||
|
|
||||||
|
class ActivitySelection:
|
||||||
|
"""Keep activity navigation state separate from its Textual rendering."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.view = "live"
|
||||||
|
self.browse_date: str | None = None
|
||||||
|
self.selected_history_hour: str | None = None
|
||||||
|
self.history_range_days = HISTORY_RANGE_DEFAULT
|
||||||
|
self.measure = "written"
|
||||||
|
self.following_live = True
|
||||||
|
self.selected_live_interval: IntervalIdentity | None = None
|
||||||
|
self.live_interval_expired = False
|
||||||
|
|
||||||
|
def set_view(self, view: str) -> None:
|
||||||
|
"""Select live, day, or history view."""
|
||||||
|
if view not in ("live", "day", "history"):
|
||||||
|
raise ValueError(f"unknown activity view: {view}")
|
||||||
|
self.view = view
|
||||||
|
if view == "live":
|
||||||
|
self.follow_live()
|
||||||
|
|
||||||
|
def select_history_date(self, date: str | None) -> None:
|
||||||
|
"""Keep the chosen historical local date across refresh."""
|
||||||
|
if date != self.browse_date:
|
||||||
|
self.selected_history_hour = None
|
||||||
|
self.browse_date = date
|
||||||
|
|
||||||
|
def select_history_hour(self, hour: str | None) -> None:
|
||||||
|
"""Keep the chosen historical hour across refresh."""
|
||||||
|
self.selected_history_hour = hour
|
||||||
|
|
||||||
|
def toggle_measure(self) -> str:
|
||||||
|
"""Switch read/write presentation without changing point selection."""
|
||||||
|
self.measure = "read" if self.measure == "written" else "written"
|
||||||
|
return self.measure
|
||||||
|
|
||||||
|
def follow_live(self) -> None:
|
||||||
|
"""Resume following the newest interval and clear stale-pin notices."""
|
||||||
|
self.view = "live"
|
||||||
|
self.browse_date = None
|
||||||
|
self.selected_history_hour = None
|
||||||
|
self.following_live = True
|
||||||
|
self.selected_live_interval = None
|
||||||
|
self.live_interval_expired = False
|
||||||
|
|
||||||
|
def update_live(self, intervals: Sequence[IntervalIdentity]) -> None:
|
||||||
|
"""Reconcile the selected interval with the rolling live window."""
|
||||||
|
if self.following_live:
|
||||||
|
self.selected_live_interval = intervals[-1] if intervals else None
|
||||||
|
return
|
||||||
|
if self.selected_live_interval in intervals:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.following_live = True
|
||||||
|
self.selected_live_interval = intervals[-1] if intervals else None
|
||||||
|
self.live_interval_expired = True
|
||||||
|
|
||||||
|
def move_live(
|
||||||
|
self, intervals: Sequence[IntervalIdentity], offset: int,
|
||||||
|
) -> IntervalIdentity | None:
|
||||||
|
"""Inspect an adjacent interval by stable identity."""
|
||||||
|
if not intervals:
|
||||||
|
return None
|
||||||
|
if self.selected_live_interval in intervals:
|
||||||
|
current = intervals.index(self.selected_live_interval)
|
||||||
|
else:
|
||||||
|
current = len(intervals) - 1
|
||||||
|
selected = intervals[max(0, min(len(intervals) - 1, current + offset))]
|
||||||
|
self._pin_live_interval(selected)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
def inspect_live(
|
||||||
|
self,
|
||||||
|
intervals: Sequence[IntervalIdentity],
|
||||||
|
interval: IntervalIdentity,
|
||||||
|
) -> None:
|
||||||
|
"""Pin the interval chosen by mouse inspection."""
|
||||||
|
if interval in intervals:
|
||||||
|
self._pin_live_interval(interval)
|
||||||
|
|
||||||
|
def selected_live_index(self, intervals: Sequence[IntervalIdentity]) -> int:
|
||||||
|
"""Return the selected point's current render index, or -1."""
|
||||||
|
if self.selected_live_interval is None:
|
||||||
|
return -1
|
||||||
|
try:
|
||||||
|
return intervals.index(self.selected_live_interval)
|
||||||
|
except ValueError:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def _pin_live_interval(self, interval: IntervalIdentity) -> None:
|
||||||
|
self.selected_live_interval = interval
|
||||||
|
self.following_live = False
|
||||||
|
self.live_interval_expired = False
|
||||||
+14
-10
@@ -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)
|
||||||
|
|||||||
+304
-101
@@ -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,29 @@ class InvariantViolationError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _PendingDerivationError(Exception):
|
||||||
|
"""A valid staged observation failed while deriving dependent evidence."""
|
||||||
|
|
||||||
|
def __init__(self, cause: Exception):
|
||||||
|
super().__init__(str(cause))
|
||||||
|
self.cause = cause
|
||||||
|
|
||||||
|
|
||||||
|
class _PendingRecoveryFailure(Exception):
|
||||||
|
"""A publication attempt failed after earlier pending rows committed."""
|
||||||
|
|
||||||
|
def __init__(self, error: Exception, published_count: int):
|
||||||
|
derivation_error = isinstance(error, _PendingDerivationError)
|
||||||
|
cause = error.cause if derivation_error else error
|
||||||
|
super().__init__(str(cause))
|
||||||
|
self.cause = cause
|
||||||
|
self.published_count = published_count
|
||||||
|
self.derivation_error = derivation_error
|
||||||
|
|
||||||
|
|
||||||
|
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 +220,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 +244,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 +259,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,122 +283,301 @@ 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],
|
||||||
|
identity: Dict[str, Any],
|
||||||
|
tz_name: str,
|
||||||
|
) -> None:
|
||||||
|
"""Publish one staged sample and all dependent evidence in caller transaction."""
|
||||||
|
observed_at = _observation_time(sample)
|
||||||
|
sample["local_tz"] = tz_name
|
||||||
|
validate_sample_invariants(sample, conn)
|
||||||
|
ensure_period_open(conn, observed_at)
|
||||||
|
seg_info = write_sample(sample, identity, conn, observed_at)
|
||||||
|
|
||||||
|
current_id = seg_info["sample_id"]
|
||||||
|
prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id)
|
||||||
try:
|
try:
|
||||||
# Acquire counters and thermal evidence
|
if prev is not None:
|
||||||
counters = acquire_from_smartctl(smartctl_data)
|
current = {
|
||||||
|
"id": current_id,
|
||||||
# Acquire controller identity
|
"ts": sample["ts"],
|
||||||
identity = acquire_from_sysfs(sysfs_path)
|
"bytes_written": sample["bytes_written"],
|
||||||
|
"bytes_read": sample["bytes_read"],
|
||||||
# Build sample with injected clock
|
"power_on_hours": sample["power_on_hours"],
|
||||||
sample = {
|
"temperature_c": sample["temperature_c"],
|
||||||
"ts": clock.utcnow().isoformat(),
|
"data_units_written": sample["data_units_written"],
|
||||||
"device": config["device"],
|
"data_units_read": sample["data_units_read"],
|
||||||
**counters,
|
"local_tz": tz_name,
|
||||||
**identity,
|
}
|
||||||
}
|
derive_hours_from_interval(conn, prev, current)
|
||||||
|
from .local_day import record_local_activity_interval
|
||||||
# Initialize store if needed
|
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)
|
||||||
|
except Exception as exc: # noqa: BLE001 - preserve valid staged evidence for retry.
|
||||||
|
raise _PendingDerivationError(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
try:
|
||||||
|
_publish_observation(
|
||||||
|
conn,
|
||||||
|
observation["sample"],
|
||||||
|
observation["identity"],
|
||||||
|
observation["tz_name"],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise _PendingRecoveryFailure(exc, recovered) from exc
|
||||||
|
conn.execute("DELETE FROM pending_publications WHERE id = ?", (pending_id,))
|
||||||
|
conn.commit()
|
||||||
|
recovered += 1
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _is_store_or_invariant_failure(exc: Exception) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(exc, sqlite3.Error)
|
||||||
|
and not isinstance(exc, sqlite3.IntegrityError)
|
||||||
|
) or isinstance(exc, InvariantViolationError)
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_capacity_error(
|
||||||
|
reason: str,
|
||||||
|
*,
|
||||||
|
acquisition_skipped: bool = False,
|
||||||
|
) -> RuntimeError:
|
||||||
|
outcome = "; no new observation acquired" if acquisition_skipped else ""
|
||||||
|
return RuntimeError(
|
||||||
|
"pending publication capacity full "
|
||||||
|
f"({PENDING_PUBLICATION_LIMIT} observations){outcome}; "
|
||||||
|
f"{reason}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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: # noqa: BLE001 - preserve pending evidence on recovery errors.
|
||||||
|
cause = exc.cause if isinstance(exc, _PendingRecoveryFailure) else exc
|
||||||
|
if _is_store_or_invariant_failure(cause):
|
||||||
|
raise cause
|
||||||
|
try:
|
||||||
|
capacity_full = _pending_count(conn) >= PENDING_PUBLICATION_LIMIT
|
||||||
|
except sqlite3.Error:
|
||||||
|
raise cause
|
||||||
|
if capacity_full:
|
||||||
|
raise _pending_capacity_error(f"recovery failed: {cause}") from cause
|
||||||
|
raise cause
|
||||||
|
|
||||||
|
|
||||||
|
def _recover_pending_for_admission(conn: sqlite3.Connection) -> int:
|
||||||
|
"""Recover in order, then admit one observation if bounded space remains."""
|
||||||
|
try:
|
||||||
|
recovered = _recover_pending(conn)
|
||||||
|
except _PendingRecoveryFailure as failure:
|
||||||
|
if (
|
||||||
|
not failure.derivation_error
|
||||||
|
or _is_store_or_invariant_failure(failure.cause)
|
||||||
|
):
|
||||||
|
raise failure.cause
|
||||||
|
try:
|
||||||
|
pending_count = _pending_count(conn)
|
||||||
|
except sqlite3.Error:
|
||||||
|
raise failure.cause
|
||||||
|
if pending_count >= PENDING_PUBLICATION_LIMIT:
|
||||||
|
raise _pending_capacity_error(
|
||||||
|
f"recovery failed: {failure.cause}",
|
||||||
|
acquisition_skipped=True,
|
||||||
|
) from failure.cause
|
||||||
|
return failure.published_count
|
||||||
|
|
||||||
|
if _pending_count(conn) >= PENDING_PUBLICATION_LIMIT:
|
||||||
|
raise _pending_capacity_error(
|
||||||
|
"recovery left the queue full",
|
||||||
|
acquisition_skipped=True,
|
||||||
|
)
|
||||||
|
return recovered
|
||||||
|
|
||||||
|
|
||||||
|
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_admission(conn)
|
||||||
try:
|
checked_pending_count = _pending_count(conn)
|
||||||
# Ensure monitoring period is open (issue #73 AC2)
|
|
||||||
ensure_period_open(conn, clock.utcnow())
|
|
||||||
|
|
||||||
# Validate invariants
|
|
||||||
validate_sample_invariants(sample, conn)
|
|
||||||
|
|
||||||
# Write sample and get segment info
|
|
||||||
seg_info = write_sample(sample, identity, conn, clock)
|
|
||||||
|
|
||||||
# Derive hour observations from interval with previous sample
|
|
||||||
try:
|
|
||||||
# Find the sample we just wrote
|
|
||||||
cursor = conn.execute("SELECT id FROM samples ORDER BY id DESC LIMIT 1")
|
|
||||||
current_id = cursor.fetchone()[0]
|
|
||||||
|
|
||||||
prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id)
|
# Hold the writer reservation across the capacity check and acquisition.
|
||||||
if prev is not None:
|
# Recheck recovery if another collector appended work during preflight.
|
||||||
# Build current sample dict for derivation
|
while True:
|
||||||
current = {
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
"id": current_id,
|
waiting = _pending_count(conn)
|
||||||
"ts": sample["ts"],
|
if (
|
||||||
"bytes_written": sample["bytes_written"],
|
waiting >= PENDING_PUBLICATION_LIMIT
|
||||||
"bytes_read": sample["bytes_read"],
|
or waiting > checked_pending_count
|
||||||
"power_on_hours": sample["power_on_hours"],
|
):
|
||||||
"temperature_c": sample["temperature_c"],
|
conn.rollback()
|
||||||
"data_units_written": sample["data_units_written"],
|
published_count += _recover_pending_for_admission(conn)
|
||||||
"data_units_read": sample["data_units_read"],
|
checked_pending_count = _pending_count(conn)
|
||||||
}
|
continue
|
||||||
derive_hours_from_interval(conn, prev, current)
|
|
||||||
|
|
||||||
# Rebuild day aggregates from hour observations
|
from .tz_util import detect_system_tz
|
||||||
from .day_aggregate import derive_all_days, persist_day_aggregate
|
tz_name = detect_system_tz()
|
||||||
for agg in derive_all_days(conn):
|
if acquire is not None:
|
||||||
persist_day_aggregate(conn, agg)
|
smartctl_data, sysfs_path = acquire()
|
||||||
|
if smartctl_data is None or sysfs_path is None:
|
||||||
|
raise AcquisitionError("No acquired SMART data or sysfs identity")
|
||||||
|
|
||||||
# Derive local-day summary using system timezone (issue #90)
|
counters = acquire_from_smartctl(smartctl_data)
|
||||||
try:
|
identity = acquire_from_sysfs(sysfs_path)
|
||||||
from .tz_util import detect_system_tz
|
sample = {
|
||||||
from .local_day import derive_local_day_summary, persist_local_day
|
"ts": clock.utcnow().isoformat(),
|
||||||
tz_name = detect_system_tz()
|
"device": config["device"],
|
||||||
clock_now = clock.utcnow()
|
**counters,
|
||||||
local_summary = derive_local_day_summary(conn, tz_name, clock_now)
|
**identity,
|
||||||
if local_summary is not None:
|
|
||||||
persist_local_day(conn, local_summary)
|
|
||||||
except Exception:
|
|
||||||
# Local-day derivation failure must not prevent publication
|
|
||||||
pass
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
except Exception:
|
|
||||||
# Derivation failure must not prevent sample persistence (issue #73 AC6)
|
|
||||||
pass
|
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
@@ -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"])
|
||||||
@@ -207,9 +208,6 @@ def _upsert_hour_observation(
|
|||||||
"WHERE id = ?",
|
"WHERE id = ?",
|
||||||
(new_bw, new_br, new_samples, existing[0]),
|
(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,
|
||||||
@@ -241,4 +239,3 @@ def _add_unattributed_bytes(
|
|||||||
"unattributed_bytes_read = unattributed_bytes_read + ? WHERE day = ?",
|
"unattributed_bytes_read = unattributed_bytes_read + ? WHERE day = ?",
|
||||||
(bw_delta, br_delta, day),
|
(bw_delta, br_delta, day),
|
||||||
)
|
)
|
||||||
conn.commit()
|
|
||||||
|
|||||||
+1011
-132
File diff suppressed because it is too large
Load Diff
@@ -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"])
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -494,16 +494,16 @@ def _has_complete_local_day(conn):
|
|||||||
within a monitoring period that has usable observation evidence.
|
within a monitoring period that has usable observation evidence.
|
||||||
This is the prerequisite for showing an endurance outlook.
|
This is the prerequisite for showing an endurance outlook.
|
||||||
"""
|
"""
|
||||||
row = conn.execute(
|
return _count_complete_local_days(conn) > 0
|
||||||
"SELECT 1 FROM local_days WHERE complete = 1 LIMIT 1"
|
|
||||||
).fetchone()
|
|
||||||
return row is not None
|
|
||||||
|
|
||||||
|
|
||||||
def _count_complete_local_days(conn):
|
def _count_complete_local_days(conn):
|
||||||
"""Count the number of complete local observation days."""
|
"""Count the number of complete local observation days."""
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT COUNT(*) FROM local_days WHERE complete = 1"
|
"SELECT COUNT(*) FROM local_days "
|
||||||
|
"WHERE complete = 1 "
|
||||||
|
"AND activity_precision IN ('measured', 'coarse') "
|
||||||
|
"AND activity_intervals > 0"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return row[0] if row else 0
|
return row[0] if row else 0
|
||||||
|
|
||||||
|
|||||||
+360
-145
@@ -1,131 +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.
|
|
||||||
|
|
||||||
Local-day summaries (local_days table) are never touched by pruning.
|
|
||||||
They are persisted at collection time from hour observations and survive
|
|
||||||
raw-sample pruning because they depend on hour observations, not on raw
|
|
||||||
samples. This is the mechanism that keeps local-day history trustworthy
|
|
||||||
after detail expires (issue #93).
|
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import 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(
|
||||||
@@ -133,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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+192
-58
@@ -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 = 3
|
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
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -209,10 +211,18 @@ def _create_schema(conn: sqlite3.Connection):
|
|||||||
coverage REAL DEFAULT 0.0,
|
coverage REAL DEFAULT 0.0,
|
||||||
sample_count INTEGER DEFAULT 0,
|
sample_count INTEGER DEFAULT 0,
|
||||||
complete BOOLEAN 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)
|
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 (
|
||||||
@@ -221,59 +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
|
|
||||||
elif branches when SCHEMA_VERSION increases.
|
|
||||||
|
|
||||||
Spec: §3.6, §10.2
|
|
||||||
"""
|
|
||||||
# Migration 1→2: add segment_id provenance to samples,
|
|
||||||
# unattributed byte tracking to day_aggregates (issue #73)
|
|
||||||
if current_version < 2:
|
|
||||||
# Defensive: only ALTER if table exists (handles minimal v1 stores)
|
|
||||||
tables = {row[0] for row in conn.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
||||||
).fetchall()}
|
|
||||||
if "samples" in tables:
|
|
||||||
# Check if column already exists (idempotent)
|
|
||||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(samples)").fetchall()}
|
|
||||||
if "segment_id" not in cols:
|
|
||||||
conn.execute("ALTER TABLE samples ADD COLUMN segment_id INTEGER")
|
|
||||||
if "day_aggregates" in tables:
|
|
||||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(day_aggregates)").fetchall()}
|
|
||||||
if "unattributed_bytes_written" not in cols:
|
|
||||||
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_written INTEGER DEFAULT 0")
|
|
||||||
if "unattributed_bytes_read" not in cols:
|
|
||||||
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0")
|
|
||||||
current_version = 2
|
|
||||||
|
|
||||||
# Migration 2→3: add local_days table for local-day activity totals
|
Commit each version transition independently. A failed step rolls back in
|
||||||
# (issue #90, ADR 0010). Pure addition — no existing rows touched.
|
full while earlier successful steps remain versioned and retryable.
|
||||||
if current_version < 3:
|
"""
|
||||||
tables = {row[0] for row in conn.execute(
|
migrations = {
|
||||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
2: _migrate_1_to_2,
|
||||||
|
3: _migrate_2_to_3,
|
||||||
|
4: _migrate_3_to_4,
|
||||||
|
5: _migrate_4_to_5,
|
||||||
|
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 "local_days" not in tables:
|
if "segment_id" not in cols:
|
||||||
conn.execute("""
|
conn.execute("ALTER TABLE samples ADD COLUMN segment_id INTEGER")
|
||||||
CREATE TABLE IF NOT EXISTS local_days (
|
if "day_aggregates" in tables:
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
cols = {row[1] for row in conn.execute(
|
||||||
local_date TEXT NOT NULL,
|
"PRAGMA table_info(day_aggregates)"
|
||||||
tz_name TEXT NOT NULL,
|
).fetchall()}
|
||||||
tz_offset TEXT NOT NULL,
|
for column in ("unattributed_bytes_written", "unattributed_bytes_read"):
|
||||||
utc_start TEXT NOT NULL,
|
if column not in cols:
|
||||||
utc_end TEXT NOT NULL,
|
conn.execute(
|
||||||
bytes_written INTEGER DEFAULT 0,
|
f"ALTER TABLE day_aggregates ADD COLUMN {column} INTEGER DEFAULT 0"
|
||||||
bytes_read INTEGER DEFAULT 0,
|
|
||||||
coverage REAL DEFAULT 0.0,
|
|
||||||
sample_count INTEGER DEFAULT 0,
|
|
||||||
complete BOOLEAN DEFAULT 0,
|
|
||||||
UNIQUE(local_date, tz_name)
|
|
||||||
)
|
)
|
||||||
""")
|
|
||||||
current_version = 3
|
|
||||||
|
def _migrate_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:
|
||||||
@@ -310,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
@@ -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"),
|
||||||
}
|
}
|
||||||
|
|||||||
+521
-573
File diff suppressed because it is too large
Load Diff
@@ -25,11 +25,14 @@ def detect_system_tz() -> str:
|
|||||||
|
|
||||||
localtime = Path("/etc/localtime")
|
localtime = Path("/etc/localtime")
|
||||||
if localtime.is_symlink():
|
if localtime.is_symlink():
|
||||||
target = os.readlink(str(localtime))
|
target_path = Path(os.readlink(str(localtime)))
|
||||||
# Strip common prefixes: /usr/share/zoneinfo/, /usr/lib/zoneinfo/
|
if not target_path.is_absolute():
|
||||||
for prefix in ("/usr/share/zoneinfo/", "/usr/lib/zoneinfo/"):
|
target_path = localtime.parent / target_path
|
||||||
if target.startswith(prefix):
|
target = target_path.resolve().as_posix()
|
||||||
return target[len(prefix):]
|
marker = "/zoneinfo/"
|
||||||
|
marker_index = target.find(marker)
|
||||||
|
if marker_index >= 0:
|
||||||
|
return target[marker_index + len(marker):]
|
||||||
return target
|
return target
|
||||||
|
|
||||||
return "UTC"
|
return "UTC"
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO local_days "
|
"INSERT INTO local_days "
|
||||||
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
|
||||||
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||||
bw, br, coverage, samples, complete),
|
bw, br, coverage, samples, complete),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Activity-selection transitions independent from Textual widgets."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.activity_selection import ActivitySelection, IntervalIdentity
|
||||||
|
|
||||||
|
|
||||||
|
def _interval(n):
|
||||||
|
return IntervalIdentity(f"start-{n}", f"end-{n}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_selection_starts_and_stays_on_newest_interval():
|
||||||
|
selection = ActivitySelection()
|
||||||
|
first = [_interval(1), _interval(2)]
|
||||||
|
|
||||||
|
selection.update_live(first)
|
||||||
|
assert selection.following_live
|
||||||
|
assert selection.selected_live_interval == first[-1]
|
||||||
|
|
||||||
|
second = first + [_interval(3)]
|
||||||
|
selection.update_live(second)
|
||||||
|
assert selection.selected_live_interval == second[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_keyboard_and_mouse_inspection_pin_interval_identity():
|
||||||
|
intervals = [_interval(1), _interval(2), _interval(3)]
|
||||||
|
keyboard = ActivitySelection()
|
||||||
|
mouse = ActivitySelection()
|
||||||
|
|
||||||
|
keyboard.update_live(intervals)
|
||||||
|
keyboard.move_live(intervals, -1)
|
||||||
|
mouse.update_live(intervals)
|
||||||
|
mouse.inspect_live(intervals, intervals[1])
|
||||||
|
|
||||||
|
assert keyboard.selected_live_interval == mouse.selected_live_interval == intervals[1]
|
||||||
|
assert not keyboard.following_live
|
||||||
|
assert not mouse.following_live
|
||||||
|
|
||||||
|
refreshed = [_interval(2), _interval(3), _interval(4)]
|
||||||
|
keyboard.update_live(refreshed)
|
||||||
|
mouse.update_live(refreshed)
|
||||||
|
assert keyboard.selected_live_interval == mouse.selected_live_interval == intervals[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_pin_explains_expiration_and_resumes_following():
|
||||||
|
selection = ActivitySelection()
|
||||||
|
intervals = [_interval(1), _interval(2), _interval(3)]
|
||||||
|
selection.update_live(intervals)
|
||||||
|
selection.inspect_live(intervals, intervals[0])
|
||||||
|
|
||||||
|
newest = _interval(4)
|
||||||
|
selection.update_live([_interval(2), _interval(3), newest])
|
||||||
|
|
||||||
|
assert selection.following_live
|
||||||
|
assert selection.selected_live_interval == newest
|
||||||
|
assert selection.live_interval_expired
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_resets_expired_pin_and_read_write_toggle_keeps_identity():
|
||||||
|
selection = ActivitySelection()
|
||||||
|
intervals = [_interval(1), _interval(2)]
|
||||||
|
selection.update_live(intervals)
|
||||||
|
selection.inspect_live(intervals, intervals[0])
|
||||||
|
selection.update_live([intervals[1]])
|
||||||
|
newest = _interval(3)
|
||||||
|
selection.update_live([intervals[1], newest])
|
||||||
|
assert selection.live_interval_expired
|
||||||
|
|
||||||
|
selection.toggle_measure()
|
||||||
|
assert selection.measure == "read"
|
||||||
|
assert selection.selected_live_interval == newest
|
||||||
|
|
||||||
|
selection.select_history_date("2026-09-27")
|
||||||
|
selection.set_view("history")
|
||||||
|
selection.set_view("live")
|
||||||
|
assert selection.browse_date is None
|
||||||
|
assert selection.following_live
|
||||||
|
assert not selection.live_interval_expired
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_identity_uses_both_interval_boundaries():
|
||||||
|
first = IntervalIdentity("2026-09-28T10:00:00Z", "2026-09-28T10:03:00Z")
|
||||||
|
changed_start = IntervalIdentity("2026-09-28T09:57:00Z", "2026-09-28T10:03:00Z")
|
||||||
|
selection = ActivitySelection()
|
||||||
|
selection.update_live([first])
|
||||||
|
selection.inspect_live([first], first)
|
||||||
|
|
||||||
|
selection.update_live([changed_start])
|
||||||
|
|
||||||
|
assert selection.following_live
|
||||||
|
assert selection.selected_live_interval == changed_start
|
||||||
|
assert selection.live_interval_expired
|
||||||
@@ -154,7 +154,7 @@ class TestSchemaMigration:
|
|||||||
|
|
||||||
# Migrate
|
# Migrate
|
||||||
steps = migrate_to_latest(db)
|
steps = migrate_to_latest(db)
|
||||||
assert steps == 2 # v1→v2→v3
|
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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|
||||||
@@ -249,6 +249,9 @@ def test_store_initialization(config_fixture: Dict[str, Any]):
|
|||||||
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("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()
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO local_days "
|
"INSERT INTO local_days "
|
||||||
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
|
||||||
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||||
bw, br, coverage, samples, complete),
|
bw, br, coverage, samples, complete),
|
||||||
)
|
)
|
||||||
@@ -166,6 +166,47 @@ class TestGateNoCompleteDay:
|
|||||||
# The key assertion: gate message should NOT appear when gate IS met
|
# 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)
|
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
|
# Gate-2: One complete local day → Limited confidence
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Bound pending-publication admission before device acquisition."""
|
||||||
|
import json
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self, now):
|
||||||
|
self.now = now
|
||||||
|
|
||||||
|
def utcnow(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sysfs_controller(tmp_path):
|
||||||
|
controller_path = tmp_path / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
controller_path.mkdir(parents=True)
|
||||||
|
(controller_path / "subsysnqn").write_text("nqn.test:drive\n")
|
||||||
|
(controller_path / "model").write_text("Test NVMe\n")
|
||||||
|
(controller_path / "serial").write_text("test-serial\n")
|
||||||
|
(controller_path / "firmware_rev").write_text("1.0\n")
|
||||||
|
transport = controller_path / "transport"
|
||||||
|
transport.mkdir()
|
||||||
|
(transport / "trstring").write_text("pcie\n")
|
||||||
|
return controller_path
|
||||||
|
|
||||||
|
|
||||||
|
def _smartctl(units_written):
|
||||||
|
return {
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0,
|
||||||
|
"temperature": 35,
|
||||||
|
"available_spare": 100,
|
||||||
|
"percentage_used": 5,
|
||||||
|
"data_units_written": units_written,
|
||||||
|
"data_units_read": 500,
|
||||||
|
"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 _collect(config, controller, now, units_written):
|
||||||
|
return collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(now),
|
||||||
|
acquire=lambda: (_smartctl(units_written), controller),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_recovery_frees_admission_and_keeps_pending_order(
|
||||||
|
tmp_path, sysfs_controller, monkeypatch,
|
||||||
|
):
|
||||||
|
"""A freed slot admits one observation behind older pending evidence."""
|
||||||
|
assert collector.PENDING_PUBLICATION_LIMIT == 6_720
|
||||||
|
monkeypatch.setenv("TZ", "UTC")
|
||||||
|
monkeypatch.setattr(collector, "PENDING_PUBLICATION_LIMIT", 2)
|
||||||
|
|
||||||
|
store_path = tmp_path / "observations.db"
|
||||||
|
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
|
||||||
|
old_time = datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||||
|
controller = sysfs_controller
|
||||||
|
|
||||||
|
assert _collect(config, controller, old_time, 1000)["ok"] is True
|
||||||
|
|
||||||
|
original_derive = collector.derive_hours_from_interval
|
||||||
|
|
||||||
|
def unavailable_derivation(*_args):
|
||||||
|
raise RuntimeError("derivation unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(collector, "derive_hours_from_interval", unavailable_derivation)
|
||||||
|
first_pending = _collect(
|
||||||
|
config, controller, old_time + timedelta(minutes=5), 1010,
|
||||||
|
)
|
||||||
|
assert first_pending["ok"] is False
|
||||||
|
|
||||||
|
second_pending = _collect(
|
||||||
|
config, controller, old_time + timedelta(minutes=10), 1020,
|
||||||
|
)
|
||||||
|
assert second_pending["ok"] is False
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM pending_publications"
|
||||||
|
).fetchone()[0] == 2
|
||||||
|
|
||||||
|
called = False
|
||||||
|
|
||||||
|
def must_not_acquire():
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return _smartctl(1030), controller
|
||||||
|
|
||||||
|
full = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(old_time + timedelta(days=40)),
|
||||||
|
acquire=must_not_acquire,
|
||||||
|
)
|
||||||
|
assert full["ok"] is False
|
||||||
|
assert "capacity full (2 observations)" in full["error"]
|
||||||
|
assert called is False
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*), MIN(sample_ts) FROM pending_publications"
|
||||||
|
).fetchone() == (
|
||||||
|
2,
|
||||||
|
(old_time + timedelta(minutes=5)).isoformat(),
|
||||||
|
)
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 1
|
||||||
|
|
||||||
|
def fail_second_pending(conn, previous, current):
|
||||||
|
if current["data_units_written"] == 1020:
|
||||||
|
raise RuntimeError("second observation still blocked")
|
||||||
|
return original_derive(conn, previous, current)
|
||||||
|
|
||||||
|
monkeypatch.setattr(collector, "derive_hours_from_interval", fail_second_pending)
|
||||||
|
acquired = False
|
||||||
|
|
||||||
|
def acquire_after_partial_recovery():
|
||||||
|
nonlocal acquired
|
||||||
|
acquired = True
|
||||||
|
return _smartctl(1030), controller
|
||||||
|
|
||||||
|
partial = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(old_time + timedelta(days=40, minutes=5)),
|
||||||
|
acquire=acquire_after_partial_recovery,
|
||||||
|
)
|
||||||
|
assert partial["ok"] is False
|
||||||
|
assert acquired is True, partial
|
||||||
|
assert "no new observation acquired" not in partial["error"]
|
||||||
|
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT data_units_written FROM samples ORDER BY id"
|
||||||
|
).fetchall() == [(1000,), (1010,)]
|
||||||
|
queued_units = [
|
||||||
|
json.loads(row[0])["sample"]["data_units_written"]
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT payload FROM pending_publications ORDER BY id"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert queued_units == [1020, 1030]
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0] == 1
|
||||||
|
|
||||||
|
recovered_in_order = []
|
||||||
|
|
||||||
|
def record_recovery_order(conn, previous, current):
|
||||||
|
recovered_in_order.append(current["data_units_written"])
|
||||||
|
return original_derive(conn, previous, current)
|
||||||
|
|
||||||
|
monkeypatch.setattr(collector, "derive_hours_from_interval", record_recovery_order)
|
||||||
|
recovered = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(old_time + timedelta(days=40, minutes=10)),
|
||||||
|
acquire=lambda: (_smartctl(1040), controller),
|
||||||
|
)
|
||||||
|
assert recovered["ok"] is True, recovered
|
||||||
|
assert recovered_in_order == [1020, 1030, 1040]
|
||||||
|
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT data_units_written FROM samples ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone() == (1040,)
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_actual_pending_limit_fails_before_acquisition_and_keeps_old_evidence(
|
||||||
|
tmp_path, sysfs_controller, monkeypatch,
|
||||||
|
):
|
||||||
|
"""The production limit retains every old row and refuses device acquisition."""
|
||||||
|
monkeypatch.setenv("TZ", "UTC")
|
||||||
|
store_path = tmp_path / "full-observations.db"
|
||||||
|
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
|
||||||
|
controller = sysfs_controller
|
||||||
|
old_time = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||||
|
assert _collect(config, controller, old_time, 1000)["ok"] is True
|
||||||
|
|
||||||
|
def unavailable_derivation(*_args):
|
||||||
|
raise RuntimeError("derivation unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(collector, "derive_hours_from_interval", unavailable_derivation)
|
||||||
|
failed = _collect(
|
||||||
|
config, controller, old_time + timedelta(minutes=5), 1010,
|
||||||
|
)
|
||||||
|
assert failed["ok"] is False
|
||||||
|
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
payload_row = conn.execute(
|
||||||
|
"SELECT payload FROM pending_publications ORDER BY id LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
assert payload_row is not None
|
||||||
|
base = json.loads(payload_row[0])
|
||||||
|
base_observed_at = datetime.fromisoformat(base["sample"]["ts"])
|
||||||
|
base_units = base["sample"]["data_units_written"]
|
||||||
|
rows = []
|
||||||
|
for offset in range(1, collector.PENDING_PUBLICATION_LIMIT):
|
||||||
|
observation = json.loads(payload_row[0])
|
||||||
|
units_written = base_units + offset
|
||||||
|
observed_at = base_observed_at + timedelta(minutes=5 * offset)
|
||||||
|
observation["sample"]["ts"] = observed_at.isoformat()
|
||||||
|
observation["sample"]["data_units_written"] = units_written
|
||||||
|
observation["sample"]["bytes_written"] = units_written * 512_000
|
||||||
|
rows.append((observed_at.isoformat(), json.dumps(observation)))
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO pending_publications (sample_ts, payload) VALUES (?, ?)",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
called = False
|
||||||
|
|
||||||
|
def must_not_acquire():
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return _smartctl(9000), controller
|
||||||
|
|
||||||
|
result = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(old_time + timedelta(days=40)),
|
||||||
|
acquire=must_not_acquire,
|
||||||
|
)
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert f"capacity full ({collector.PENDING_PUBLICATION_LIMIT} observations)" in result[
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
pending_rows = conn.execute(
|
||||||
|
"SELECT sample_ts, payload FROM pending_publications ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
assert len(pending_rows) == collector.PENDING_PUBLICATION_LIMIT
|
||||||
|
assert (pending_rows[0][0], pending_rows[-1][0]) == (
|
||||||
|
(old_time + timedelta(minutes=5)).isoformat(),
|
||||||
|
(
|
||||||
|
old_time
|
||||||
|
+ timedelta(minutes=5 * collector.PENDING_PUBLICATION_LIMIT)
|
||||||
|
).isoformat(),
|
||||||
|
)
|
||||||
|
assert [
|
||||||
|
json.loads(payload)["sample"]["data_units_written"]
|
||||||
|
for _, payload in pending_rows
|
||||||
|
] == list(range(1010, 1010 + collector.PENDING_PUBLICATION_LIMIT))
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 1
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0] == 1
|
||||||
@@ -90,8 +90,8 @@ def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO local_days "
|
"INSERT INTO local_days "
|
||||||
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
|
||||||
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||||
bw, br, coverage, samples, complete),
|
bw, br, coverage, samples, complete),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO local_days "
|
"INSERT INTO local_days "
|
||||||
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
|
||||||
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||||
bw, br, coverage, samples, complete),
|
bw, br, coverage, samples, complete),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -180,7 +180,7 @@ 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"
|
||||||
|
|
||||||
# Enter drill mode on the graph
|
# Enter drill mode on the graph
|
||||||
graph = app.query_one("#usage-history")
|
graph = app.query_one("#usage-history")
|
||||||
@@ -382,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
|
||||||
@@ -401,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")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+11
-7
@@ -29,7 +29,7 @@ from fenris.local_day import (
|
|||||||
)
|
)
|
||||||
from fenris.tui import (
|
from fenris.tui import (
|
||||||
FenrisTuiApp,
|
FenrisTuiApp,
|
||||||
DailyBarGraph,
|
HistoryGraph,
|
||||||
_RANGE_OPTIONS,
|
_RANGE_OPTIONS,
|
||||||
_RANGE_DEFAULT,
|
_RANGE_DEFAULT,
|
||||||
)
|
)
|
||||||
@@ -111,11 +111,12 @@ def _make_app(tmp_path, clock=None):
|
|||||||
import fenris.tui as tui_mod
|
import fenris.tui as tui_mod
|
||||||
from datetime import datetime as _real_datetime
|
from datetime import datetime as _real_datetime
|
||||||
|
|
||||||
mock_dt = type("MockDatetime", (), {
|
class MockDatetime(_real_datetime):
|
||||||
"now": staticmethod(lambda tz=None: clock),
|
@classmethod
|
||||||
"strptime": staticmethod(lambda *a, **kw: _real_datetime.strptime(*a, **kw)),
|
def now(cls, tz=None):
|
||||||
"date": staticmethod(lambda: clock.date()),
|
return clock
|
||||||
})()
|
|
||||||
|
mock_dt = MockDatetime
|
||||||
|
|
||||||
patcher = patch.object(tui_mod, "datetime", mock_dt)
|
patcher = patch.object(tui_mod, "datetime", mock_dt)
|
||||||
patcher.start()
|
patcher.start()
|
||||||
@@ -262,7 +263,7 @@ class TestArrowInspection:
|
|||||||
async with app.run_test(size=(100, 40)) as pilot:
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
graph = app.query_one("#usage-history")
|
graph = app.query_one("#usage-history")
|
||||||
# Focus the graph widget explicitly
|
await pilot.press("v", "v")
|
||||||
graph.focus()
|
graph.focus()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
day_before = graph._day_data[graph.selected_index].get("day")
|
day_before = graph._day_data[graph.selected_index].get("day")
|
||||||
@@ -281,6 +282,7 @@ class TestArrowInspection:
|
|||||||
async with app.run_test(size=(100, 40)) as pilot:
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
graph = app.query_one("#usage-history")
|
graph = app.query_one("#usage-history")
|
||||||
|
await pilot.press("v", "v")
|
||||||
await pilot.click("#usage-history")
|
await pilot.click("#usage-history")
|
||||||
|
|
||||||
# Enter drill-down
|
# Enter drill-down
|
||||||
@@ -329,6 +331,7 @@ class TestTodayBinding:
|
|||||||
async with app.run_test(size=(100, 40)) as pilot:
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
graph = app.query_one("#usage-history")
|
graph = app.query_one("#usage-history")
|
||||||
|
await pilot.press("v", "v")
|
||||||
await pilot.click("#usage-history")
|
await pilot.click("#usage-history")
|
||||||
|
|
||||||
# Enter drill
|
# Enter drill
|
||||||
@@ -546,6 +549,7 @@ class TestDrillDownData:
|
|||||||
async with app.run_test(size=(100, 40)) as pilot:
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
graph = app.query_one("#usage-history")
|
graph = app.query_one("#usage-history")
|
||||||
|
await pilot.press("v", "v")
|
||||||
await pilot.click("#usage-history")
|
await pilot.click("#usage-history")
|
||||||
|
|
||||||
# Enter drill-down
|
# Enter drill-down
|
||||||
|
|||||||
+13
-16
@@ -151,8 +151,8 @@ class TestDetailExpiresSummariesRemain:
|
|||||||
# Run pruning
|
# Run pruning
|
||||||
pruned = prune_old_samples(conn, now, retention_days=14)
|
pruned = prune_old_samples(conn, now, retention_days=14)
|
||||||
|
|
||||||
# Old samples should be pruned, recent ones retained
|
# The handcrafted totals lack replacement provenance, so retain samples.
|
||||||
assert pruned >= 1
|
assert pruned == 0
|
||||||
|
|
||||||
# Local-day summaries must still be queryable
|
# Local-day summaries must still be queryable
|
||||||
old_result = query_local_day_summary(conn, "2026-09-16")
|
old_result = query_local_day_summary(conn, "2026-09-16")
|
||||||
@@ -277,7 +277,7 @@ class TestTimezonePreservation:
|
|||||||
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
old = LocalDaySummary(
|
old = LocalDaySummary(
|
||||||
local_date="2026-09-16", tz_name="US/Eastern", tz_offset="-04:00",
|
local_date="2026-09-16", tz_name="America/New_York", tz_offset="-04:00",
|
||||||
utc_start="2026-09-16T04:00:00+00:00",
|
utc_start="2026-09-16T04:00:00+00:00",
|
||||||
utc_end="2026-09-17T04:00:00+00:00",
|
utc_end="2026-09-17T04:00:00+00:00",
|
||||||
bytes_written=5000, bytes_read=2000,
|
bytes_written=5000, bytes_read=2000,
|
||||||
@@ -288,7 +288,7 @@ class TestTimezonePreservation:
|
|||||||
entries = query_local_day_history(conn, "2026-09-16", "2026-09-16", now)
|
entries = query_local_day_history(conn, "2026-09-16", "2026-09-16", now)
|
||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
entry = entries[0]
|
entry = entries[0]
|
||||||
assert entry.tz_name == "US/Eastern"
|
assert entry.tz_name == "America/New_York"
|
||||||
assert entry.tz_offset == "-04:00"
|
assert entry.tz_offset == "-04:00"
|
||||||
assert entry.utc_start == "2026-09-16T04:00:00+00:00"
|
assert entry.utc_start == "2026-09-16T04:00:00+00:00"
|
||||||
assert entry.detail_available is False
|
assert entry.detail_available is False
|
||||||
@@ -389,10 +389,10 @@ class TestIdempotencySafety:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestVolumeConservation:
|
class TestVolumeConservation:
|
||||||
"""AC6: Summary volume plus unallocated evidence conserves each delta once."""
|
"""AC6: UTC-hour totals never become fabricated local-day totals."""
|
||||||
|
|
||||||
def test_midnight_spanning_not_double_counted(self, tmp_path):
|
def test_hourly_bytes_are_unavailable_at_local_precision(self, tmp_path):
|
||||||
"""Midnight-spanning interval appears once, not in both days."""
|
"""UTC-day hour bytes cannot be split across local-day boundaries."""
|
||||||
conn = init_store(tmp_path / "obs.db")
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
|
||||||
# Insert hour observations that span midnight
|
# Insert hour observations that span midnight
|
||||||
@@ -409,13 +409,10 @@ class TestVolumeConservation:
|
|||||||
summary_17 = derive_local_day_summary(conn, "UTC", clock_17)
|
summary_17 = derive_local_day_summary(conn, "UTC", clock_17)
|
||||||
assert summary_17 is not None
|
assert summary_17 is not None
|
||||||
|
|
||||||
# Each day should only have its own hour's bytes
|
assert summary_16.bytes_written == 0
|
||||||
# Day 16 has the 23:00 hour (100 bw)
|
assert summary_17.bytes_written == 0
|
||||||
# Day 17 has the 00:00 hour (200 bw)
|
assert summary_16.activity_precision == "unavailable"
|
||||||
assert summary_16.bytes_written == 100
|
assert summary_17.activity_precision == "unavailable"
|
||||||
assert summary_17.bytes_written == 200
|
|
||||||
# Total is conserved: 100 + 200 = 300
|
|
||||||
assert summary_16.bytes_written + summary_17.bytes_written == 300
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -437,10 +434,10 @@ class TestExistingEntryPoints:
|
|||||||
_insert_sample(conn, ts, bw=days_ago * 100)
|
_insert_sample(conn, ts, bw=days_ago * 100)
|
||||||
|
|
||||||
pruned = prune_old_samples(conn, now, retention_days=14)
|
pruned = prune_old_samples(conn, now, retention_days=14)
|
||||||
assert pruned == 15
|
assert pruned == 0
|
||||||
|
|
||||||
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||||
assert cursor.fetchone()[0] == 14
|
assert cursor.fetchone()[0] == 29
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def test_repair_with_real_temp_store(self, tmp_path):
|
def test_repair_with_real_temp_store(self, tmp_path):
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
monkeypatch.setattr(collector, "PENDING_PUBLICATION_LIMIT", 1)
|
||||||
|
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()
|
||||||
@@ -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()
|
||||||
+109
-8
@@ -6,26 +6,24 @@ Covers:
|
|||||||
- Three-minute cadence constants
|
- Three-minute cadence constants
|
||||||
- TUI integration with live graph
|
- TUI integration with live graph
|
||||||
"""
|
"""
|
||||||
|
import sys
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
from fenris.store import init_store
|
from fenris.activity_selection import IntervalIdentity
|
||||||
from fenris.monitoring_periods import ensure_period_open
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
|
from fenris.status import ACCURACY_SEC, CADENCE_DEFAULT_S, FRESH_THRESHOLD_S
|
||||||
|
from fenris.store import init_store
|
||||||
from fenris.tui import (
|
from fenris.tui import (
|
||||||
|
LIVE_WINDOW_H,
|
||||||
FenrisTuiApp,
|
FenrisTuiApp,
|
||||||
LiveActivityGraph,
|
LiveActivityGraph,
|
||||||
_query_live_graph_data,
|
_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
|
# Helpers
|
||||||
@@ -63,6 +61,27 @@ 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 _live_intervals(count=4):
|
||||||
|
first = datetime(2026, 9, 28, 12, 0, tzinfo=timezone.utc)
|
||||||
|
intervals = []
|
||||||
|
for index in range(count):
|
||||||
|
start = first + timedelta(minutes=3 * index)
|
||||||
|
end = start + timedelta(minutes=3)
|
||||||
|
intervals.append({
|
||||||
|
"start_ts": start.isoformat(),
|
||||||
|
"end_ts": end.isoformat(),
|
||||||
|
"start_label": start.strftime("%H:%M"),
|
||||||
|
"end_label": end.strftime("%H:%M"),
|
||||||
|
"bytes_written": (index + 1) * 1000,
|
||||||
|
"bytes_read": (index + 1) * 500,
|
||||||
|
"elapsed_s": 180,
|
||||||
|
"is_gap": False,
|
||||||
|
"is_zero": False,
|
||||||
|
"is_segment_boundary": False,
|
||||||
|
})
|
||||||
|
return intervals
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Cadence constants (issue #91 AC1)
|
# Cadence constants (issue #91 AC1)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -263,7 +282,7 @@ class TestLiveActivityGraph:
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
header = str(graph.query_one("#live-header").render())
|
header = str(graph.query_one("#live-header").render())
|
||||||
assert "1 intervals" in header
|
assert "1 intervals" in header
|
||||||
assert "w Toggle W/R" in header
|
assert "w W/R" in str(app.query_one("#activity-tools").render())
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -322,3 +341,85 @@ class TestTUILiveIntegration:
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
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")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("size", [(100, 40), (70, 20)])
|
||||||
|
async def test_follow_pin_expiry_and_today_use_visible_readout(self, tmp_path, size):
|
||||||
|
"""Live selection follows, pins, expires, and resets at both layout sizes."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
intervals = _live_intervals()
|
||||||
|
async with app.run_test(size=size) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#live-activity")
|
||||||
|
graph.set_data(intervals[:3])
|
||||||
|
|
||||||
|
readout = str(app.query_one("#live-readout").render())
|
||||||
|
assert "12:09 UTC" in readout
|
||||||
|
assert graph.selection.following_live
|
||||||
|
assert graph.selection.selected_live_interval == IntervalIdentity(
|
||||||
|
intervals[2]["start_ts"], intervals[2]["end_ts"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh while following selects the newly measured newest point.
|
||||||
|
graph.set_data(intervals)
|
||||||
|
assert "12:12 UTC" in str(app.query_one("#live-readout").render())
|
||||||
|
|
||||||
|
# Live graph receives focus at launch; first arrow pins immediately.
|
||||||
|
await pilot.press("left")
|
||||||
|
pinned = graph.selection.selected_live_interval
|
||||||
|
readout = str(app.query_one("#live-readout").render())
|
||||||
|
assert "12:09 UTC" in readout
|
||||||
|
assert not graph.selection.following_live
|
||||||
|
|
||||||
|
graph.set_data(intervals)
|
||||||
|
readout = str(app.query_one("#live-readout").render())
|
||||||
|
assert "12:09 UTC" in readout
|
||||||
|
assert graph.selection.selected_live_interval == pinned
|
||||||
|
|
||||||
|
# Read/write changes presentation only; pinned point stays stable.
|
||||||
|
await pilot.press("w")
|
||||||
|
graph.set_data(intervals)
|
||||||
|
assert graph.measure == "read"
|
||||||
|
assert graph.selection.selected_live_interval == pinned
|
||||||
|
assert "12:09 UTC" in str(app.query_one("#live-readout").render())
|
||||||
|
|
||||||
|
# Remove pinned interval from rolling window. Explain expiration.
|
||||||
|
graph.set_data(intervals[3:])
|
||||||
|
readout = str(app.query_one("#live-readout").render())
|
||||||
|
assert "Inspected interval expired; following live" in readout
|
||||||
|
assert "12:12 UTC" in readout
|
||||||
|
assert graph.selection.following_live
|
||||||
|
|
||||||
|
# Today action clears expiration and resumes newest-point following.
|
||||||
|
await pilot.press("t")
|
||||||
|
assert graph.selection.following_live
|
||||||
|
assert not graph.selection.live_interval_expired
|
||||||
|
graph.set_data(intervals)
|
||||||
|
readout = str(app.query_one("#live-readout").render())
|
||||||
|
assert "12:12 UTC" in readout
|
||||||
|
assert "Inspected interval expired" not in readout
|
||||||
|
assert graph.selection.following_live
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mouse_inspection_pins_same_interval_identity(self, tmp_path):
|
||||||
|
"""Mouse inspection pins the interval at its visible graph location."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
intervals = _live_intervals()
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#live-activity")
|
||||||
|
graph.set_data(intervals[:3])
|
||||||
|
await pilot.pause()
|
||||||
|
render = app.query_one("#live-render")
|
||||||
|
await pilot.click(
|
||||||
|
"#live-render",
|
||||||
|
offset=(8, max(0, render.content_size.height // 2)),
|
||||||
|
)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
expected = IntervalIdentity(
|
||||||
|
intervals[0]["start_ts"], intervals[0]["end_ts"]
|
||||||
|
)
|
||||||
|
assert graph.selection.selected_live_interval == expected
|
||||||
|
assert not graph.selection.following_live
|
||||||
|
assert "12:03 UTC" in str(app.query_one("#live-readout").render())
|
||||||
|
|||||||
+520
-14
@@ -25,6 +25,7 @@ from fenris.store import init_store, SCHEMA_VERSION
|
|||||||
from fenris.local_day import (
|
from fenris.local_day import (
|
||||||
derive_local_day_summary,
|
derive_local_day_summary,
|
||||||
persist_local_day,
|
persist_local_day,
|
||||||
|
record_local_activity_interval,
|
||||||
query_local_day_summary,
|
query_local_day_summary,
|
||||||
query_current_local_day,
|
query_current_local_day,
|
||||||
LocalDaySummary,
|
LocalDaySummary,
|
||||||
@@ -135,13 +136,56 @@ class TestSchemaMigration:
|
|||||||
assert count == 0
|
assert count == 0
|
||||||
conn.close()
|
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 derivation from UTC hours
|
# Local-day coverage derivation from UTC hours
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestDeriveLocalDay:
|
class TestDeriveLocalDay:
|
||||||
"""Derive local-day summaries from UTC hour observations."""
|
"""Derive coverage from UTC hours without allocating local-day bytes."""
|
||||||
|
|
||||||
def test_utc_plus_zero(self, tmp_path):
|
def test_utc_plus_zero(self, tmp_path):
|
||||||
"""UTC timezone: local day boundaries = UTC day boundaries."""
|
"""UTC timezone: local day boundaries = UTC day boundaries."""
|
||||||
@@ -158,8 +202,9 @@ class TestDeriveLocalDay:
|
|||||||
assert summary.local_date == "2026-09-01"
|
assert summary.local_date == "2026-09-01"
|
||||||
assert summary.tz_name == "UTC"
|
assert summary.tz_name == "UTC"
|
||||||
assert summary.tz_offset == "+00:00"
|
assert summary.tz_offset == "+00:00"
|
||||||
assert summary.bytes_written == 300
|
assert summary.bytes_written == 0
|
||||||
assert summary.bytes_read == 130
|
assert summary.bytes_read == 0
|
||||||
|
assert summary.activity_precision == "unavailable"
|
||||||
assert summary.complete is False # not all 24 hours covered
|
assert summary.complete is False # not all 24 hours covered
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -179,12 +224,13 @@ class TestDeriveLocalDay:
|
|||||||
assert summary.local_date == "2026-09-01"
|
assert summary.local_date == "2026-09-01"
|
||||||
assert summary.tz_name == "Asia/Kolkata"
|
assert summary.tz_name == "Asia/Kolkata"
|
||||||
assert summary.tz_offset == "+05:30"
|
assert summary.tz_offset == "+05:30"
|
||||||
assert summary.bytes_written == 300
|
assert summary.bytes_written == 0
|
||||||
assert summary.bytes_read == 130
|
assert summary.bytes_read == 0
|
||||||
|
assert summary.activity_precision == "unavailable"
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def test_midnight_spanning_hour_included(self, tmp_path):
|
def test_hourly_bytes_cannot_be_allocated_to_local_midnight(self, tmp_path):
|
||||||
"""UTC hour straddling local midnight is included in the local day."""
|
"""UTC-hour totals do not establish exact local-day byte totals."""
|
||||||
conn = init_store(tmp_path / "obs.db")
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
|
||||||
# For UTC+5:30, local day 2026-09-01 starts at 2026-08-31T18:30 UTC
|
# For UTC+5:30, local day 2026-09-01 starts at 2026-08-31T18:30 UTC
|
||||||
@@ -196,9 +242,9 @@ class TestDeriveLocalDay:
|
|||||||
summary = derive_local_day_summary(conn, "Asia/Kolkata", clock)
|
summary = derive_local_day_summary(conn, "Asia/Kolkata", clock)
|
||||||
|
|
||||||
assert summary is not None
|
assert summary is not None
|
||||||
# The midnight-spanning hour (18:00) is included
|
assert summary.bytes_written == 0
|
||||||
assert summary.bytes_written == 300
|
assert summary.bytes_read == 0
|
||||||
assert summary.bytes_read == 130
|
assert summary.activity_precision == "unavailable"
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def test_no_hours_returns_none(self, tmp_path):
|
def test_no_hours_returns_none(self, tmp_path):
|
||||||
@@ -387,6 +433,411 @@ class TestCollectorIntegration:
|
|||||||
assert row[1] == 30 * 512000 # reads
|
assert row[1] == 30 * 512000 # reads
|
||||||
conn.close()
|
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)
|
# DST handling (23/25-hour days)
|
||||||
@@ -402,17 +853,72 @@ class TestDSTHandling:
|
|||||||
# Simulate a 23-hour day in a timezone with DST
|
# Simulate a 23-hour day in a timezone with DST
|
||||||
# For simplicity, just verify the summary records the correct UTC range
|
# For simplicity, just verify the summary records the correct UTC range
|
||||||
clock = datetime(2026, 3, 8, 12, 0, 0, tzinfo=timezone.utc)
|
clock = datetime(2026, 3, 8, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
# US/Eastern springs forward on 2026-03-08
|
# New York springs forward on 2026-03-08
|
||||||
# Local day 2026-03-08 is 23 hours: UTC [07:00, 06:00+1d)
|
# 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-08T08:00:00+00:00", bw=100, active=3600)
|
||||||
_insert_hour(conn, "2026-03-08T12:00:00+00:00", bw=200, active=3600)
|
_insert_hour(conn, "2026-03-08T12:00:00+00:00", bw=200, active=3600)
|
||||||
|
|
||||||
summary = derive_local_day_summary(conn, "US/Eastern", clock)
|
summary = derive_local_day_summary(conn, "America/New_York", clock)
|
||||||
assert summary is not None
|
assert summary is not None
|
||||||
assert summary.local_date == "2026-03-08"
|
assert summary.local_date == "2026-03-08"
|
||||||
# UTC range should be approximately 23 hours
|
# UTC range should be approximately 23 hours
|
||||||
utc_start = datetime.fromisoformat(summary.utc_start)
|
utc_start = datetime.fromisoformat(summary.utc_start)
|
||||||
utc_end = datetime.fromisoformat(summary.utc_end)
|
utc_end = datetime.fromisoformat(summary.utc_end)
|
||||||
duration = (utc_end - utc_start).total_seconds()
|
duration = (utc_end - utc_start).total_seconds()
|
||||||
assert 22 * 3600 <= duration <= 24 * 3600 # ~23h ± tolerance
|
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()
|
conn.close()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO local_days "
|
"INSERT INTO local_days "
|
||||||
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
|
||||||
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||||
bw, br, coverage, samples, complete),
|
bw, br, coverage, samples, complete),
|
||||||
)
|
)
|
||||||
|
|||||||
+17
-15
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+67
-63
@@ -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,7 @@ 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",
|
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||||
@@ -129,8 +124,8 @@ def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO local_days "
|
"INSERT INTO local_days "
|
||||||
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
|
||||||
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||||
bw, br, coverage, samples, complete),
|
bw, br, coverage, samples, complete),
|
||||||
)
|
)
|
||||||
@@ -365,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
|
||||||
@@ -405,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) == 6
|
|
||||||
banner = str(paused_banner.render()).lower()
|
banner = str(paused_banner.render()).lower()
|
||||||
assert "monitoring: paused — deliberate disable" in banner
|
assert "monitoring: paused — deliberate disable" in banner
|
||||||
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner
|
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner
|
||||||
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()
|
||||||
@@ -423,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()
|
||||||
@@ -490,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())
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -600,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()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -864,15 +853,15 @@ class TestQueryHourlyGraphData:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 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):
|
||||||
@@ -884,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
|
||||||
@@ -902,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
|
||||||
@@ -911,7 +900,7 @@ class TestDailyBarGraph:
|
|||||||
assert len(graph._day_data) == 7
|
assert len(graph._day_data) == 7
|
||||||
|
|
||||||
def test_scale_includes_allocated_and_unallocated_writes(self):
|
def test_scale_includes_allocated_and_unallocated_writes(self):
|
||||||
graph = DailyBarGraph()
|
graph = HistoryGraph()
|
||||||
graph._all_day_data = [{
|
graph._all_day_data = [{
|
||||||
"day": "2026-09-16",
|
"day": "2026-09-16",
|
||||||
"total_bytes": 800,
|
"total_bytes": 800,
|
||||||
@@ -927,7 +916,7 @@ class TestDailyBarGraph:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
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):
|
||||||
@@ -943,20 +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())
|
range_label = str(app.query_one("#bar-range").render())
|
||||||
assert "14 days" in range_label
|
assert "14 days" in range_label
|
||||||
assert "UTC" in range_label
|
assert "UTC" in range_label
|
||||||
plotted = str(app.query_one("#bar-render").render())
|
plotted = str(app.query_one("#bar-render").render())
|
||||||
assert "Writes (" in plotted
|
assert "Writes (" in legend
|
||||||
assert "Day UTC" in plotted
|
assert "UTC" in range_label
|
||||||
assert "→" in plotted
|
assert "─" in plotted
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_arrow_selection(self, tmp_path):
|
async def test_arrow_selection(self, tmp_path):
|
||||||
@@ -972,6 +962,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()
|
||||||
@@ -1002,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()
|
||||||
@@ -1043,6 +1035,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()
|
||||||
@@ -1079,6 +1072,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()
|
||||||
@@ -1114,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()
|
||||||
|
|
||||||
@@ -1124,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):
|
||||||
@@ -1141,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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1172,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
|
||||||
@@ -1180,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):
|
||||||
@@ -1198,13 +1193,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=(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
|
||||||
@@ -1225,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
|
||||||
|
|
||||||
@@ -1247,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)
|
||||||
@@ -1285,6 +1283,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")
|
||||||
# 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")
|
||||||
@@ -1303,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
|
||||||
@@ -1314,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()
|
||||||
@@ -1335,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
|
||||||
@@ -1368,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()
|
||||||
@@ -1387,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
|
||||||
@@ -1396,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")
|
||||||
@@ -1415,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
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user