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:
@@ -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