Files
2026-11-12 15:45:52 +08:00

171 lines
8.9 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 string? HumidifierTurnOnResult { 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" when domain == "humidifier" && HumidifierTurnOnResult is not null => HumidifierTurnOnResult,
"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; }
}
}