322 lines
17 KiB
C#
322 lines
17 KiB
C#
using Evergreen.Health.Application.Health.Devices;
|
|
using Evergreen.Health.Application.Health.Devices.Climate;
|
|
using Evergreen.Health.Application.Health.Devices.Contracts;
|
|
using Evergreen.Health.Application.Health.Devices.Discovery;
|
|
using Evergreen.Health.Application.Health.Devices.Environment;
|
|
using Evergreen.Health.Application.Health.Devices.Lighting;
|
|
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
|
using Evergreen.Health.Application.Health.Integrations.MiAirX;
|
|
using Evergreen.Health.Application.Health.Models;
|
|
using Evergreen.Health.Application.Health.Policies;
|
|
using Evergreen.Health.Application.Health.Scenes;
|
|
using Evergreen.Health.Application.Health.Services;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using System.Text.Json;
|
|
using Xunit;
|
|
|
|
namespace Admin.NET.Test.Health;
|
|
|
|
/// <summary>两盏灯和空调的真实模块组合,全部使用内存网关与可推进时钟,绝不控制实机。</summary>
|
|
public sealed class SceneLightingIntegrationTests
|
|
{
|
|
private const string Primary = "light.bedroom", Strip = "light.strip", Climate = "climate.room", Temperature = "sensor.indoor";
|
|
private static SceneLightOptions StripSettings() => new() { Key = "sleep-light-strip", Name = "测试助眠灯带", EntityId = Strip, SleepEffect = "助眠模式" };
|
|
private static HomeAssistantOptions Settings() => new()
|
|
{
|
|
Entities = new() { AmbientLight = Primary, Climate = Climate, Temperature = Temperature },
|
|
AdditionalSleepLights = [StripSettings()]
|
|
};
|
|
private static HomeAssistantState Light(string id, string state = "off", string[]? effects = null) => new()
|
|
{
|
|
EntityId = id, State = state, Attributes = JsonSerializer.SerializeToElement(new
|
|
{
|
|
supported_color_modes = new[] { "color_temp" }, min_color_temp_kelvin = 2700, max_color_temp_kelvin = 6500,
|
|
supported_features = 4, effect_list = effects ?? ["阅读模式", "助眠模式"], brightness = 128, color_temp_kelvin = 3000
|
|
})
|
|
};
|
|
private static HomeAssistantState ClimateState(string mode = "off") => new()
|
|
{
|
|
EntityId = Climate, State = mode, Attributes = JsonSerializer.SerializeToElement(new
|
|
{
|
|
hvac_modes = new[] { "heat", "cool", "off", "fan_only" }, supported_features = 393,
|
|
min_temp = 5, max_temp = 35, target_temp_step = .5, temperature_unit = "°C", temperature = 23,
|
|
current_temperature = 99 // 故意与环境仪不同,不能误用空调内置温度进行舒睡判断。
|
|
})
|
|
};
|
|
private sealed class Handler : ILightOffHandler
|
|
{
|
|
public int Calls;
|
|
public Task HandleAsync(CancellationToken token) { Calls++; return Task.CompletedTask; }
|
|
}
|
|
private sealed class Fixture
|
|
{
|
|
public ManualTestTimeProvider Time { get; } = new();
|
|
public FakeHaGateway Gateway { get; }
|
|
public IOptions<HomeAssistantOptions> Options { get; } = Microsoft.Extensions.Options.Options.Create(Settings());
|
|
public Handler Off { get; } = new();
|
|
public HomeAssistantLightTransitionService Lights { get; }
|
|
public DeviceActionRunner Runner { get; }
|
|
public DeviceModuleRegistry Modules { get; }
|
|
public HealthSceneCoordinator Coordinator { get; }
|
|
public Fixture()
|
|
{
|
|
Gateway = new() { Time = Time };
|
|
Gateway.States[Primary] = Light(Primary); Gateway.States[Strip] = Light(Strip);
|
|
Gateway.States[Climate] = ClimateState();
|
|
foreach (var id in new[] { "number.left_heat", "number.right_heat" }) Gateway.States[id] = new() { EntityId = id, State = "0" };
|
|
Runner = new(Gateway, Time, new() { VerificationDelay = TimeSpan.Zero });
|
|
var dispatcher = new LightOffDispatcher([Off]);
|
|
Lights = new(Gateway, dispatcher, Options, NullLogger<HomeAssistantLightTransitionService>.Instance, new(), Time);
|
|
var modules = new List<IHealthDeviceModule>
|
|
{
|
|
new LightingModule(Runner, Options, Lights.ForEntity(Primary), dispatcher),
|
|
new ConfiguredSleepLightModule(Runner, StripSettings(), Lights.ForEntity(Strip)),
|
|
new ClimateModule(Runner, Options)
|
|
};
|
|
Modules = new(modules);
|
|
Coordinator = new(Modules, Runner, Gateway, Lights, new FakeMusic());
|
|
}
|
|
public PreparedSleepScene Prepare(double? temperature)
|
|
{
|
|
if (temperature is not null) Gateway.States[Temperature] = new()
|
|
{
|
|
EntityId = Temperature, State = temperature.Value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
LastReported = Time.GetUtcNow(), Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
|
|
};
|
|
else Gateway.States.TryRemove(Temperature, out _);
|
|
var comfort = new EnvironmentAdviceService(Gateway, new IndoorEnvironmentReader(Options, Time), Options)
|
|
.Get(null, Gateway.States);
|
|
var config = new PersonalizedSceneConfig { LightColorTemperatureKelvin = 3000 };
|
|
EnvironmentAdviceService.ApplyTo(config, comfort);
|
|
RoomTemperaturePolicy.PickTargetForSession(config.ClimateAdvice);
|
|
return new(config, "balanced");
|
|
}
|
|
}
|
|
private static async Task WaitUntilAsync(Func<bool> predicate)
|
|
{
|
|
for (var attempt = 0; attempt < 1000 && !predicate(); attempt++) await Task.Delay(1);
|
|
Assert.True(predicate(), "模拟渐变任务未按时推进");
|
|
}
|
|
private static async Task<T> CompleteAsync<T>(Task<T> task, ManualTestTimeProvider time)
|
|
{
|
|
for (var attempt = 0; attempt < 1000 && !task.IsCompleted; attempt++)
|
|
{
|
|
time.Advance(TimeSpan.FromMilliseconds(100)); await Task.Delay(1);
|
|
}
|
|
Assert.True(task.IsCompleted, "场景未在模拟时间中完成");
|
|
return await task;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SceneStartsBothLightsAtFiftyAndSetsOnlyStripsOwnSleepEffect()
|
|
{
|
|
var f = new Fixture();
|
|
try
|
|
{
|
|
var result = await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default);
|
|
Assert.True(result.Success);
|
|
Assert.Equal(new[] { Primary, Strip }, f.Gateway.Calls.Where(call => call.Domain == "light").Select(call => call.EntityId));
|
|
var strip = f.Gateway.Calls.Single(call => call.EntityId == Strip);
|
|
Assert.Equal("助眠模式", strip.Data!["effect"]); Assert.Equal(50, strip.Data["brightness_pct"]);
|
|
Assert.Equal(3000, strip.Data["color_temp_kelvin"]);
|
|
Assert.False(f.Gateway.Calls.Single(call => call.EntityId == Primary).Data!.ContainsKey("effect"));
|
|
f.Time.Advance(TimeSpan.FromSeconds(5));
|
|
await WaitUntilAsync(() => f.Lights.GetStatus().Lights.All(light => light.BrightnessPercent == 49));
|
|
Assert.Equal(2, f.Lights.GetStatus().Lights.Count);
|
|
Assert.All(f.Lights.GetStatus().Lights, light => Assert.True(light.Running));
|
|
}
|
|
finally { await f.Lights.StopAsync(default); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BothLightsReachZeroAndOnlyOriginalLightKeepsLightOffEvent()
|
|
{
|
|
var f = new Fixture();
|
|
try
|
|
{
|
|
await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default);
|
|
for (var step = 1; step <= 50; step++)
|
|
{
|
|
f.Time.Advance(TimeSpan.FromSeconds(5));
|
|
var expected = 50 - step;
|
|
await WaitUntilAsync(() => f.Lights.GetStatus().Lights.All(light => light.BrightnessPercent == expected &&
|
|
(expected != 0 || !light.Running)));
|
|
}
|
|
Assert.Equal("off", f.Gateway.States[Primary].State); Assert.Equal("off", f.Gateway.States[Strip].State);
|
|
Assert.Equal(1, f.Off.Calls);
|
|
}
|
|
finally { await f.Lights.StopAsync(default); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AllOffCancelsBothTimersAndShutsClimateWithoutLateReopening()
|
|
{
|
|
var f = new Fixture();
|
|
try
|
|
{
|
|
await f.Coordinator.ExecuteAsync("sleep", f.Prepare(30).Config, "balanced", default);
|
|
var result = await f.Coordinator.ExecuteAsync("all-off", null, "", default);
|
|
Assert.True(result.Success);
|
|
Assert.All(new[] { Primary, Strip, Climate }, id => Assert.Equal("off", f.Gateway.States[id].State));
|
|
Assert.All(f.Lights.GetStatus().Lights, light => Assert.False(light.Running));
|
|
var count = f.Gateway.Calls.Count; f.Time.Advance(TimeSpan.FromMinutes(5)); await Task.Delay(5);
|
|
Assert.Equal(count, f.Gateway.Calls.Count);
|
|
}
|
|
finally { await f.Lights.StopAsync(default); }
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(15, "heat")]
|
|
[InlineData(30, "cool")]
|
|
[InlineData(22, "off")]
|
|
[InlineData(24, "off")]
|
|
[InlineData(25, "off")]
|
|
public async Task EnvironmentSensorDrivesActualSceneClimateWithoutRetiredHeating(double room, string mode)
|
|
{
|
|
var f = new Fixture();
|
|
try
|
|
{
|
|
var prepared = f.Prepare(room);
|
|
var result = await CompleteAsync(f.Coordinator.ExecutePreparedSleepAsync(_ => Task.FromResult(prepared), default), f.Time);
|
|
Assert.True(result.Success); Assert.Equal(mode, result.ClimateAdvice!.Mode);
|
|
Assert.False(result.ClimateAdvice.PendingIntegration);
|
|
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId is "number.left_heat" or "number.right_heat");
|
|
if (mode == "off")
|
|
{
|
|
Assert.Null(result.ClimateAdvice.TargetTemperatureCelsius);
|
|
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Climate);
|
|
}
|
|
else
|
|
{
|
|
Assert.Equal(mode, f.Gateway.States[Climate].State);
|
|
Assert.InRange(result.ClimateAdvice.TargetTemperatureCelsius!.Value, 22, 25);
|
|
Assert.Equal(result.ClimateAdvice.TargetTemperatureCelsius.Value, f.Gateway.States[Climate].GetDoubleAttribute("temperature"));
|
|
Assert.Equal(new[] { "set_hvac_mode", "set_temperature" }, f.Gateway.Calls.Where(call => call.EntityId == Climate).Select(call => call.Service));
|
|
}
|
|
}
|
|
finally { await f.Lights.StopAsync(default); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MissingOrStaleEnvironmentNeverStartsClimateOrHeating()
|
|
{
|
|
var f = new Fixture();
|
|
try
|
|
{
|
|
var prepared = f.Prepare(null);
|
|
var result = await f.Coordinator.ExecuteAsync("sleep", prepared.Config, "balanced", default);
|
|
Assert.Equal("unavailable", result.ClimateAdvice!.Mode);
|
|
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Climate);
|
|
f.Prepare(15); f.Time.Advance(TimeSpan.FromMinutes(31));
|
|
var comfort = new EnvironmentAdviceService(f.Gateway, new IndoorEnvironmentReader(f.Options, f.Time), f.Options).Get(null, f.Gateway.States);
|
|
Assert.Equal("unavailable", comfort.ClimateAdvice.Mode);
|
|
}
|
|
finally { await f.Lights.StopAsync(default); }
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public async Task ManualStandardControlCancelsOnlyThatLightsFade(bool strip)
|
|
{
|
|
var f = new Fixture();
|
|
try
|
|
{
|
|
await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default);
|
|
var id = strip ? Strip : Primary;
|
|
var other = strip ? Primary : Strip;
|
|
var device = new HomeAssistantDiscoveredDeviceOutput
|
|
{
|
|
DeviceId = "manual", Online = true, PrimaryControlEntityId = id,
|
|
Entities = [new() { EntityId = id, Domain = "light", State = "on", Available = true }]
|
|
};
|
|
var parameters = new DeviceParameterController(f.Modules, f.Runner, [new StandardLightControl()]);
|
|
await parameters.ExecuteAsync(device, f.Gateway.States, "light_brightness", 77, id, default);
|
|
Assert.False(f.Lights.ForEntity(id).GetStatus().Running); Assert.True(f.Lights.ForEntity(other).GetStatus().Running);
|
|
f.Time.Advance(TimeSpan.FromSeconds(5));
|
|
await WaitUntilAsync(() => f.Lights.ForEntity(other).GetStatus().BrightnessPercent == 49);
|
|
Assert.Equal(77d / 100 * 255, f.Gateway.States[id].GetDoubleAttribute("brightness"));
|
|
}
|
|
finally { await f.Lights.StopAsync(default); }
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(Primary, Strip)]
|
|
[InlineData(Strip, Primary)]
|
|
public async Task OfflineLightDoesNotBlockOtherSceneLight(string offline, string active)
|
|
{
|
|
var f = new Fixture(); f.Gateway.States[offline] = Light(offline, "unavailable");
|
|
try
|
|
{
|
|
var result = await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default);
|
|
Assert.True(result.Success); Assert.True(f.Lights.ForEntity(active).GetStatus().Running);
|
|
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == offline);
|
|
Assert.Contains(result.Actions, action => action.EntityId == offline && action.Status == "Skipped");
|
|
}
|
|
finally { await f.Lights.StopAsync(default); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnsupportedDefaultEffectFailsBeforeStripTurnsOnAndDoesNotStopOtherLight()
|
|
{
|
|
var f = new Fixture(); f.Gateway.States[Strip] = Light(Strip, effects: ["阅读模式"]);
|
|
try
|
|
{
|
|
var result = await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default);
|
|
Assert.Equal("Partial", result.Status);
|
|
Assert.True(f.Lights.ForEntity(Primary).GetStatus().Running);
|
|
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Strip);
|
|
Assert.Contains(result.Actions, action => action.EntityId == Strip && action.Status == "Failed");
|
|
}
|
|
finally { await f.Lights.StopAsync(default); }
|
|
}
|
|
|
|
[Fact]
|
|
public void ModuleDiscoveryAndPreviewExposeOwnBindingAndDefaultWithoutHardwareWrites()
|
|
{
|
|
var f = new Fixture();
|
|
var catalog = new HomeAssistantDeviceCatalog(f.Options, f.Modules);
|
|
var discovered = catalog.BuildDiscoveredDevices(null, f.Gateway.States);
|
|
var strip = Assert.Single(discovered.Where(device => device.PrimaryControlEntityId == Strip));
|
|
Assert.Equal("sleep-light-strip", strip.ModuleKey); Assert.Equal("助眠模式", strip.DefaultLightEffect);
|
|
Assert.Null(discovered.Single(device => device.PrimaryControlEntityId == Primary).DefaultLightEffect);
|
|
var preview = f.Modules.ForEntities([Strip])!.BuildPreview(new(), "balanced")!;
|
|
Assert.Equal(Strip, preview.EntityId); Assert.Contains("每5秒降低1%", preview.Detail);
|
|
Assert.Empty(f.Gateway.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProductionRegistrationAddsSceneStripWithIndependentManualPower()
|
|
{
|
|
var services = new ServiceCollection(); services.AddOptions(); services.AddLogging(); services.AddHttpClient();
|
|
var settings = Settings();
|
|
settings.AdditionalSleepLights.Single().StartSleepOnManualPowerOn = false;
|
|
var gateway = new FakeHaGateway();
|
|
gateway.States[Strip] = Light(Strip);
|
|
services.AddSingleton<IOptions<HomeAssistantOptions>>(Options.Create(settings));
|
|
services.AddHealthDevices(); services.AddSingleton<IHomeAssistantGateway>(gateway);
|
|
services.AddSingleton<IAudioPlaybackGateway>(new FakeAudioGateway());
|
|
using var provider = services.BuildServiceProvider();
|
|
var registry = provider.GetRequiredService<DeviceModuleRegistry>();
|
|
var strip = Assert.IsType<ConfiguredSleepLightModule>(registry.ForEntities([Strip]));
|
|
Assert.IsType<LightingModule>(registry.ForEntities([Primary]));
|
|
Assert.IsType<ClimateModule>(registry.ForEntities([Climate]));
|
|
await strip.SetPowerAsync(true, default);
|
|
var command = Assert.Single(gateway.Calls);
|
|
Assert.Equal(Strip, command.EntityId);
|
|
Assert.Equal("turn_on", command.Service);
|
|
Assert.Single(command.Data!); // 详情页开灯不套用舒睡模式或亮度。
|
|
Assert.False(provider.GetRequiredService<HomeAssistantLightTransitionService>().ForEntity(Strip).GetStatus().Running);
|
|
}
|
|
|
|
[Fact]
|
|
public void DuplicateSceneLightEntityCannotCreateCompetingTimers()
|
|
{
|
|
var options = Settings(); options.AdditionalSleepLights.Add(new() { Key = "duplicate", Name = "重复灯", EntityId = Strip });
|
|
Assert.Throws<InvalidOperationException>(() => new HomeAssistantLightTransitionService(new FakeHaGateway(), new([]),
|
|
Options.Create(options), NullLogger<HomeAssistantLightTransitionService>.Instance, new(), TimeProvider.System));
|
|
}
|
|
}
|