169 lines
8.7 KiB
C#
169 lines
8.7 KiB
C#
using Evergreen.Health.Application.Health.Devices.Contracts;
|
|
using Evergreen.Health.Application.Health.Devices;
|
|
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.Scenes;
|
|
using System.Collections.Concurrent;
|
|
using System.Text.Json;
|
|
|
|
namespace Admin.NET.Test.Health;
|
|
|
|
internal sealed class FakeHaGateway : IHomeAssistantGateway
|
|
{
|
|
public ConcurrentDictionary<string, HomeAssistantState> States { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
public ConcurrentQueue<DeviceCommand> Calls { get; } = new();
|
|
public string? FailEntity { get; set; }
|
|
public int ReadsToFail { get; set; }
|
|
public string? CoverCloseResult { get; set; }
|
|
public TimeProvider Time { get; set; } = TimeProvider.System;
|
|
|
|
public Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (ReadsToFail > 0) { ReadsToFail--; throw new IOException("模拟状态读取失败"); }
|
|
return Task.FromResult<IReadOnlyList<HomeAssistantState>>(States.Values.ToArray());
|
|
}
|
|
|
|
public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> data,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var entity = (string)data["entity_id"]!;
|
|
Calls.Enqueue(new(service, entity, domain, service, new Dictionary<string, object?>(data)));
|
|
if (entity == FailEntity) throw new IOException("模拟设备命令失败");
|
|
var state = service switch
|
|
{
|
|
"turn_on" => "on", "turn_off" => "off",
|
|
"close_cover" when CoverCloseResult is not null => CoverCloseResult,
|
|
"select_option" => data["option"]!.ToString()!,
|
|
"set_value" => data["value"]!.ToString()!, _ => States.GetValueOrDefault(entity)?.State ?? "unknown"
|
|
};
|
|
if (service == "set_hvac_mode") state = (string)data["hvac_mode"]!;
|
|
var attributes = new Dictionary<string, object?>();
|
|
if (States.TryGetValue(entity, out var previous) && previous.Attributes.ValueKind == JsonValueKind.Object)
|
|
foreach (var property in previous.Attributes.EnumerateObject()) attributes[property.Name] = property.Value.Clone();
|
|
if (data.TryGetValue("brightness_pct", out var brightness))
|
|
attributes["brightness"] = Convert.ToDouble(brightness) / 100 * 255;
|
|
foreach (var key in new[] { "effect", "color_temp_kelvin", "temperature", "fan_mode" })
|
|
if (data.TryGetValue(key, out var value)) attributes[key] = value;
|
|
States[entity] = new() { EntityId = entity, State = state, LastReported = Time.GetUtcNow(), LastUpdated = Time.GetUtcNow(),
|
|
Attributes = JsonSerializer.SerializeToElement(attributes) };
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
internal sealed class FakeMusic : ITcmMusicRuntime
|
|
{
|
|
public int StopCount { get; private set; }
|
|
public TcmMusicTransitionOutput GetStatus() => new();
|
|
public bool HasPlayableTracks(string code) => true;
|
|
public Task StartForConstitutionAsync(string code, CancellationToken ct) => Task.CompletedTask;
|
|
public Task PlayTestMusicAsync(CancellationToken ct) => Task.CompletedTask;
|
|
public Task SetManualVolumeAsync(int volume, CancellationToken ct) => Task.CompletedTask;
|
|
public Task StopMusicAsync(CancellationToken ct) { StopCount++; return Task.CompletedTask; }
|
|
}
|
|
|
|
internal sealed class FakeLight : ILightTransitionRuntime
|
|
{
|
|
public HomeAssistantLightTransitionOutput GetStatus() => new();
|
|
public Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int kelvin, CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
|
|
public Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
|
|
public Task<HomeAssistantLightTransitionOutput> CancelAsync(CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
|
|
}
|
|
|
|
internal sealed class SyntheticDeviceModule : HealthDeviceModule
|
|
{
|
|
private readonly bool _block;
|
|
private readonly bool _twoOff;
|
|
private readonly string _key;
|
|
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
public bool ContinuedAfterWait { get; private set; }
|
|
public SyntheticDeviceModule(DeviceActionRunner runner, bool block = false, bool twoOff = false, string key = "synthetic")
|
|
: base(runner) => (_block, _twoOff, _key) = (block, twoOff, key);
|
|
public override string Key => _key;
|
|
public override string Category => nameof(HealthDeviceCategory.Other);
|
|
public override string DisplayName => "测试新增家具";
|
|
public override string PrimaryEntityId => "switch.synthetic";
|
|
public override IReadOnlyList<DeviceParameterDefinition> Parameters =>
|
|
[DeviceValues.Number("synthetic_value", "number.synthetic", 0, 5, "测试参数")];
|
|
public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
|
|
string constitutionCode, CancellationToken ct)
|
|
{
|
|
Started.TrySetResult();
|
|
if (_block) await Task.Delay(Timeout.InfiniteTimeSpan, ct);
|
|
ContinuedAfterWait = true;
|
|
await context.RunAsync(BuildParameterCommand("synthetic_value", 3), ct);
|
|
}
|
|
public override IEnumerable<DeviceCommand> GetShutdownCommands()
|
|
{
|
|
if (_twoOff) yield return new("失败设备关闭", "switch.bad", "switch", "turn_off", ExpectedState: "off");
|
|
yield return new("测试家具关闭", PrimaryEntityId, "switch", "turn_off", ExpectedState: "off");
|
|
}
|
|
}
|
|
|
|
internal sealed class FakeAudioGateway : IAudioPlaybackGateway
|
|
{
|
|
public bool IsConfigured => true;
|
|
public ConcurrentQueue<int> Volumes { get; } = new();
|
|
public ConcurrentQueue<string> Tracks { get; } = new();
|
|
public int Stops;
|
|
public Task PlayAsync(CancellationToken ct = default) => Task.CompletedTask;
|
|
public Task PlayUrlAsync(string url, CancellationToken ct = default) { Tracks.Enqueue(url); return Task.CompletedTask; }
|
|
public Task StopAsync(CancellationToken ct = default) { Interlocked.Increment(ref Stops); return Task.CompletedTask; }
|
|
public Task SetVolumeAsync(int volume, CancellationToken ct = default) { Volumes.Enqueue(volume); return Task.CompletedTask; }
|
|
}
|
|
|
|
/// <summary>只推进测试时间,不等待真实 250 秒,也不依赖额外测试包。</summary>
|
|
internal sealed class ManualTestTimeProvider : TimeProvider
|
|
{
|
|
private readonly object _lock = new();
|
|
private readonly List<ManualTimer> _timers = new();
|
|
private readonly DateTimeOffset _origin = DateTimeOffset.UtcNow;
|
|
private long _ticks;
|
|
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
|
public override long GetTimestamp() { lock (_lock) return _ticks; }
|
|
public override DateTimeOffset GetUtcNow() { lock (_lock) return _origin.AddTicks(_ticks); }
|
|
|
|
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
|
{
|
|
var timer = new ManualTimer(this, callback, state);
|
|
lock (_lock) { _timers.Add(timer); timer.Change(dueTime, period); }
|
|
return timer;
|
|
}
|
|
public void Advance(TimeSpan duration)
|
|
{
|
|
List<ManualTimer> due;
|
|
lock (_lock)
|
|
{
|
|
_ticks += duration.Ticks;
|
|
due = _timers.Where(timer => !timer.Disposed && timer.Due <= _ticks).ToList();
|
|
foreach (var timer in due) timer.Due = timer.Period > 0 ? _ticks + timer.Period : long.MaxValue;
|
|
}
|
|
foreach (var timer in due) timer.Callback(timer.State);
|
|
}
|
|
private sealed class ManualTimer : ITimer
|
|
{
|
|
private readonly ManualTestTimeProvider _owner;
|
|
public TimerCallback Callback { get; }
|
|
public object? State { get; }
|
|
public bool Disposed;
|
|
public long Due;
|
|
public long Period;
|
|
public ManualTimer(ManualTestTimeProvider owner, TimerCallback callback, object? state)
|
|
=> (_owner, Callback, State) = (owner, callback, state);
|
|
public bool Change(TimeSpan dueTime, TimeSpan period)
|
|
{
|
|
lock (_owner._lock)
|
|
{
|
|
if (Disposed) return false;
|
|
Due = dueTime == Timeout.InfiniteTimeSpan ? long.MaxValue : _owner._ticks + dueTime.Ticks;
|
|
Period = period == Timeout.InfiniteTimeSpan ? 0 : period.Ticks;
|
|
return true;
|
|
}
|
|
}
|
|
public void Dispose() { lock (_owner._lock) { Disposed = true; _owner._timers.Remove(this); } }
|
|
public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; }
|
|
}
|
|
}
|