Meters: add manual reading entry from the meter-detail Readings tab
ci / build-test (push) Successful in 1m37s
ci / build-test (push) Successful in 1m37s
Entering a reading by hand previously meant POST /api/v1/readings with an API key, or a one-row CSV through the import wizard. SourceType.Manual existed in the enum but nothing was behind it. This adds the click path, built for the case it is actually used in: walking to each manual meter with a phone in hand. "Add reading" on the Readings tab opens a dialog prefilled with the meter's last register value and the current local time, both editable: - An on-screen keypad, because a register is read standing at the meter. It behaves like a calculator against the prefill - the first digit replaces it (a fresh register), while backspace edits it in place, which is the common case since only a register's last digits move. - Typed input accepts both separators (last one wins), so a German and an English phone keyboard both do the right thing. ReadingEntry owns that rule and is unit-tested; it deliberately differs from GermanNumber, where a lone dot really is a thousands separator. - A live parsed-value echo plus delta-since-last, which is the net that catches a mistyped digit before it is committed. - Decrease / replaces-existing / future / backdated surfaced before saving, and DST spring-forward gaps refused rather than shifted. The verdict line sits in a fixed-height, no-wrap slot above the keypad. That is load-bearing, not cosmetic: an alert that appears there when the value dips below the last reading moves the keys out from under the user's thumb mid-entry, which is a guaranteed mistype on a phone. The long-form explanation goes below the keypad, where reflow is harmless. Saving goes through IngestionService.IngestByMeterAsync, so the monotonic-decrease guard and inline renormalization apply exactly as for any other ingest. A new optional quality parameter stamps the row ReadingQuality.Manual; null preserves today's behaviour, so a source re-reporting the same timestamp updates the value without silently relabelling a hand-entered or imported reading. Also: the meter-detail tabs now render times in the instance timezone per SDD section 10, instead of raw UTC. Without it a reading entered at 18:00 reads back as 16:00. Side effect is that historic imported monthly rows show 01:00/02:00 rather than 00:00 - correct, if noisier. Claude-Session: https://claude.ai/code/session_01D4x3JbNKCSV4cBR9s7bJmX
This commit is contained in:
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading timestamped and immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types (electricity, water, heating oil, gas, …) and meters are **user-defined, never hardcoded**.
|
||||
|
||||
**Status: implemented (M0–M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. **Admin write-CRUD** (SDD §8.7) is implemented as MudBlazor inline-dialog pages: energy types, meters (+ recompute on mode/baseline change), a meter's ingest sources (meter-detail Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`, secrets by env-var reference only). `/admin/settings` is a read-only effective-config view (settings are env-driven and reproducible, not DB-stored). **Home Assistant reading** is configured here: an HA connector (`BaseUrl` + `TokenEnv`) + an HA source (entity id) drives `HomeAssistantWorker`'s REST poll, or — with the connector's **WebSocket push** toggle (`HaEndpointConfig.UseWebSocket`) — `HomeAssistantWebSocketWorker` holds a persistent `state_changed` subscription and ingests in real time (the poll worker skips WS endpoints, so each is served once; `HaWebSocketProtocol` is the pure, unit-tested handshake/parse logic). `HaConnectionTester` powers the connector "Test connection" button. **Meter topology & flow**: `MeterLink` (a directed `from→to` edge; a downstream meter is a *subsection* of an upstream one, multi-parent allowed) drives a per-energy-type page `/energy/{id}` with a hand-rolled SVG **Sankey** (`SankeyChart.razor`, since ApexCharts has no Sankey type) computed by `FlowService` (link value = downstream consumption, split proportionally across multiple parents; unaccounted remainder → an "Other" node). Upstream meters are wired cycle-safely in the meter editor; the nav lists a link per energy type. **CSV mapping wizard** (`/import/wizard`): upload an arbitrary CSV, map columns → meters/roles, dry-run preview, then commit as a revertible `import_batch` (the `/import` page lists batches with one-click revert). The `instant_rate` mode is normalized (`InstantRateNormalizer`: rate integrated over time, trapezoidal). Remaining refinement: full de-DE UI-string localization (data parsing is already de-DE) — noted at its commit. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo.
|
||||
**Status: implemented (M0–M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. **Admin write-CRUD** (SDD §8.7) is implemented as MudBlazor inline-dialog pages: energy types, meters (+ recompute on mode/baseline change), a meter's ingest sources (meter-detail Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`, secrets by env-var reference only). **Manual readings** are entered from the meter-detail Readings tab ("Add reading"): a touch-first dialog prefilled with the meter's last register value and the current local time, with an on-screen keypad for phone entry at the meter, a live parsed-value + delta-since-last readout, and the decrease guard surfaced before saving. It goes through `IngestionService.IngestByMeterAsync(quality: Manual)`, so it is stamped `ReadingQuality.Manual` and renormalizes inline like any other ingest — the layout of that dialog deliberately reserves fixed space for its verdict line, because anything that reflows moves the keys out from under the user's thumb mid-entry. `/admin/settings` is a read-only effective-config view (settings are env-driven and reproducible, not DB-stored). **Home Assistant reading** is configured here: an HA connector (`BaseUrl` + `TokenEnv`) + an HA source (entity id) drives `HomeAssistantWorker`'s REST poll, or — with the connector's **WebSocket push** toggle (`HaEndpointConfig.UseWebSocket`) — `HomeAssistantWebSocketWorker` holds a persistent `state_changed` subscription and ingests in real time (the poll worker skips WS endpoints, so each is served once; `HaWebSocketProtocol` is the pure, unit-tested handshake/parse logic). `HaConnectionTester` powers the connector "Test connection" button. **Meter topology & flow**: `MeterLink` (a directed `from→to` edge; a downstream meter is a *subsection* of an upstream one, multi-parent allowed) drives a per-energy-type page `/energy/{id}` with a hand-rolled SVG **Sankey** (`SankeyChart.razor`, since ApexCharts has no Sankey type) computed by `FlowService` (link value = downstream consumption, split proportionally across multiple parents; unaccounted remainder → an "Other" node). Upstream meters are wired cycle-safely in the meter editor; the nav lists a link per energy type. **CSV mapping wizard** (`/import/wizard`): upload an arbitrary CSV, map columns → meters/roles, dry-run preview, then commit as a revertible `import_batch` (the `/import` page lists batches with one-click revert). The `instant_rate` mode is normalized (`InstantRateNormalizer`: rate integrated over time, trapezoidal). Remaining refinement: full de-DE UI-string localization (data parsing is already de-DE) — noted at its commit. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo.
|
||||
|
||||
## Source of truth
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ public static class ApiEndpoints
|
||||
{
|
||||
// Normalize once per meter after the batch, not per reading: a recompute rewrites the
|
||||
// meter's whole consumption series, so doing it inside the loop is quadratic.
|
||||
switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, renormalize: false, ct))
|
||||
switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, renormalize: false, cancellationToken: ct))
|
||||
{
|
||||
case IngestionOutcome.Written: written++; touched.Add(r.MeterId); break;
|
||||
case IngestionOutcome.Updated: updated++; touched.Add(r.MeterId); break;
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Nav
|
||||
@inject IServiceScopeFactory Scopes
|
||||
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@using MudBlazor
|
||||
|
||||
@@ -24,6 +27,18 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Big touch targets and tabular digits for the manual-reading dialog: it is used standing at a
|
||||
meter on a phone, where the default input sizes are fiddly. *@
|
||||
<style>
|
||||
.mv-reading-value input { font-size: 1.9rem; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
/* nowrap pins it to exactly two lines, so a long unit or a big delta cannot spill over and
|
||||
move the keypad; the full wording is repeated in the alert below the fold. */
|
||||
.mv-reading-verdict { display: flex; flex-direction: column; min-height: 2.6rem; }
|
||||
.mv-reading-verdict > * { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.mv-keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; }
|
||||
.mv-keypad .mud-button { height: 56px; font-size: 1.35rem; }
|
||||
</style>
|
||||
|
||||
<div class="d-flex align-center mb-4" style="gap:.75rem">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" />
|
||||
<MudText Typo="Typo.h4">@_detail.Name</MudText>
|
||||
@@ -138,20 +153,38 @@ else
|
||||
|
||||
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
|
||||
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
|
||||
@if (_detail.Mode == MeterMode.Virtual)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
A virtual meter is an expression over other meters, so it stores no readings of its own —
|
||||
enter the reading on the meter the expression refers to.
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-end mb-2">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add" OnClick="OpenReading">
|
||||
Add reading
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
@if (_detail.RecentReadings.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No raw readings.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentReadings.Count (raw, immutable audit truth).</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Most recent @_detail.RecentReadings.Count (raw, immutable audit truth). Times in @_tz.Id.
|
||||
</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
||||
<thead><tr><th>Time</th><th style="text-align:right">Value</th><th>Quality</th><th>Flags</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _detail.RecentReadings)
|
||||
{
|
||||
<tr>
|
||||
<td>@r.Time.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td>@Local(r.Time).ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td style="text-align:right">@Format.Number(r.Value, 2) @_detail.Unit</td>
|
||||
<td>@QualityChip(r.Quality)</td>
|
||||
<td>@(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString())</td>
|
||||
@@ -176,7 +209,7 @@ else
|
||||
@foreach (var c in _detail.RecentConsumption)
|
||||
{
|
||||
<tr>
|
||||
<td>@c.Time.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td>@Local(c.Time).ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td style="text-align:right">@Format.Number(c.Amount, 2) @_detail.Unit</td>
|
||||
<td>@c.Kind</td>
|
||||
<td>@QualityChip(c.Quality)</td>
|
||||
@@ -200,7 +233,7 @@ else
|
||||
@foreach (var e in _detail.Events)
|
||||
{
|
||||
<tr>
|
||||
<td>@e.Time.ToString("yyyy-MM-dd")</td>
|
||||
<td>@Local(e.Time).ToString("yyyy-MM-dd")</td>
|
||||
<td>@e.Type</td>
|
||||
<td style="text-align:right">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—")</td>
|
||||
<td style="text-align:right">@(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—")</td>
|
||||
@@ -288,6 +321,93 @@ else
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
|
||||
<MudDialog @bind-Visible="_readingOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Add reading — @_detail.Name</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@LastReadingCaption()</MudText>
|
||||
|
||||
<MudTextField T="string" Value="_entry.Text" ValueChanged="OnReadingTyped" Immediate="true"
|
||||
Label="@($"Reading ({_detail.Unit})")" Variant="Variant.Outlined"
|
||||
InputMode="DecimalKeyboard" Class="mv-reading-value" Clearable="true" />
|
||||
|
||||
@* Fixed height, and above the keypad on purpose. The verdict on a value has to be visible
|
||||
while it is being typed — the keypad pushes anything below it off a phone screen — but
|
||||
anything that grows or shrinks here would move the keys out from under the user's
|
||||
thumb mid-entry. So the slot is always the same size whether or not it says anything. *@
|
||||
<div class="mv-reading-verdict mt-1 mb-3">
|
||||
<MudText Typo="Typo.caption" Color="@(_entry.Value is null ? Color.Error : Color.Secondary)">
|
||||
@(_entry.Value is { } parsed ? $"= {Format.Number(parsed, 3)} {_detail.Unit}" : "Enter a value")
|
||||
</MudText>
|
||||
@if (ChangeSinceLast is { } change)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="@(WouldBeRejected ? Color.Warning : Color.Secondary)">
|
||||
@ChangeSinceText(change)@(WouldBeRejected ? " — will be rejected" : "")
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mv-keypad mb-3">
|
||||
@foreach (var key in Keypad)
|
||||
{
|
||||
var pressed = key;
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@(() => PressKey(pressed))">@pressed</MudButton>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center flex-wrap" style="gap:.75rem">
|
||||
<MudDatePicker @bind-Date="_readingDate" Label="Date" Variant="Variant.Outlined"
|
||||
Class="flex-grow-1" Style="min-width:150px" />
|
||||
<MudTimePicker @bind-Time="_readingTime" Label="Time" Variant="Variant.Outlined"
|
||||
Class="flex-grow-1" Style="min-width:130px" />
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.Schedule" OnClick="SetNow">Now</MudButton>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1">Local time in @_tz.Id.</MudText>
|
||||
|
||||
@* Everything below here can reflow freely: the dialog's buttons sit outside this scroll
|
||||
area, so nothing the user is aiming at moves. *@
|
||||
@if (EnteredTimeSkipped)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">
|
||||
That clock time never happened in @_tz.Id — the clocks moved forward. Pick another time.
|
||||
</MudAlert>
|
||||
}
|
||||
@if (WouldBeRejected)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-3">
|
||||
Below the last reading (@Format.Number(_detail.LastReadingValue ?? 0, 2) @_detail.Unit) on a
|
||||
register that only counts up, so it will be rejected. If the meter was swapped or reset,
|
||||
record that on the Events tab first.
|
||||
</MudAlert>
|
||||
}
|
||||
@if (ReplacesRecentReading)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
This meter already has a reading at that time — saving replaces its value.
|
||||
</MudAlert>
|
||||
}
|
||||
@if (IsFuture)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">That time is in the future.</MudAlert>
|
||||
}
|
||||
else if (IsBackdated)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
Backdated before the latest reading — consumption from there on is recomputed.
|
||||
</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _readingOpen = false)" Disabled="_readingSaving">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" Size="Size.Large"
|
||||
OnClick="SaveReadingAsync" Disabled="@(!CanSaveReading)">
|
||||
@(_readingSaving ? "Saving…" : "Save reading")
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
<MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText>
|
||||
@@ -366,11 +486,34 @@ else
|
||||
private SourceEdit _sourceEdit = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
private bool _readingOpen;
|
||||
private bool _readingSaving;
|
||||
private readonly ReadingEntry _entry = new();
|
||||
private DateTime? _readingDate;
|
||||
private TimeSpan? _readingTime;
|
||||
private TimeZoneInfo _tz = TimeZoneInfo.Utc;
|
||||
|
||||
/// <summary>Phone-dialpad order, ending in the row the thumb reaches last: separator, zero, backspace.</summary>
|
||||
private static readonly string[] Keypad = ["7", "8", "9", "4", "5", "6", "1", "2", "3", ",", "0", "⌫"];
|
||||
|
||||
/// <summary>
|
||||
/// A decimal keyboard, so a phone offers the separator. Hoisted out of the markup because
|
||||
/// <c>decimal</c> is a C# keyword and Razor would read the required <c>@</c> escape in an
|
||||
/// attribute as a transition.
|
||||
/// </summary>
|
||||
private const InputMode DecimalKeyboard = InputMode.@decimal;
|
||||
|
||||
private static readonly MeterMode[] MonotonicModes =
|
||||
[MeterMode.CumulativeCounter, MeterMode.GenerationCounter, MeterMode.RuntimeCounter];
|
||||
|
||||
protected override void OnInitialized() => _tz = ResolveTimeZone(Options.Value.TimeZone);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_detail = null;
|
||||
_periods = null;
|
||||
_notFound = false;
|
||||
_readingOpen = false;
|
||||
_detail = await Details.GetAsync(Id);
|
||||
_notFound = _detail is null;
|
||||
if (_detail is not null)
|
||||
@@ -380,6 +523,22 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
// Readings are stored UTC (SDD §10) and shown in the instance timezone, so a value entered at
|
||||
// 18:00 reads back as 18:00 rather than as its UTC instant.
|
||||
private static TimeZoneInfo ResolveTimeZone(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||
}
|
||||
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
|
||||
{
|
||||
return TimeZoneInfo.Utc;
|
||||
}
|
||||
}
|
||||
|
||||
private DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, _tz);
|
||||
|
||||
/// <summary>
|
||||
/// "+12%" / "−4%" against the previous period. Less is better for consumption and worse for
|
||||
/// generation, so colour is left to the caller's context rather than hardcoded green/red here.
|
||||
@@ -406,6 +565,169 @@ else
|
||||
+ "background:var(--mud-palette-primary); border-radius:2px 2px 0 0";
|
||||
}
|
||||
|
||||
private void OpenReading()
|
||||
{
|
||||
if (_detail is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SetNow();
|
||||
// Prefilling the last reading is what makes this quick standing at the meter: a register only
|
||||
// moves in its final digits, so backspace-and-retype beats keying six digits from scratch.
|
||||
// Falls back to the configured baseline while the meter has no readings at all.
|
||||
_entry.Prefill(_detail.LastReadingValue ?? _detail.InitialBaseline);
|
||||
_readingOpen = true;
|
||||
}
|
||||
|
||||
private void SetNow()
|
||||
{
|
||||
var now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, _tz);
|
||||
_readingDate = now.Date;
|
||||
_readingTime = new TimeSpan(now.Hour, now.Minute, 0);
|
||||
}
|
||||
|
||||
private void OnReadingTyped(string? value) => _entry.SetText(value);
|
||||
|
||||
private void PressKey(string key)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case "⌫":
|
||||
_entry.Backspace();
|
||||
break;
|
||||
case ",":
|
||||
_entry.AppendSeparator();
|
||||
break;
|
||||
default:
|
||||
_entry.AppendDigit(key[0]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private string LastReadingCaption()
|
||||
{
|
||||
if (_detail is not { } detail)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return detail is { LastReadingValue: { } value, LastReadingTime: { } time }
|
||||
? $"Last reading {Format.Number(value, 2)} {detail.Unit} on {Local(time):yyyy-MM-dd HH:mm}."
|
||||
: $"No readings yet — prefilled with this meter's baseline ({Format.Number(detail.InitialBaseline, 2)} {detail.Unit}).";
|
||||
}
|
||||
|
||||
/// <summary>The wall-clock instant the two pickers describe, read in the instance timezone.</summary>
|
||||
private DateTime? EnteredWallClock =>
|
||||
_readingDate is { } date ? date.Date + (_readingTime ?? TimeSpan.Zero) : null;
|
||||
|
||||
/// <summary>
|
||||
/// True when the chosen local time falls in a spring-forward gap and so names no instant at all.
|
||||
/// Converting it would throw, so the dialog blocks the save and says why instead.
|
||||
/// </summary>
|
||||
private bool EnteredTimeSkipped =>
|
||||
EnteredWallClock is { } wall && _tz.IsInvalidTime(DateTime.SpecifyKind(wall, DateTimeKind.Unspecified));
|
||||
|
||||
/// <remarks>
|
||||
/// An ambiguous autumn hour resolves to standard time, <see cref="TimeZoneInfo"/>'s default. The
|
||||
/// two candidate instants are an hour apart on one hour of one night a year — well inside the
|
||||
/// precision of a timestamp somebody typed by hand.
|
||||
/// </remarks>
|
||||
private DateTimeOffset? EnteredUtc
|
||||
{
|
||||
get
|
||||
{
|
||||
if (EnteredWallClock is not { } wall || EnteredTimeSkipped)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var unspecified = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
|
||||
return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(unspecified, _tz), TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsMonotonic => _detail is not null && Array.IndexOf(MonotonicModes, _detail.Mode) >= 0;
|
||||
|
||||
private bool IsBackdated => EnteredUtc is { } entered && _detail?.LastReadingTime is { } last && entered < last;
|
||||
|
||||
// A minute of slack so "now" never trips the future warning on a slow round trip.
|
||||
private bool IsFuture => EnteredUtc is { } entered && entered > DateTimeOffset.UtcNow.AddMinutes(1);
|
||||
|
||||
private double? ChangeSinceLast =>
|
||||
!IsBackdated && _entry.Value is { } value && _detail?.LastReadingValue is { } last ? value - last : null;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the ingestion guard closely enough to warn before saving rather than after. The
|
||||
/// service compares against the reading immediately before the entered time; this page only
|
||||
/// holds the latest one, so a backdated entry gets no verdict rather than a wrong one.
|
||||
/// </summary>
|
||||
private bool WouldBeRejected =>
|
||||
IsMonotonic && !IsBackdated && _entry.Value is { } value
|
||||
&& _detail?.LastReadingValue is { } last && value < last;
|
||||
|
||||
/// <summary>
|
||||
/// Whether saving would overwrite a reading the page already lists. Bounded to the loaded rows,
|
||||
/// so it is a heads-up rather than a guarantee — the save reports what actually happened.
|
||||
/// </summary>
|
||||
private bool ReplacesRecentReading =>
|
||||
EnteredUtc is { } entered && _detail is not null && _detail.RecentReadings.Any(r => r.Time == entered);
|
||||
|
||||
private bool CanSaveReading =>
|
||||
!_readingSaving && _entry.Value is not null && EnteredWallClock is not null && !EnteredTimeSkipped;
|
||||
|
||||
private string ChangeSinceText(double change) =>
|
||||
Math.Abs(change) < 1e-9
|
||||
? "no change since last reading"
|
||||
: $"{(change > 0 ? "+" : "−")}{Format.Number(Math.Abs(change), 2)} {_detail?.Unit} since last reading";
|
||||
|
||||
private async Task SaveReadingAsync()
|
||||
{
|
||||
if (_detail is null || _entry.Value is not { } value || EnteredUtc is not { } utc)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_readingSaving = true;
|
||||
try
|
||||
{
|
||||
// A scope per operation: IngestionService holds a scoped DbContext, and a Blazor circuit
|
||||
// long outlives the unit of work a single save should share one with.
|
||||
await using var scope = Scopes.CreateAsyncScope();
|
||||
var ingestion = scope.ServiceProvider.GetRequiredService<IngestionService>();
|
||||
var outcome = await ingestion.IngestByMeterAsync(
|
||||
Id, utc, value, renormalize: true, quality: ReadingQuality.Manual);
|
||||
|
||||
switch (outcome)
|
||||
{
|
||||
case IngestionOutcome.Written:
|
||||
Snackbar.Add($"Reading saved: {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success);
|
||||
break;
|
||||
case IngestionOutcome.Updated:
|
||||
Snackbar.Add($"Replaced the reading at that time with {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success);
|
||||
break;
|
||||
case IngestionOutcome.RejectedDecrease:
|
||||
// Leave the dialog open: the typed value is still on screen to correct, and the
|
||||
// alternative fix — recording a reset or swap — is a decision, not a retry.
|
||||
Snackbar.Add(
|
||||
"Rejected — below the previous reading on a register that only counts up. "
|
||||
+ "Record a counter reset or meter swap first.", Severity.Error);
|
||||
return;
|
||||
default:
|
||||
Snackbar.Add("This meter no longer exists.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
_readingOpen = false;
|
||||
_detail = await Details.GetAsync(Id);
|
||||
_periods = _detail is null ? null : await Periods.GetAsync(Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_readingSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadSourcesAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace MeterVault.App;
|
||||
|
||||
/// <summary>
|
||||
/// The edit buffer behind the manual-reading keypad. Holds the value as text rather than a number
|
||||
/// so a half-typed entry ("12345," while the decimals are still coming) is representable and no
|
||||
/// intermediate state gets rounded away by a numeric binding.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Separator handling is deliberately lenient rather than culture-strict: the same field is driven
|
||||
/// by the on-screen keypad (always a comma), by an Android keyboard (comma on a German locale, dot
|
||||
/// on an English one) and by a desktop numpad, so a value has to survive either character. The rule
|
||||
/// is that only the <em>last</em> separator is decimal and earlier ones are grouping — "1.234,5"
|
||||
/// and "1,234.5" both give 1234.5. A lone separator is therefore always decimal ("1.234" → 1.234),
|
||||
/// which is what the keypad emits and what an English keyboard means; nobody types thousands
|
||||
/// separators into a meter register, and the dialog echoes the parsed value back formatted, so a
|
||||
/// misread is visible before saving. This differs from
|
||||
/// <see cref="Core.Parsing.GermanNumber"/> on purpose: that one parses spreadsheet exports, where a
|
||||
/// lone dot really is a thousands separator.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="IsPristine"/> gives the buffer calculator behaviour. The dialog opens prefilled with
|
||||
/// the meter's last reading; the first digit key then replaces it outright (a fresh reading), while
|
||||
/// backspace edits it in place (only the last few digits of a register usually move). Without that
|
||||
/// distinction one of the two workflows always costs a full retype on a phone.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ReadingEntry
|
||||
{
|
||||
/// <summary>Wide enough for any real register plus decimals; stops a stuck key growing the string.</summary>
|
||||
public const int MaxLength = 18;
|
||||
|
||||
private const char Separator = ',';
|
||||
|
||||
public string Text { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>True while the buffer still holds the untouched prefill, so the next digit replaces it.</summary>
|
||||
public bool IsPristine { get; private set; }
|
||||
|
||||
/// <summary>The entered number, or null while the buffer is empty or not yet a valid number.</summary>
|
||||
public double? Value => TryParse(Text, out var value) ? value : null;
|
||||
|
||||
/// <summary>Seeds the buffer with a meter's last reading, marked pristine.</summary>
|
||||
public void Prefill(double value)
|
||||
{
|
||||
// "0.###" keeps a register readable (12345,6) without inventing precision the meter
|
||||
// never had; invariant then swapped so the buffer only ever contains one separator glyph.
|
||||
Text = value.ToString("0.###", CultureInfo.InvariantCulture).Replace('.', Separator);
|
||||
IsPristine = true;
|
||||
}
|
||||
|
||||
public void AppendDigit(char digit)
|
||||
{
|
||||
if (!char.IsAsciiDigit(digit))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ReplacePrefillOnFirstKey();
|
||||
|
||||
// A leading zero is never meaningful on a register, and letting it stand makes "0" then "5"
|
||||
// read as "05" — replace it instead, exactly like a calculator.
|
||||
if (Text == "0")
|
||||
{
|
||||
Text = digit.ToString();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Text.Length < MaxLength)
|
||||
{
|
||||
Text += digit;
|
||||
}
|
||||
}
|
||||
|
||||
public void AppendSeparator()
|
||||
{
|
||||
ReplacePrefillOnFirstKey();
|
||||
|
||||
if (Text.Contains(Separator, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// "," alone parses as nothing, so lead with the zero the user means.
|
||||
Text = Text.Length == 0 ? "0" + Separator : Text + Separator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the last character. Unlike a digit key this keeps the prefill rather than clearing
|
||||
/// it — reading a register usually means correcting its final digits, not retyping all of them.
|
||||
/// </summary>
|
||||
public void Backspace()
|
||||
{
|
||||
IsPristine = false;
|
||||
if (Text.Length > 0)
|
||||
{
|
||||
Text = Text[..^1];
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Text = string.Empty;
|
||||
IsPristine = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts free text from the keyboard-bound field, keeping only characters that can form a
|
||||
/// number. Junk is dropped rather than rejected so typing never dead-ends mid-value.
|
||||
/// </summary>
|
||||
public void SetText(string? raw)
|
||||
{
|
||||
IsPristine = false;
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
Text = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(Math.Min(raw.Length, MaxLength));
|
||||
foreach (var c in raw)
|
||||
{
|
||||
if (builder.Length >= MaxLength)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (char.IsAsciiDigit(c) || c is '.' or ',' || (c == '-' && builder.Length == 0))
|
||||
{
|
||||
builder.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
Text = builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Parses a buffer as described on the type: last separator decimal, earlier ones grouping.</summary>
|
||||
public static bool TryParse(string? text, out double value)
|
||||
{
|
||||
value = 0;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var raw = text.Trim();
|
||||
var negative = raw[0] == '-';
|
||||
if (negative || raw[0] == '+')
|
||||
{
|
||||
raw = raw[1..];
|
||||
}
|
||||
|
||||
var lastSeparator = raw.LastIndexOfAny(['.', ',']);
|
||||
var builder = new StringBuilder(raw.Length);
|
||||
var digits = 0;
|
||||
for (var i = 0; i < raw.Length; i++)
|
||||
{
|
||||
var c = raw[i];
|
||||
if (char.IsAsciiDigit(c))
|
||||
{
|
||||
builder.Append(c);
|
||||
digits++;
|
||||
}
|
||||
else if (c is '.' or ',')
|
||||
{
|
||||
if (i == lastSeparator)
|
||||
{
|
||||
builder.Append('.');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (digits == 0
|
||||
|| !double.TryParse(builder.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = negative ? -parsed : parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ReplacePrefillOnFirstKey()
|
||||
{
|
||||
if (IsPristine)
|
||||
{
|
||||
Text = string.Empty;
|
||||
IsPristine = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,21 +56,26 @@ public sealed class IngestionService(
|
||||
return IngestionOutcome.RejectedDecrease;
|
||||
}
|
||||
|
||||
var outcome = await UpsertAsync(meter, utc, value, source.Id, cancellationToken).ConfigureAwait(false);
|
||||
var outcome = await UpsertAsync(meter, utc, value, source.Id, quality: null, cancellationToken).ConfigureAwait(false);
|
||||
await UpdateSourceStatusAsync(source, utc, value, "ok", cancellationToken).ConfigureAwait(false);
|
||||
await RenormalizeAsync(meter.Id, outcome, cancellationToken).ConfigureAwait(false);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/// <summary>Ingests directly against a meter (REST push, e.g. Home Assistant POST /api/v1/readings).</summary>
|
||||
/// <summary>Ingests directly against a meter (REST push, or a hand-entered reading from the UI).</summary>
|
||||
/// <param name="renormalize">
|
||||
/// False to skip deriving consumption, for callers ingesting a batch into one meter: recomputing
|
||||
/// rewrites the meter's entire series, so doing it per reading is quadratic in batch size. Such a
|
||||
/// caller must recompute the affected meters itself once the batch is in.
|
||||
/// </param>
|
||||
/// <param name="quality">
|
||||
/// Provenance to stamp on the row. Null keeps the default for a new row and leaves an existing
|
||||
/// row's quality alone — a source re-reporting a timestamp must not silently relabel a reading
|
||||
/// somebody entered by hand or that came from an import.
|
||||
/// </param>
|
||||
public async Task<IngestionOutcome> IngestByMeterAsync(
|
||||
int meterId, DateTimeOffset time, double value, bool renormalize = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
ReadingQuality? quality = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var meter = await _db.Meters
|
||||
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
|
||||
@@ -86,7 +91,7 @@ public sealed class IngestionService(
|
||||
return IngestionOutcome.RejectedDecrease;
|
||||
}
|
||||
|
||||
var outcome = await UpsertAsync(meter, utc, value, sourceId: null, cancellationToken).ConfigureAwait(false);
|
||||
var outcome = await UpsertAsync(meter, utc, value, sourceId: null, quality, cancellationToken).ConfigureAwait(false);
|
||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (renormalize)
|
||||
{
|
||||
@@ -161,7 +166,8 @@ public sealed class IngestionService(
|
||||
}
|
||||
|
||||
private async Task<IngestionOutcome> UpsertAsync(
|
||||
Meter meter, DateTimeOffset utc, double value, int? sourceId, CancellationToken cancellationToken)
|
||||
Meter meter, DateTimeOffset utc, double value, int? sourceId, ReadingQuality? quality,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = _db.Readings.Local.FirstOrDefault(r => r.MeterId == meter.Id && r.Time == utc)
|
||||
?? await _db.Readings.FirstOrDefaultAsync(r => r.MeterId == meter.Id && r.Time == utc, cancellationToken).ConfigureAwait(false);
|
||||
@@ -174,13 +180,18 @@ public sealed class IngestionService(
|
||||
Time = utc,
|
||||
Value = value,
|
||||
SourceId = sourceId,
|
||||
Quality = ReadingQuality.Measured,
|
||||
Quality = quality ?? ReadingQuality.Measured,
|
||||
});
|
||||
return IngestionOutcome.Written;
|
||||
}
|
||||
|
||||
existing.Value = value;
|
||||
existing.SourceId = sourceId ?? existing.SourceId;
|
||||
if (quality is { } stamp)
|
||||
{
|
||||
existing.Quality = stamp;
|
||||
}
|
||||
|
||||
return IngestionOutcome.Updated;
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,12 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
var response = await client.GetAsync(new Uri(path, UriKind.Relative));
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
// Manual entry is reachable without an API key or a CSV: the Readings tab of a real
|
||||
// (non-virtual) meter offers it, prefilled with that meter's last register value.
|
||||
var meterPage = await (await client.GetAsync(new Uri($"/meters/{hausId}", UriKind.Relative)))
|
||||
.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Add reading", meterPage, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -199,6 +199,69 @@ public sealed class IngestionServiceTests(TimescaleFixture fx)
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_hand_entered_reading_is_stamped_manual_and_normalizes_immediately()
|
||||
{
|
||||
// The meter-detail "Add reading" path: provenance has to survive, otherwise a value somebody
|
||||
// walked to the meter to read is indistinguishable from one a sensor reported.
|
||||
await using var db = fx.CreateContext();
|
||||
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter);
|
||||
var service = NewIngestion(db);
|
||||
|
||||
var written = await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual);
|
||||
|
||||
Assert.Equal(IngestionOutcome.Written, written);
|
||||
var reading = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0);
|
||||
Assert.Equal(ReadingQuality.Manual, reading.Quality);
|
||||
Assert.True(await db.Consumption.AnyAsync(c => c.MeterId == meterId));
|
||||
|
||||
// Correcting a typo re-enters the same timestamp: value replaced, still manual.
|
||||
var updated = await service.IngestByMeterAsync(meterId, T0, 1100, quality: ReadingQuality.Manual);
|
||||
|
||||
Assert.Equal(IngestionOutcome.Updated, updated);
|
||||
var corrected = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0);
|
||||
Assert.Equal(1100d, corrected.Value, 6);
|
||||
Assert.Equal(ReadingQuality.Manual, corrected.Quality);
|
||||
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_source_reporting_the_same_timestamp_does_not_relabel_a_hand_entered_reading()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
|
||||
var service = NewIngestion(db);
|
||||
|
||||
await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual);
|
||||
await service.IngestAsync(sourceId, T0, 1200); // same instant, this time from the broker
|
||||
|
||||
var reading = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0);
|
||||
Assert.Equal(1200d, reading.Value, 6); // the newer value still wins...
|
||||
Assert.Equal(ReadingQuality.Manual, reading.Quality); // ...but provenance is not silently rewritten
|
||||
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_hand_entered_decrease_on_a_counter_is_rejected_like_any_other()
|
||||
{
|
||||
// The dialog warns before saving, but the guard is what actually protects the series: a
|
||||
// mistyped register must not silently wipe out a month of consumption.
|
||||
await using var db = fx.CreateContext();
|
||||
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter);
|
||||
var service = NewIngestion(db);
|
||||
|
||||
await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual);
|
||||
var outcome = await service.IngestByMeterAsync(
|
||||
meterId, T0.AddDays(30), 100, quality: ReadingQuality.Manual);
|
||||
|
||||
Assert.Equal(IngestionOutcome.RejectedDecrease, outcome);
|
||||
Assert.False(await db.Readings.AnyAsync(r => r.MeterId == meterId && r.Time == T0.AddDays(30)));
|
||||
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
private static IngestionService NewIngestion(MeterVaultDbContext db) =>
|
||||
new(db, new MeterVault.Infrastructure.Normalization.NormalizationService(
|
||||
db, MeterVault.Core.Normalization.NormalizationEngine.CreateDefault()));
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using MeterVault.App;
|
||||
|
||||
namespace MeterVault.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The manual-reading keypad buffer: the pure part of entering a reading by hand at the meter.
|
||||
/// No database — these are the rules that decide what number gets stored, so they are pinned here
|
||||
/// rather than left to a render test.
|
||||
/// </summary>
|
||||
public sealed class ReadingEntryTests
|
||||
{
|
||||
[Theory]
|
||||
// The keypad's own output, and what a German keyboard produces.
|
||||
[InlineData("12345,6", 12345.6)]
|
||||
[InlineData("12345,", 12345)]
|
||||
// An English keyboard: a lone dot is decimal, because nobody groups thousands on a register.
|
||||
[InlineData("12345.6", 12345.6)]
|
||||
[InlineData("1.234", 1.234)]
|
||||
// Both separators present: the last one is decimal, whichever dialect it came from.
|
||||
[InlineData("1.234,5", 1234.5)]
|
||||
[InlineData("1,234.5", 1234.5)]
|
||||
[InlineData("1.234.567,89", 1234567.89)]
|
||||
[InlineData("0", 0)]
|
||||
[InlineData("-12,5", -12.5)]
|
||||
public void Parses_either_dialect_taking_the_last_separator_as_decimal(string text, double expected)
|
||||
{
|
||||
Assert.True(ReadingEntry.TryParse(text, out var value));
|
||||
Assert.Equal(expected, value, 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(",")]
|
||||
[InlineData("-")]
|
||||
[InlineData("12 345")]
|
||||
[InlineData("abc")]
|
||||
public void Rejects_input_that_is_not_a_number(string text) =>
|
||||
Assert.False(ReadingEntry.TryParse(text, out _));
|
||||
|
||||
[Fact]
|
||||
public void Prefill_shows_the_last_reading_in_the_dialect_the_keypad_types()
|
||||
{
|
||||
var entry = new ReadingEntry();
|
||||
entry.Prefill(12345.6);
|
||||
|
||||
Assert.Equal("12345,6", entry.Text);
|
||||
Assert.True(entry.IsPristine);
|
||||
Assert.Equal(12345.6, entry.Value!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefill_does_not_invent_precision_the_meter_never_reported()
|
||||
{
|
||||
var entry = new ReadingEntry();
|
||||
entry.Prefill(2287);
|
||||
|
||||
Assert.Equal("2287", entry.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First_digit_after_a_prefill_starts_a_fresh_value()
|
||||
{
|
||||
// Reading a completely different register: typing must not append to the prefill.
|
||||
var entry = new ReadingEntry();
|
||||
entry.Prefill(12345.6);
|
||||
|
||||
entry.AppendDigit('9');
|
||||
|
||||
Assert.Equal("9", entry.Text);
|
||||
Assert.False(entry.IsPristine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backspace_after_a_prefill_edits_it_in_place()
|
||||
{
|
||||
// The walk-up case: only the last digits of a register have moved, so backspace keeps the
|
||||
// leading digits instead of clearing them the way a digit key does.
|
||||
var entry = new ReadingEntry();
|
||||
entry.Prefill(12345.6);
|
||||
|
||||
entry.Backspace(); // "12345,"
|
||||
entry.Backspace(); // "12345"
|
||||
entry.AppendDigit('8');
|
||||
entry.AppendSeparator();
|
||||
entry.AppendDigit('9');
|
||||
|
||||
Assert.Equal("123458,9", entry.Text);
|
||||
Assert.Equal(123458.9, entry.Value!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accepts_at_most_one_separator()
|
||||
{
|
||||
var entry = new ReadingEntry();
|
||||
entry.AppendDigit('1');
|
||||
entry.AppendSeparator();
|
||||
entry.AppendSeparator();
|
||||
entry.AppendDigit('5');
|
||||
|
||||
Assert.Equal("1,5", entry.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_leading_separator_becomes_a_leading_zero()
|
||||
{
|
||||
var entry = new ReadingEntry();
|
||||
entry.AppendSeparator();
|
||||
entry.AppendDigit('5');
|
||||
|
||||
Assert.Equal("0,5", entry.Text);
|
||||
Assert.Equal(0.5, entry.Value!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_leading_zero_is_replaced_rather_than_kept()
|
||||
{
|
||||
var entry = new ReadingEntry();
|
||||
entry.AppendDigit('0');
|
||||
entry.AppendDigit('7');
|
||||
|
||||
Assert.Equal("7", entry.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_empties_the_buffer_and_leaves_no_value()
|
||||
{
|
||||
var entry = new ReadingEntry();
|
||||
entry.Prefill(500);
|
||||
entry.Clear();
|
||||
|
||||
Assert.Equal(string.Empty, entry.Text);
|
||||
Assert.False(entry.IsPristine);
|
||||
Assert.Null(entry.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backspace_on_an_empty_buffer_is_a_no_op()
|
||||
{
|
||||
var entry = new ReadingEntry();
|
||||
entry.Backspace();
|
||||
|
||||
Assert.Equal(string.Empty, entry.Text);
|
||||
Assert.Null(entry.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Typed_text_keeps_only_what_can_form_a_number()
|
||||
{
|
||||
var entry = new ReadingEntry();
|
||||
entry.Prefill(1);
|
||||
entry.SetText("1 234,5 kWh");
|
||||
|
||||
Assert.Equal("1234,5", entry.Text);
|
||||
Assert.False(entry.IsPristine);
|
||||
Assert.Equal(1234.5, entry.Value!.Value, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stuck_key_cannot_grow_the_buffer_without_bound()
|
||||
{
|
||||
var entry = new ReadingEntry();
|
||||
for (var i = 0; i < ReadingEntry.MaxLength + 10; i++)
|
||||
{
|
||||
entry.AppendDigit('9');
|
||||
}
|
||||
|
||||
Assert.Equal(ReadingEntry.MaxLength, entry.Text.Length);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user