diff --git a/AGENTS.md b/AGENTS.md index 5811733..252e890 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,8 @@ src/DeviceBatteryInfo/ BatteryIntegration.Batteries.cs the same partial class: IBatteryProvider (SDK beta.17), feeding Macro Deck's own battery catalog and built-in Battery widget HostBatteries/ HostBatteryDevices (slot + snapshot -> BatteryDevice), HostBatteryPublisher - (one diffed publish per poll, RemoveAsync for a deleted entry) + (one diffed publish per poll, RemoveAsync for a deleted entry), + ExternalBatteryDevices (other providers' devices for the widgets) ConfigFlow/ DeviceConfigFlow (host-rendered steps per device: basics picks a name and a category, "Other devices" adds a brand/model step; a backend that still needs an address or a device name (adb, Bluetooth) then gets a details @@ -98,6 +99,8 @@ tests/DeviceBatteryInfo.Tests/ LinuxSourceTests.cs power_supply and BlueZ parsers, sysfs HID interface and unit keys HostBatteryPublisherTests.cs the host battery catalog: one publish per change, renames, deletes, out-of-reach devices, the kind mapping + ExternalBatteryDevicesTests.cs other providers' devices in the picker and as widget rows, our own + filtered out, a live widget session, the reverse kind and state mapping HidTransportPlatformTests.cs macOS entry ids and descriptors (a captured G Pro X Wireless one), Windows interface and unit keys, the bounded exchange on a fake channel, vendor-collection matching @@ -288,6 +291,16 @@ Design knowledge that is not obvious from the code alone: provider, so `AttachAsync` forgets what it sent. Our kinds map to the host's closest one (headset and earbuds to headphones, the system battery to laptop, pen and VR headset to other); our own widget keeps its icons. +- **The widgets can also show other providers' devices** from the same catalog + (`IIntegrationContext.Batteries`, read through `ExternalBatteryDevices`). They are widget-only: no + `battery__*` variables, no events, no trend. A widget stores one by its qualified catalog id + (`:`), whose colon keeps it apart from every slot id, and an empty `sourceIds` still + means this plugin's devices only, so existing widgets do not grow new rows. Our own devices come back + through the catalog too and are filtered out by the local id we published. The row id is a hash of the + catalog id (`RowKey`), because a `UiRepeat` key takes no colon. The host drops a `Changed` handler + subscribed through the context on every re-initialization, and widget sessions outlive that, so the + integration re-subscribes in `InitializeAsync` and re-raises its own `BatteryCatalogChanged` for the + sessions. A withdrawn device (its plugin stopped) drops out of the widget until it is published again. - **Charging is inferred where a source cannot tell.** Bluetooth only ever reports a level, so `BluetoothBatterySource` fills in `Discharging` with `BatteryReading.StatusIsAssumed`; an `Unknown` status counts the same. `ChargingInference` (a DI singleton like `BatteryTrendTracker`, applied by diff --git a/README.md b/README.md index db1e2f2..ca7ab5d 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ plugin's variables, which stay unchanged. A device you are not reaching right no last level and shows as disconnected; deleting the device removes it. Macro Deck lets every installed plugin read these devices (name, kind and level). +**Devices from other plugins** work the other way round: the widgets' device list also offers every +device another plugin or Macro Deck itself reports to Macro Deck (for example the phones of the +built-in ADB integration), shown as "device - plugin". They appear in the widgets only, with their +level, charging state and connection; they get no variables or events from this plugin and no battery +trend. A widget with no devices picked still shows only this plugin's devices. + ## Supported devices ### Generic sources @@ -212,7 +218,8 @@ src/DeviceBatteryInfo/ BatteryIntegration.Widgets.cs the same partial class: IWidgetTypeProvider + IUiProvider BatteryIntegration.Issues.cs the same partial class: IIntegrationIssueProvider (Linux HID access) BatteryIntegration.Batteries.cs the same partial class: IBatteryProvider (Macro Deck's battery catalog) - HostBatteries/ configured devices -> Macro Deck's battery catalog, published after each poll + HostBatteries/ configured devices -> Macro Deck's battery catalog, published after each poll, + and other providers' devices from that catalog for the widgets ConfigFlow/ the device config flow (add/edit steps, discovery, the "Other devices" catalog) Core/ IBatterySource, BatteryReading, BatteryRegistry, BatteryPollingService, BatteryPluginOptions, DeviceCatalog, DeviceAccessProblems diff --git a/src/DeviceBatteryInfo/BatteryIntegration.Batteries.cs b/src/DeviceBatteryInfo/BatteryIntegration.Batteries.cs index 21722b8..835fa82 100644 --- a/src/DeviceBatteryInfo/BatteryIntegration.Batteries.cs +++ b/src/DeviceBatteryInfo/BatteryIntegration.Batteries.cs @@ -1,9 +1,16 @@ +using DeviceBatteryInfo.HostBatteries; using MacroDeck.Sdk.Batteries; namespace DeviceBatteryInfo; public sealed partial class BatteryIntegration : IBatteryProvider { + private IBatteryApi _batteries = UnsupportedBatteryApi.Instance; + + // Re-raised from the catalog's own event, which the host drops on every re-initialization while + // widget sessions stay open across it. + private event EventHandler? BatteryCatalogChanged; + // The plugin's own name, a proper noun like the manifest's. public string ProviderName => "Device Battery Info"; @@ -25,4 +32,23 @@ Task IBatteryProvider.ShutdownAsync(CancellationToken cancellationToken) private void OnPolled(object? sender, EventArgs e) => _ = _hostBatteries.SyncAsync(CancellationToken.None); + + private void AttachBatteryCatalog(IBatteryApi batteries) + { + _batteries.Changed -= OnBatteryCatalogChanged; + _batteries = batteries; + _batteries.Changed += OnBatteryCatalogChanged; + } + + private void DetachBatteryCatalog() + { + _batteries.Changed -= OnBatteryCatalogChanged; + _batteries = UnsupportedBatteryApi.Instance; + } + + private void OnBatteryCatalogChanged(object? sender, BatteryDevicesChangedEventArgs e) => + BatteryCatalogChanged?.Invoke(this, EventArgs.Empty); + + private IReadOnlyList ExternalDevices() => + ExternalBatteryDevices.List(_batteries, _catalog.Devices); } diff --git a/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs b/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs index cc38a9a..d9daacb 100644 --- a/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs +++ b/src/DeviceBatteryInfo/BatteryIntegration.Widgets.cs @@ -1,9 +1,12 @@ using System.Text.Json; using DeviceBatteryInfo.Core; +using DeviceBatteryInfo.HostBatteries; using DeviceBatteryInfo.Ui; +using MacroDeck.Sdk.Batteries; using MacroDeck.Sdk.Ui; using MacroDeck.Sdk.Widgets; using MacroDeck.Ui.Config; +using MacroDeck.Ui.Config.Options; using MacroDeck.Ui.Model.Surfaces; using MacroDeck.Ui.Runtime; @@ -120,6 +123,7 @@ private static string Preview(JsonElement value) void OnCatalogChanged(object? sender, EventArgs e) => Refresh(); _registry.Changed += OnRegistryChanged; _catalog.Changed += OnCatalogChanged; + BatteryCatalogChanged += OnCatalogChanged; _polling.RequestRefresh(); @@ -129,6 +133,7 @@ private static string Preview(JsonElement value) { _registry.Changed -= OnRegistryChanged; _catalog.Changed -= OnCatalogChanged; + BatteryCatalogChanged -= OnCatalogChanged; } ); } @@ -153,60 +158,97 @@ private static string Preview(JsonElement value) var options = ParseOptions(data); var view = new UiView( surface, - BatteryWidgetConfigView.Build(localId, options, CurrentSlots(), data ?? default) + BatteryWidgetConfigView.Build(localId, options, DeviceChoices(), data ?? default) ); return new UiViewSession(view); } - private BatteryWidgetModel BuildModel(BatteryWidgetOptions options) + internal BatteryWidgetModel BuildModel(BatteryWidgetOptions options) { - var rows = ResolveDevices(options.SourceIds) - .Select(slot => - { - if (_registry.TryGet(slot.Id, out var snapshot)) - { - var reading = snapshot.Reading; - return new BatteryWidgetRow( - slot.Id, - slot.DisplayName, - reading.Percent, - reading.Status, - reading.IsCharging, - snapshot.IsStale, - FormatTimeToFull(reading.TimeToFull), - BatteryTrendFormatter.FormatText(_trend.GetTrend(snapshot)), - slot.Kind - ); - } - - return new BatteryWidgetRow( - slot.Id, - slot.DisplayName, - null, - BatteryStatus.Unknown, - false, - true, - null, - Kind: slot.Kind - ); - }) - .ToArray(); + var own = CurrentSlots(); + IReadOnlyList rows; + if (options.SourceIds.Count == 0) + { + rows = own.Select(OwnRow).ToArray(); + } + else + { + var ownById = own.ToDictionary(slot => slot.Id, StringComparer.Ordinal); + var external = ExternalDevices().ToDictionary(d => d.Id, StringComparer.Ordinal); + rows = options + .SourceIds.Select(id => + ownById.TryGetValue(id, out var slot) ? OwnRow(slot) + : external.TryGetValue(id, out var device) ? ExternalRow(device) + : null + ) + .OfType() + .ToArray(); + } return new BatteryWidgetModel(options.Order(rows), options); } - private IReadOnlyList ResolveDevices(IReadOnlyList sourceIds) + private BatteryWidgetRow OwnRow(BatterySlot slot) { - var all = CurrentSlots(); - if (sourceIds.Count == 0) + if (_registry.TryGet(slot.Id, out var snapshot)) { - return all; + var reading = snapshot.Reading; + return new BatteryWidgetRow( + slot.Id, + slot.DisplayName, + reading.Percent, + reading.Status, + reading.IsCharging, + snapshot.IsStale, + FormatTimeToFull(reading.TimeToFull), + BatteryTrendFormatter.FormatText(_trend.GetTrend(snapshot)), + slot.Kind + ); } - var byId = all.ToDictionary(slot => slot.Id, StringComparer.Ordinal); - return sourceIds.Where(byId.ContainsKey).Select(id => byId[id]).ToArray(); + return new BatteryWidgetRow( + slot.Id, + slot.DisplayName, + null, + BatteryStatus.Unknown, + false, + true, + null, + Kind: slot.Kind + ); } + private static BatteryWidgetRow ExternalRow(BatteryDeviceSnapshot device) + { + var status = ExternalBatteryDevices.StatusOf(device.ChargeState, device.LevelPercent); + return new BatteryWidgetRow( + ExternalBatteryDevices.RowKey(device.Id), + device.Name, + device.LevelPercent, + status, + status is BatteryStatus.Charging, + !device.IsConnected, + FormatTimeToFull(device.TimeToFull), + Kind: ExternalBatteryDevices.KindOf(device.Kind) + ); + } + + internal UiOption[] DeviceChoices() => + [ + .. CurrentSlots() + .Select(slot => UiOption.Of(slot.Id) with { Label = slot.DisplayName }), + .. ExternalDevices() + .Select(device => + UiOption.Of(device.Id) with + { + Label = Strings.Widgets.Config.Devices.External( + device.Name, + device.ProviderName + ), + } + ), + ]; + private string? LocalWidgetId(string widgetTypeAttribute) { foreach (var (localId, qualifiedId) in _widgetTypeIds) diff --git a/src/DeviceBatteryInfo/BatteryIntegration.cs b/src/DeviceBatteryInfo/BatteryIntegration.cs index 8a666ff..9d6deee 100644 --- a/src/DeviceBatteryInfo/BatteryIntegration.cs +++ b/src/DeviceBatteryInfo/BatteryIntegration.cs @@ -56,6 +56,7 @@ public IConfigFlow CreateConfigFlow() => public async Task InitializeAsync(IIntegrationContext context) { _context = context; + AttachBatteryCatalog(context.Batteries); _registry.Changed -= OnSnapshotChanged; _registry.Changed += OnSnapshotChanged; @@ -90,6 +91,7 @@ public Task ShutdownAsync() { _registry.Changed -= OnSnapshotChanged; _catalog.Changed -= OnDeviceCatalogChanged; + DetachBatteryCatalog(); _context = null; _sink = null; return Task.CompletedTask; diff --git a/src/DeviceBatteryInfo/HostBatteries/ExternalBatteryDevices.cs b/src/DeviceBatteryInfo/HostBatteries/ExternalBatteryDevices.cs new file mode 100644 index 0000000..948ff36 --- /dev/null +++ b/src/DeviceBatteryInfo/HostBatteries/ExternalBatteryDevices.cs @@ -0,0 +1,59 @@ +using System.Security.Cryptography; +using System.Text; +using DeviceBatteryInfo.Core; +using MacroDeck.Sdk.Batteries; + +namespace DeviceBatteryInfo.HostBatteries; + +// Devices other providers published to Macro Deck's battery catalog. A widget stores one by its +// qualified catalog id, which contains a colon and so never collides with a slot id. +internal static class ExternalBatteryDevices +{ + public static IReadOnlyList List( + IBatteryApi batteries, + IReadOnlyList ownSlots + ) + { + // Our own devices come back through the catalog too; they are matched by the id we sent. + var own = ownSlots + .Where(s => s.EntryId is not null) + .Select(s => HostBatteryDevices.IdOf(s.EntryId!.Value)) + .ToHashSet(StringComparer.Ordinal); + + return batteries + .Devices.Where(d => !own.Contains(d.LocalId)) + .OrderBy(d => d.ProviderName, StringComparer.OrdinalIgnoreCase) + .ThenBy(d => d.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + // A catalog id may hold characters a UiElement key rejects (a colon at least), so a widget row + // is keyed by a hash of it instead. + public static string RowKey(string catalogId) => + "ext-" + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(catalogId)))[..16]; + + public static BatterySourceKind KindOf(string kind) => + BatteryDeviceKinds.Normalize(kind) switch + { + BatteryDeviceKinds.Phone => BatterySourceKind.Phone, + BatteryDeviceKinds.Tablet => BatterySourceKind.Tablet, + BatteryDeviceKinds.Laptop => BatterySourceKind.System, + BatteryDeviceKinds.Headphones => BatterySourceKind.Headset, + BatteryDeviceKinds.Controller => BatterySourceKind.Controller, + BatteryDeviceKinds.Mouse => BatterySourceKind.Mouse, + BatteryDeviceKinds.Keyboard => BatterySourceKind.Keyboard, + BatteryDeviceKinds.Speaker => BatterySourceKind.Speaker, + _ => BatterySourceKind.Other, + }; + + // NotCharging (a charge limit) reads like the macOS and Linux sources map it: Full at 100. + public static BatteryStatus StatusOf(BatteryChargeState state, int? level) => + state switch + { + BatteryChargeState.Discharging => BatteryStatus.Discharging, + BatteryChargeState.Charging => BatteryStatus.Charging, + BatteryChargeState.Full => BatteryStatus.Full, + BatteryChargeState.NotCharging when level >= 100 => BatteryStatus.Full, + _ => BatteryStatus.Unknown, + }; +} diff --git a/src/DeviceBatteryInfo/Localization/Strings.resx b/src/DeviceBatteryInfo/Localization/Strings.resx index 7674d7c..93314ab 100644 --- a/src/DeviceBatteryInfo/Localization/Strings.resx +++ b/src/DeviceBatteryInfo/Localization/Strings.resx @@ -134,6 +134,10 @@ Devices + + {device} - {provider} + A device another plugin reports to Macro Deck, followed by that plugin's name. + Heading diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs index ff4fdf6..e4b946c 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetConfigView.cs @@ -14,7 +14,7 @@ internal static class BatteryWidgetConfigView public static UiElement Build( string widgetLocalId, BatteryWidgetOptions current, - IReadOnlyList devices, + IReadOnlyList devices, JsonElement data = default ) { @@ -80,9 +80,7 @@ UiValue OnlyFor(string layoutValue) => { Key = "sourceIds", Label = Strings.Widgets.Config.Devices.Label(), - Options = UiValue.Of>( - devices.Select(d => UiOption.Of(d.Id) with { Label = d.DisplayName }).ToArray() - ), + Options = UiValue.Of(devices), Binding = Bind.To(sourceIds), Reorderable = UiValue.Of(true), } diff --git a/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs b/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs index 7126c52..47524e2 100644 --- a/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs +++ b/src/DeviceBatteryInfo/Ui/BatteryWidgetTypes.cs @@ -71,7 +71,7 @@ public static JsonElement StoredFlows(JsonElement? data) => "sourceIds": { "type": "array", "items": { "type": "string" }, - "description": "Battery source ids to show, in display order. Empty means every device the plugin sees." + "description": "Devices to show, in display order: this plugin's device ids or Macro Deck battery catalog ids (provider:id) of other plugins' devices. Empty means every device this plugin reads." }, "showBar": { "type": "boolean", "default": true }, "showPercent": { "type": "boolean", "default": true }, diff --git a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs index 9268dc6..8454f2a 100644 --- a/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs +++ b/tests/DeviceBatteryInfo.Tests/BatteryWidgetViewTests.cs @@ -2,6 +2,7 @@ using DeviceBatteryInfo.Actions; using DeviceBatteryInfo.Core; using DeviceBatteryInfo.Ui; +using MacroDeck.Ui.Config.Options; using MacroDeck.Ui.Model.Events; using MacroDeck.Ui.Model.Surfaces; using MacroDeck.Ui.Previews; @@ -113,10 +114,7 @@ public void Empty_model_still_builds() [TestCase(BatteryWidgetTypes.TileId)] public void Config_view_builds(string widgetId) { - var devices = new[] - { - new BatterySlot("system", "This PC", BatterySourceKind.System, DeviceType.System), - }; + UiOption[] devices = [UiOption.Of("system") with { Label = "This PC" }]; Assert.DoesNotThrow( () => diff --git a/tests/DeviceBatteryInfo.Tests/ExternalBatteryDevicesTests.cs b/tests/DeviceBatteryInfo.Tests/ExternalBatteryDevicesTests.cs new file mode 100644 index 0000000..95b629c --- /dev/null +++ b/tests/DeviceBatteryInfo.Tests/ExternalBatteryDevicesTests.cs @@ -0,0 +1,223 @@ +using System.Text.Json; +using DeviceBatteryInfo.Core; +using DeviceBatteryInfo.HostBatteries; +using DeviceBatteryInfo.Sources; +using DeviceBatteryInfo.Ui; +using MacroDeck.Plugin.Testing; +using MacroDeck.Sdk.Batteries; +using MacroDeck.Sdk.Ui; +using MacroDeck.Ui.Model.Surfaces; +using MacroDeck.Ui.Model.Versioning; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace DeviceBatteryInfo.Tests; + +[TestFixture] +public sealed class ExternalBatteryDevicesTests +{ + private const string PhoneId = "com.example.phones:pixel"; + private static readonly Guid OwnEntry = Guid.Parse("5d3c8f2a-1b7e-4c90-a6d4-93e0f1b2c7a8"); + + private static PluginTestHarness CreateHarness() => + PluginTestHarness.Create(builder => + { + builder + .UseLocalization(Strings.LocalizationCatalog) + .RegisterIntegration(); + + builder.Services.AddOptions(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddBatterySources(); + builder.Services.AddSingleton(); + }); + + private static BatteryDeviceSnapshot Snapshot( + string providerId, + string localId, + string name, + string kind = BatteryDeviceKinds.Phone, + int? level = 55, + BatteryChargeState state = BatteryChargeState.Charging, + bool connected = true + ) => + new() + { + Id = $"{providerId}:{localId}", + LocalId = localId, + ProviderId = providerId, + ProviderName = providerId == "com.example.phones" ? "Phones" : "Device Battery Info", + Name = name, + Kind = kind, + LevelPercent = level, + ChargeState = state, + IsConnected = connected, + UpdatedAt = DateTimeOffset.UnixEpoch, + }; + + private static async Task InitializedWithCatalogAsync() + { + var harness = CreateHarness(); + await harness.InitializeIntegrationsAsync(); + harness + .Services.GetRequiredService() + .Set( + [ + new BatterySlot( + "mouse", + "Mouse", + BatterySourceKind.Mouse, + DeviceType.Catalog, + EntryId: OwnEntry + ), + ] + ); + harness.Context.Batteries.Set( + [ + Snapshot("com.example.phones", "pixel", "Pixel 9"), + Snapshot( + "com.pyflat.device-battery-info", + HostBatteryDevices.IdOf(OwnEntry), + "Mouse", + BatteryDeviceKinds.Mouse + ), + ] + ); + return harness; + } + + private static BatteryIntegration Integration(PluginTestHarness harness) => + harness.Services.GetRequiredService(); + + [Test] + public async Task The_picker_lists_other_providers_devices_with_their_provider_but_not_our_own() + { + await using var harness = await InitializedWithCatalogAsync(); + + var choices = Integration(harness).DeviceChoices(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(choices.Select(c => c.Value), Is.EqualTo(new[] { "mouse", PhoneId })); + Assert.That( + choices[1].Label.ToString(), + Does.Contain("Widgets.Config.Devices.External") + ); + } + } + + [Test] + public async Task A_picked_external_device_becomes_a_widget_row() + { + await using var harness = await InitializedWithCatalogAsync(); + + var model = Integration(harness) + .BuildModel(BatteryWidgetOptions.Default with { SourceIds = ["mouse", PhoneId] }); + + var phone = model.Rows[1]; + using (Assert.EnterMultipleScope()) + { + Assert.That(model.Rows, Has.Count.EqualTo(2)); + Assert.That(phone.Name, Is.EqualTo("Pixel 9")); + Assert.That(phone.Percent, Is.EqualTo(55)); + Assert.That(phone.Charging, Is.True); + Assert.That(phone.Stale, Is.False); + Assert.That(phone.Kind, Is.EqualTo(BatterySourceKind.Phone)); + } + } + + [Test] + public async Task An_empty_selection_still_shows_only_this_plugins_devices() + { + await using var harness = await InitializedWithCatalogAsync(); + + var model = Integration(harness).BuildModel(BatteryWidgetOptions.Default); + + Assert.That(model.Rows.Select(r => r.Name), Is.EqualTo(new[] { "Mouse" })); + } + + [Test] + public async Task A_withdrawn_external_device_leaves_the_widget() + { + await using var harness = await InitializedWithCatalogAsync(); + harness.Context.Batteries.Set([]); + + var model = Integration(harness) + .BuildModel(BatteryWidgetOptions.Default with { SourceIds = [PhoneId] }); + + Assert.That(model.Rows, Is.Empty); + } + + [Test] + public async Task A_widget_showing_an_external_device_opens_and_follows_the_catalog() + { + await using var harness = await InitializedWithCatalogAsync(); + var data = JsonSerializer.SerializeToElement(new { sourceIds = new[] { PhoneId } }); + var session = await Integration(harness) + .CreateSessionAsync( + new UiSessionRequest + { + UiModelVersion = UiModelVersions.Current, + Surface = new UiSurface + { + Kind = UiSurfaceKinds.Widget, + SessionMode = UiSessionModes.Shared, + Attributes = new Dictionary + { + [UiWidgetSurfaceAttributes.WidgetType] = + JsonSerializer.SerializeToElement(BatteryWidgetTypes.PanelId), + [UiWidgetSurfaceAttributes.Data] = data, + }, + }, + }, + CancellationToken.None + ); + Assert.That(session, Is.Not.Null); + await using var _ = session!; + Assert.DoesNotThrow(() => session!.BuildTree()); + + var changed = false; + session!.Changed += (_, _) => changed = true; + harness.Context.Batteries.Set( + [Snapshot("com.example.phones", "pixel", "Pixel 9", level: 20)] + ); + + Assert.That(changed, Is.True); + } + + [Test] + public void Row_keys_are_valid_ui_keys_and_stable() + { + var key = ExternalBatteryDevices.RowKey(PhoneId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(key, Does.Match("^[A-Za-z0-9][A-Za-z0-9._-]*$")); + Assert.That(ExternalBatteryDevices.RowKey(PhoneId), Is.EqualTo(key)); + Assert.That( + ExternalBatteryDevices.RowKey("com.example.phones:other"), + Is.Not.EqualTo(key) + ); + } + } + + [TestCase(BatteryDeviceKinds.Headphones, BatterySourceKind.Headset)] + [TestCase(BatteryDeviceKinds.Laptop, BatterySourceKind.System)] + [TestCase(BatteryDeviceKinds.Watch, BatterySourceKind.Other)] + [TestCase("a-kind-from-a-newer-host", BatterySourceKind.Other)] + public void Kinds_map_back_to_our_icons(string kind, BatterySourceKind expected) => + Assert.That(ExternalBatteryDevices.KindOf(kind), Is.EqualTo(expected)); + + [TestCase(BatteryChargeState.NotCharging, 100, BatteryStatus.Full)] + [TestCase(BatteryChargeState.NotCharging, 80, BatteryStatus.Unknown)] + [TestCase(BatteryChargeState.Charging, 40, BatteryStatus.Charging)] + public void Charge_states_map_like_our_own_sources( + BatteryChargeState state, + int level, + BatteryStatus expected + ) => Assert.That(ExternalBatteryDevices.StatusOf(state, level), Is.EqualTo(expected)); +}