Files
MeterVault/tests/Integration.Tests/ReadingEntryTests.cs
T
schmidt.florian af786c7b28
ci / build-test (push) Successful in 1m37s
Meters: add manual reading entry from the meter-detail Readings tab
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
2026-08-01 10:25:05 +02:00

171 lines
4.8 KiB
C#

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);
}
}