Files
Rbm.FamStack/tests/FamilyPlanner.Web.Tests/House/HousePageTests.cs
T
openclaw 70f1890641 feat: Phase 19 Haus - Vallox MV ventilation unit (profile, temperatures, profile switching)
- New device kind Ventilation with driver Vallox (WebSocket protocol of the unit's web UI:
  READ_TABLES/WRITE_DATA, checksum, big-endian table); no migration (enums stored as text)
- Read profile incl. remaining minutes, outdoor/supply/extract/exhaust air temperatures,
  humidity, CO2, fan speed; activate Home/Away/Boost/Fireplace (timed profiles use the
  duration configured on the unit)
- Same SSRF protection as Shelly (shared connection handler), permissions, action log, audit
- Admin dialog without generation/channel/credentials for ventilation, device card with
  values and profile buttons
- Tests: unit (protocol vs. reference bytes, driver, loopback WebSocket, SSRF, rules), bUnit
- ADR-0025, roadmap phase 19
2026-10-16 20:04:14 +02:00

382 lines
17 KiB
C#

using FamilyPlanner.Domain.Home;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Home;
using FamilyPlanner.Web.Client.Components.House;
using FamilyPlanner.Web.Client.Services;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
using HousePage = FamilyPlanner.Web.Client.Pages.House;
namespace FamilyPlanner.Web.Tests.House;
/// <summary>Haus-Seite: Räume, Gerätekarten (Status als Text), Schalten, Rechte (nur ansehen), Offline, Rollos, Sensoren, Plan mit Pins.</summary>
public sealed class HousePageTests : BunitContext
{
private readonly FakeHomeApi _api = new();
public HousePageTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddMudServices();
Services.AddSingleton<IHomeApi>(_api);
}
private IRenderedComponent<HouseHost> Open(HomeOverviewDto? overview = null)
{
if (overview is not null)
{
_api.Overview = overview;
}
return Render<HouseHost>(p => p.AddChildContent<HousePage>());
}
private static HomeOverviewDto Overview(IEnumerable<HomeDeviceDto> devices, IEnumerable<HomeRoomDto>? rooms = null, IEnumerable<HomePlanDto>? plans = null, bool manage = false) =>
new([.. rooms ?? []], [.. devices], [.. plans ?? []], manage);
[Fact]
public void Empty_state_for_people_without_any_share_has_no_manage_link()
{
var cut = Open(Overview([]));
Assert.Contains("noch keine Geräte freigegeben", cut.Find(".fp-house-empty").TextContent);
Assert.Empty(cut.FindAll(".fp-house-manage"));
Assert.Equal(0, _api.StateCalls); // ohne Geräte keine Zustandsabfrage
}
[Fact]
public void Admin_sees_the_link_to_the_administration()
{
var cut = Open(Overview([], manage: true));
Assert.Equal("haus/verwaltung", cut.Find(".fp-house-manage").GetAttribute("href"));
Assert.Contains("Hausverwaltung", cut.Find(".fp-house-empty").TextContent);
}
[Fact]
public void Load_error_is_shown_with_a_retry()
{
_api.OverviewProblem = new ApiProblem { Status = 500, Detail = "Der Server ist gerade nicht erreichbar." };
var cut = Open();
Assert.Contains("nicht erreichbar", cut.Find(".fp-house-error").TextContent);
_api.OverviewProblem = null;
_api.Overview = Overview([FakeHomeApi.Device("Licht")]);
cut.FindAll("button").Single(b => b.TextContent.Contains("Erneut versuchen")).Click();
cut.WaitForAssertion(() => Assert.Contains("Licht", cut.Markup));
}
[Fact]
public void Devices_are_grouped_by_room_and_unassigned_ones_come_last()
{
var kitchen = new HomeRoomDto(Guid.NewGuid(), "Küche", 10);
var living = new HomeRoomDto(Guid.NewGuid(), "Wohnzimmer", 5);
var a = FakeHomeApi.Device("Deckenlicht", room: living.Id);
var b = FakeHomeApi.Device("Kaffeemaschine", HomeDeviceKind.Socket, kitchen.Id);
var c = FakeHomeApi.Device("Flurlicht");
_api.States[a.Id] = FakeHomeApi.On(a.Id);
_api.States[b.Id] = FakeHomeApi.Off(b.Id);
_api.States[c.Id] = FakeHomeApi.Offline(c.Id);
var cut = Open(Overview([a, b, c], [kitchen, living]));
var headings = cut.FindAll(".fp-room-title").Select(h => h.TextContent.Trim()).ToList();
Assert.Equal(["Wohnzimmer", "Küche", "Weitere Geräte"], headings);
var cards = cut.FindAll(".fp-device");
Assert.Equal(3, cards.Count);
Assert.Contains("An", cards[0].QuerySelector(".fp-device-status-text")!.TextContent);
Assert.Contains("Aus", cards[1].QuerySelector(".fp-device-status-text")!.TextContent);
Assert.Contains("Offline", cards[2].QuerySelector(".fp-device-status-text")!.TextContent);
Assert.Contains("nicht erreichbar", cards[2].QuerySelector(".fp-device-message")!.TextContent);
}
[Fact]
public void Status_is_never_only_a_color()
{
var d = FakeHomeApi.Device("Lampe");
_api.States[d.Id] = FakeHomeApi.On(d.Id, 12.5);
var cut = Open(Overview([d]));
var card = cut.Find(".fp-device");
Assert.Contains("fp-device-on", card.ClassName);
Assert.NotEmpty(card.QuerySelectorAll(".fp-device-status svg")); // Symbol
Assert.Equal("An", card.QuerySelector(".fp-device-status-text")!.TextContent.Trim()); // Text
Assert.Contains("12,5 W", card.QuerySelector(".fp-device-detail")!.TextContent);
Assert.Equal("true", card.QuerySelector(".fp-device-toggle")!.GetAttribute("aria-pressed"));
}
[Fact]
public void Toggle_button_sends_the_matching_command_and_updates_the_card()
{
var d = FakeHomeApi.Device("Stehlampe");
_api.States[d.Id] = FakeHomeApi.Off(d.Id);
var cut = Open(Overview([d]));
cut.Find(".fp-device-toggle").Click();
cut.WaitForAssertion(() => Assert.Equal("An", cut.Find(".fp-device-status-text").TextContent.Trim()));
Assert.Equal([(d.Id, HomeCommand.TurnOn)], _api.Commands);
Assert.Contains("Stehlampe eingeschaltet", cut.Find(".mud-snackbar").TextContent);
cut.Find(".fp-device-toggle").Click();
cut.WaitForAssertion(() => Assert.Equal("Aus", cut.Find(".fp-device-status-text").TextContent.Trim()));
Assert.Equal(HomeCommand.TurnOff, _api.Commands[1].Command);
}
[Fact]
public void Failed_command_shows_the_friendly_device_message()
{
var d = FakeHomeApi.Device("Steckdose", HomeDeviceKind.Socket);
_api.States[d.Id] = FakeHomeApi.Off(d.Id);
_api.CommandHandler = (id, _) => ApiResult<HomeCommandResultDto>.Ok(new HomeCommandResultDto(false, "Das Gerät ist nicht erreichbar.", FakeHomeApi.Offline(id)));
var cut = Open(Overview([d]));
cut.Find(".fp-device-toggle").Click();
cut.WaitForAssertion(() => Assert.Contains("Steckdose: Das Gerät ist nicht erreichbar.", cut.Find(".mud-snackbar").TextContent));
Assert.Contains("Offline", cut.Find(".fp-device-status-text").TextContent);
Assert.True(cut.Find(".fp-device-toggle").HasAttribute("disabled")); // offline: nicht bedienbar
}
[Fact]
public void Permission_error_from_the_server_is_shown()
{
var d = FakeHomeApi.Device("Lampe");
_api.States[d.Id] = FakeHomeApi.Off(d.Id);
_api.CommandHandler = (_, _) => ApiResult<HomeCommandResultDto>.Fail(new ApiProblem { Status = 403, Detail = "Dieses Gerät darfst du nur ansehen, nicht schalten." });
var cut = Open(Overview([d]));
cut.Find(".fp-device-toggle").Click();
cut.WaitForAssertion(() => Assert.Contains("nur ansehen", cut.Find(".mud-snackbar").TextContent));
}
[Fact]
public void View_only_devices_have_disabled_controls_and_say_so()
{
var d = FakeHomeApi.Device("Nur ansehen", canControl: false);
var cover = FakeHomeApi.Device("Rollo (ansehen)", HomeDeviceKind.Cover, canControl: false);
_api.States[d.Id] = FakeHomeApi.On(d.Id);
_api.States[cover.Id] = FakeHomeApi.Cover(cover.Id, "open", 100);
var cut = Open(Overview([d, cover]));
Assert.True(cut.Find(".fp-device-toggle").HasAttribute("disabled"));
Assert.All(cut.FindAll("button.fp-device-open, button.fp-device-stop, button.fp-device-close"), b => Assert.True(b.HasAttribute("disabled")));
Assert.Equal(2, cut.FindAll(".fp-device-readonly").Count);
cut.Find(".fp-device-toggle").Click();
Assert.Empty(_api.Commands);
}
[Fact]
public void Cover_has_open_stop_close_with_labels_and_shows_the_movement()
{
var d = FakeHomeApi.Device("Wohnzimmer-Rollo", HomeDeviceKind.Cover);
_api.States[d.Id] = FakeHomeApi.Cover(d.Id, "closed", 0);
var cut = Open(Overview([d]));
Assert.Contains("Geschlossen", cut.Find(".fp-device-status-text").TextContent);
Assert.Equal("Wohnzimmer-Rollo öffnen", cut.Find(".fp-device-open").GetAttribute("aria-label"));
Assert.Equal("Wohnzimmer-Rollo stoppen", cut.Find(".fp-device-stop").GetAttribute("aria-label"));
Assert.Equal("Wohnzimmer-Rollo schließen", cut.Find(".fp-device-close").GetAttribute("aria-label"));
cut.Find(".fp-device-open").Click();
cut.WaitForAssertion(() => Assert.Contains("Öffnet", cut.Find(".fp-device-status-text").TextContent));
cut.Find(".fp-device-stop").Click();
cut.WaitForAssertion(() => Assert.Contains("Gestoppt (50 % offen)", cut.Find(".fp-device-status-text").TextContent));
cut.Find(".fp-device-close").Click();
cut.WaitForAssertion(() => Assert.Contains("Schließt", cut.Find(".fp-device-status-text").TextContent));
Assert.Equal([HomeCommand.Open, HomeCommand.Stop, HomeCommand.Close], _api.Commands.Select(c => c.Command));
}
[Fact]
public void Sensor_shows_values_and_has_no_controls()
{
var d = FakeHomeApi.Device("Keller", HomeDeviceKind.Sensor);
_api.States[d.Id] = FakeHomeApi.Sensor(d.Id, 18.46, 61);
var cut = Open(Overview([d]));
Assert.Equal("18,5 °C · 61 % Luftfeuchte", cut.Find(".fp-device-status-text").TextContent.Trim());
Assert.Empty(cut.FindAll(".fp-device-toggle, .fp-device-open"));
Assert.Empty(cut.FindAll(".fp-device-readonly"));
}
[Fact]
public void Ventilation_shows_profile_temperatures_and_profile_buttons()
{
var d = FakeHomeApi.Device("Lüftung", HomeDeviceKind.Ventilation);
_api.States[d.Id] = FakeHomeApi.Ventilation(d.Id, HomeVentilationProfile.Boost, minutesLeft: 25);
var cut = Open(Overview([d]));
Assert.Equal("Profil: Stoßlüften · noch 25 min", cut.Find(".fp-device-status-text").TextContent.Trim());
var values = cut.FindAll(".fp-vent-value").Select(v => v.TextContent.Replace("\n", " ").Trim()).ToList();
Assert.Contains(values, v => v.Contains("Außenluft", StringComparison.Ordinal) && v.Contains("5,2 °C", StringComparison.Ordinal));
Assert.Contains(values, v => v.Contains("Zuluft", StringComparison.Ordinal) && v.Contains("18,9 °C", StringComparison.Ordinal));
Assert.Contains(values, v => v.Contains("Abluft", StringComparison.Ordinal) && v.Contains("21,7 °C", StringComparison.Ordinal));
Assert.Contains(values, v => v.Contains("Fortluft", StringComparison.Ordinal) && v.Contains("7,1 °C", StringComparison.Ordinal));
Assert.Contains(values, v => v.Contains("45 %", StringComparison.Ordinal));
Assert.Contains(values, v => v.Contains("620 ppm", StringComparison.Ordinal));
Assert.Empty(cut.FindAll(".fp-device-toggle"));
var buttons = cut.FindAll(".fp-vent-profile");
Assert.Equal(["Zuhause", "Abwesend", "Stoßlüften", "Kamin"], buttons.Select(b => b.TextContent.Trim()));
Assert.Equal("true", cut.Find(".fp-vent-profile-boost").GetAttribute("aria-pressed"));
Assert.Equal("false", cut.Find(".fp-vent-profile-home").GetAttribute("aria-pressed"));
}
[Fact]
public void Choosing_a_ventilation_profile_sends_the_command()
{
var d = FakeHomeApi.Device("Lüftung", HomeDeviceKind.Ventilation);
_api.States[d.Id] = FakeHomeApi.Ventilation(d.Id, HomeVentilationProfile.Home);
var cut = Open(Overview([d]));
cut.Find(".fp-vent-profile-fireplace").Click();
cut.WaitForAssertion(() => Assert.Equal([(d.Id, HomeCommand.ProfileFireplace)], _api.Commands));
cut.Find(".fp-vent-profile-away").Click();
cut.WaitForAssertion(() => Assert.Equal(HomeCommand.ProfileAway, _api.Commands[1].Command));
}
[Fact]
public void View_only_ventilation_cannot_change_the_profile()
{
var d = FakeHomeApi.Device("Lüftung", HomeDeviceKind.Ventilation, canControl: false);
_api.States[d.Id] = FakeHomeApi.Ventilation(d.Id, HomeVentilationProfile.Away);
var cut = Open(Overview([d]));
Assert.All(cut.FindAll(".fp-vent-profile"), b => Assert.True(b.HasAttribute("disabled")));
Assert.Contains("Nur ansehen", cut.Find(".fp-device-readonly").TextContent);
Assert.Equal("Profil: Abwesend", cut.Find(".fp-device-status-text").TextContent.Trim());
}
[Fact]
public void Without_plans_there_are_no_tabs()
{
var cut = Open(Overview([FakeHomeApi.Device("A")]));
Assert.Empty(cut.FindAll(".fp-house-tabs"));
}
// ---------- Plan ----------
private (HomeDeviceDto Light, HomeDeviceDto Cover, HomePlanDto Plan) PlanSetup()
{
var light = FakeHomeApi.Device("Wohnzimmer-Licht", icon: "bulb");
var cover = FakeHomeApi.Device("Rollo", HomeDeviceKind.Cover);
var plan = new HomePlanDto(Guid.NewGuid(), "Erdgeschoss", 3, [new HomePinDto(light.Id, 0.25, 0.5), new HomePinDto(cover.Id, 0.8, 0.1)]);
_api.States[light.Id] = FakeHomeApi.Off(light.Id);
_api.States[cover.Id] = FakeHomeApi.Cover(cover.Id, "open", 100);
return (light, cover, plan);
}
[Fact]
public void Plan_tab_shows_the_image_and_accessible_pins_at_relative_positions()
{
var (light, cover, plan) = PlanSetup();
var cut = Open(Overview([light, cover], plans: [plan]));
cut.FindAll(".mud-tab").Single(t => t.TextContent.Contains("Erdgeschoss")).Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-plan")));
var image = cut.Find(".fp-plan-image");
Assert.Equal($"api/home/plans/{plan.Id}/image?v=3", image.GetAttribute("src"));
Assert.Contains("Grundriss Erdgeschoss", image.GetAttribute("alt"));
var pins = cut.FindAll(".fp-pin");
Assert.Equal(2, pins.Count);
Assert.Equal("left:25%;top:50%", pins[0].GetAttribute("style"));
Assert.Equal("left:80%;top:10%", pins[1].GetAttribute("style"));
Assert.All(pins, pin => Assert.Equal("BUTTON", pin.TagName));
Assert.Equal("Wohnzimmer-Licht", pins[0].GetAttribute("aria-label")); // Zustand nur über aria-pressed
Assert.Equal("false", pins[0].GetAttribute("aria-pressed"));
Assert.Contains("Rollo, Offen.", pins[1].GetAttribute("aria-label"));
Assert.Null(pins[1].GetAttribute("aria-pressed")); // Rollo: kein Klick-Umschalten
Assert.Equal("Aus", pins[0].QuerySelector(".fp-pin-badge")!.TextContent); // Kurztext zusätzlich zur Farbe
Assert.Contains("fp-pin-off", pins[0].ClassName);
}
[Fact]
public void Clicking_a_switch_pin_toggles_the_device_and_shows_its_details()
{
var (light, cover, plan) = PlanSetup();
var cut = Open(Overview([light, cover], plans: [plan]));
cut.FindAll(".mud-tab").Single(t => t.TextContent.Contains("Erdgeschoss")).Click();
cut.WaitForElement(".fp-pin");
cut.FindAll(".fp-pin")[0].Click();
cut.WaitForAssertion(() => Assert.Contains("fp-pin-on", cut.FindAll(".fp-pin")[0].ClassName));
Assert.Equal([(light.Id, HomeCommand.TurnOn)], _api.Commands);
var detail = cut.Find(".fp-house-detail");
Assert.Contains("Wohnzimmer-Licht", detail.TextContent);
Assert.Contains("An", detail.QuerySelector(".fp-device-status-text")!.TextContent);
}
[Fact]
public void Clicking_a_cover_pin_only_shows_the_details()
{
var (light, cover, plan) = PlanSetup();
var cut = Open(Overview([light, cover], plans: [plan]));
cut.FindAll(".mud-tab").Single(t => t.TextContent.Contains("Erdgeschoss")).Click();
cut.WaitForElement(".fp-pin");
cut.FindAll(".fp-pin")[1].Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-house-detail .fp-device-open")));
Assert.Empty(_api.Commands);
}
[Fact]
public void Offline_pin_is_marked_with_an_exclamation_mark_and_does_not_send_commands()
{
var (light, cover, plan) = PlanSetup();
_api.States[light.Id] = FakeHomeApi.Offline(light.Id);
var cut = Open(Overview([light, cover], plans: [plan]));
cut.FindAll(".mud-tab").Single(t => t.TextContent.Contains("Erdgeschoss")).Click();
cut.WaitForElement(".fp-pin");
var pin = cut.FindAll(".fp-pin")[0];
Assert.Equal("!", pin.QuerySelector(".fp-pin-badge")!.TextContent);
Assert.Contains("Offline", pin.GetAttribute("aria-label"));
pin.Click();
Assert.Empty(_api.Commands);
}
// ---------- Darstellung ----------
[Fact]
public void Pin_style_uses_invariant_percentages()
{
Assert.Equal("left:12.35%;top:100%", FloorPlanView.PinStyle(new HomePinDto(Guid.NewGuid(), 0.1235, 1)));
Assert.Equal("left:0%;top:50%", FloorPlanView.PinStyle(new HomePinDto(Guid.NewGuid(), 0, 0.5)));
}
[Theory]
[InlineData("opening", null, "Öffnet …")]
[InlineData("closing", null, "Schließt …")]
[InlineData("open", 100, "Offen")]
[InlineData("closed", 0, "Geschlossen")]
[InlineData("stopped", 30, "Gestoppt (30 % offen)")]
[InlineData("stopped", null, "Gestoppt")]
public void Cover_status_texts(string state, int? position, string expected)
{
var device = FakeHomeApi.Device("Rollo", HomeDeviceKind.Cover);
Assert.Equal(expected, HouseUi.StatusText(device, FakeHomeApi.Cover(device.Id, state, position)));
}
[Fact]
public void Unknown_state_shows_a_loading_text_not_a_wrong_status()
{
var device = FakeHomeApi.Device("Lampe");
Assert.Equal("Status wird geladen …", HouseUi.StatusText(device, null));
Assert.Equal(HouseUi.Status.Unknown, HouseUi.StatusOf(device, null));
}
}