diff --git a/.github/workflows/tags_nuget.yml b/.github/workflows/tags_nuget.yml
index fc9c8a5ea..fd873dc2a 100644
--- a/.github/workflows/tags_nuget.yml
+++ b/.github/workflows/tags_nuget.yml
@@ -23,5 +23,7 @@ jobs:
run: dotnet pack src/App/NetDaemon.App/NetDaemon.App.csproj --configuration Release -p:PackageVersion=${GIT_TAG_NAME}-beta
- name: pack Daemon
run: dotnet pack src/Daemon/NetDaemon.Daemon/NetDaemon.Daemon.csproj --configuration Release -p:PackageVersion=${GIT_TAG_NAME}-beta
+ - name: pack Test
+ run: dotnet pack tests/NetDaemon.Test/NetDaemon.Test.csproj --configuration Release -p:PackageVersion=${GIT_TAG_NAME}-beta
- name: push to nuget
run: dotnet nuget push **/*.nupkg --api-key ${{secrets.NUGET_API_KEY}} --source https://api.nuget.org/v3/index.json --no-symbols true
diff --git a/NetDaemon.sln b/NetDaemon.sln
index 9a8abf6e7..67937bb07 100644
--- a/NetDaemon.sln
+++ b/NetDaemon.sln
@@ -24,6 +24,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DaemonRunner", "src\DaemonR
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DaemonRunner", "DaemonRunner", "{A7F8279D-A148-48B8-8492-BDD96CE1E676}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{32E20E48-2F62-447B-83A2-DB2EA7F61AB3}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetDaemon.Test", "tests\NetDaemon.Test\NetDaemon.Test.csproj", "{E1844E4F-2711-4A04-B70C-D67D5D2C8F31}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -94,6 +98,18 @@ Global
{E79D336A-0BA1-4662-A840-CAB24B3145B9}.Release|x64.Build.0 = Release|Any CPU
{E79D336A-0BA1-4662-A840-CAB24B3145B9}.Release|x86.ActiveCfg = Release|Any CPU
{E79D336A-0BA1-4662-A840-CAB24B3145B9}.Release|x86.Build.0 = Release|Any CPU
+ {E1844E4F-2711-4A04-B70C-D67D5D2C8F31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E1844E4F-2711-4A04-B70C-D67D5D2C8F31}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E1844E4F-2711-4A04-B70C-D67D5D2C8F31}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E1844E4F-2711-4A04-B70C-D67D5D2C8F31}.Debug|x64.Build.0 = Debug|Any CPU
+ {E1844E4F-2711-4A04-B70C-D67D5D2C8F31}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E1844E4F-2711-4A04-B70C-D67D5D2C8F31}.Debug|x86.Build.0 = Debug|Any CPU
+ {E1844E4F-2711-4A04-B70C-D67D5D2C8F31}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E1844E4F-2711-4A04-B70C-D67D5D2C8F31}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E1844E4F-2711-4A04-B70C-D67D5D2C8F31}.Release|x64.ActiveCfg = Release|Any CPU
+ {E1844E4F-2711-4A04-B70C-D67D5D2C8F31}.Release|x64.Build.0 = Release|Any CPU
+ {E1844E4F-2711-4A04-B70C-D67D5D2C8F31}.Release|x86.ActiveCfg = Release|Any CPU
+ {E1844E4F-2711-4A04-B70C-D67D5D2C8F31}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -104,6 +120,7 @@ Global
{1DF9E750-9308-4E12-8A48-CAABE68FF30A} = {42718370-2C75-4708-8C40-DD59D633C96E}
{E73BF13E-A17F-4B0B-8504-C2A299B83DEE} = {42718370-2C75-4708-8C40-DD59D633C96E}
{E79D336A-0BA1-4662-A840-CAB24B3145B9} = {A7F8279D-A148-48B8-8492-BDD96CE1E676}
+ {E1844E4F-2711-4A04-B70C-D67D5D2C8F31} = {32E20E48-2F62-447B-83A2-DB2EA7F61AB3}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {7C5FBB7F-654C-4CAC-964F-6D71AF3D62F8}
diff --git a/src/App/NetDaemon.App/Common/Reactive/ObservableExtensionMethods.cs b/src/App/NetDaemon.App/Common/Reactive/ObservableExtensionMethods.cs
index 432573fbb..938726c2b 100644
--- a/src/App/NetDaemon.App/Common/Reactive/ObservableExtensionMethods.cs
+++ b/src/App/NetDaemon.App/Common/Reactive/ObservableExtensionMethods.cs
@@ -18,5 +18,25 @@ public static IObservable<(EntityState Old, EntityState New)> NDSameStateFor(thi
{
return observable.Throttle(span);
}
+
+ ///
+ /// Wait for state the specified time
+ ///
+ ///
+ /// Timeout waiting for state
+ public static IObservable<(EntityState Old, EntityState New)> NDWaitForState(this IObservable<(EntityState Old, EntityState New)> observable, TimeSpan timeout)
+ {
+ return observable.Timeout(timeout, Observable.Return((new NetDaemon.Common.EntityState() { State = "TimeOut" }, new NetDaemon.Common.EntityState() { State = "TimeOut" }))).Take(1);
+ }
+
+ ///
+ /// Wait for state the default time
+ ///
+ ///
+ public static IObservable<(EntityState Old, EntityState New)> NDWaitForState(this IObservable<(EntityState Old, EntityState New)> observable)
+ {
+ return observable.Timeout(TimeSpan.FromSeconds(5), Observable.Return((new NetDaemon.Common.EntityState() { State = "TimeOut" }, new NetDaemon.Common.EntityState() { State = "TimeOut" }))).Take(1);
+ }
+
}
}
\ No newline at end of file
diff --git a/src/Daemon/NetDaemon.Daemon/Daemon/NetDaemonHost.cs b/src/Daemon/NetDaemon.Daemon/Daemon/NetDaemonHost.cs
index fa5927ab2..6f2241b53 100644
--- a/src/Daemon/NetDaemon.Daemon/Daemon/NetDaemonHost.cs
+++ b/src/Daemon/NetDaemon.Daemon/Daemon/NetDaemonHost.cs
@@ -18,6 +18,7 @@
using NetDaemon.Mapping;
[assembly: InternalsVisibleTo("NetDaemon.Daemon.Tests")]
+[assembly: InternalsVisibleTo("NetDaemon.Test")]
namespace NetDaemon.Daemon
{
diff --git a/tests/NetDaemon.Test/DaemonHostTestBase.cs b/tests/NetDaemon.Test/DaemonHostTestBase.cs
new file mode 100644
index 000000000..0ac5a66d5
--- /dev/null
+++ b/tests/NetDaemon.Test/DaemonHostTestBase.cs
@@ -0,0 +1,241 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Dynamic;
+using System.Threading;
+using System.Threading.Tasks;
+using JoySoftware.HomeAssistant.Client;
+using Moq;
+using NetDaemon.Common;
+using NetDaemon.Common.Reactive;
+using NetDaemon.Daemon.Storage;
+using Xunit;
+
+namespace NetDaemon.Daemon.Test
+{
+
+ public partial class DaemonHostTestBase : IAsyncLifetime
+ {
+ private readonly NetDaemonHost _defaultDaemonHost;
+ private readonly Mock _defaultDataRepositoryMock;
+ private readonly HassClientMock _defaultHassClientMock;
+ private readonly HttpHandlerMock _defaultHttpHandlerMock;
+ private readonly LoggerMock _loggerMock;
+
+ internal DaemonHostTestBase()
+ {
+ _loggerMock = new LoggerMock();
+ _defaultHassClientMock = HassClientMock.DefaultMock;
+ _defaultDataRepositoryMock = new Mock();
+
+ _defaultHttpHandlerMock = new HttpHandlerMock();
+ _defaultDaemonHost = new NetDaemonHost(
+ _defaultHassClientMock.Object,
+ _defaultDataRepositoryMock.Object,
+ _loggerMock.LoggerFactory,
+ _defaultHttpHandlerMock.Object);
+
+ }
+
+ public NetDaemonHost DefaultDaemonHost => _defaultDaemonHost;
+ public Mock DefaultDataRepositoryMock => _defaultDataRepositoryMock;
+ public HassClientMock DefaultHassClientMock => _defaultHassClientMock;
+ public HttpHandlerMock DefaultHttpHandlerMock => _defaultHttpHandlerMock;
+ public LoggerMock LoggerMock => _loggerMock;
+
+ Task IAsyncLifetime.DisposeAsync()
+ {
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Gets a object as dynamic
+ ///
+ /// The object to turn into dynamic
+ public dynamic GetDynamicDataObject(string testData = "testdata")
+ {
+ var expandoObject = new ExpandoObject();
+ dynamic dynamicData = expandoObject;
+ dynamicData.Test = testData;
+ return dynamicData;
+ }
+
+ ///
+ /// Converts parameters to dynamics
+ ///
+ public (dynamic, ExpandoObject) GetDynamicObject(params (string, object)[] dynamicParameters)
+ {
+ var expandoObject = new ExpandoObject();
+ var dict = expandoObject as IDictionary;
+
+ foreach (var (name, value) in dynamicParameters)
+ {
+ dict[name] = value;
+ }
+ return (expandoObject, expandoObject);
+ }
+
+ ///
+ /// Override for test init function
+ ///
+ public virtual Task InitializeAsync()
+ {
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Adds an new instance of app
+ ///
+ /// The instance of the app to add
+ public void AddAppInstance(INetDaemonAppBase app)
+ {
+ if (string.IsNullOrEmpty(app.Id))
+ throw new ArgumentException("Application needs an unique id, please provide it!");
+ DefaultDaemonHost.InternalAllAppInstances[app.Id] = app;
+ DefaultDaemonHost.InternalRunningAppInstances[app.Id] = app;
+ }
+
+ ///
+ /// Adds an simple state change event to NetDaemon to trigger apps
+ ///
+ /// Unique id of the entity
+ /// From state
+ /// To state
+ public void AddChangedEvent(string entityId, object fromState, object toState)
+ {
+ DefaultHassClientMock.AddChangedEvent(entityId, fromState, toState);
+ }
+
+ ///
+ /// Adds a full home assistant fake event
+ ///
+ /// Event to fake
+ public void AddChangedEvent(HassEvent hassEvent)
+ {
+ DefaultHassClientMock.FakeEvents.Enqueue(hassEvent);
+ }
+
+ ///
+ /// Add a fake event
+ ///
+ /// The id of the event
+ /// any custom data provided
+ public void AddCustomEvent(string eventType, dynamic? data)
+ {
+ DefaultHassClientMock.FakeEvents.Enqueue(new HassEvent
+ {
+ EventType = eventType,
+ Data = data
+ });
+ }
+
+ ///
+ /// Add a face service call event
+ ///
+ /// Domain of event
+ /// Service to call
+ /// Custom data
+ public void AddCallServiceEvent(string domain, string service, dynamic data)
+ {
+ DefaultHassClientMock.AddCallServiceEvent(domain, service, data);
+ }
+
+ ///
+ /// Verify that a service has been called
+ ///
+ /// Domain of service
+ /// The service name
+ /// Attributes
+ public void VerifyCallService(string domain, string service,
+ params (string attribute, object value)[] attributesTuples)
+ {
+ DefaultHassClientMock.VerifyCallService(domain, service, attributesTuples);
+ }
+
+ ///
+ /// Verify that a service been called specific number of times
+ ///
+ /// Service name
+ /// Times called
+ public void VerifyCallServiceTimes(string service, Times times)
+ {
+ DefaultHassClientMock.VerifyCallServiceTimes(service, times);
+ }
+
+ public async Task<(Task, CancellationTokenSource)> ReturnRunningDefauldDaemonHostTask(short milliSeconds = 100, bool overrideDebugNotCancel = false)
+ {
+ await InitApps();
+ var cancelSource = Debugger.IsAttached && !overrideDebugNotCancel
+ ? new CancellationTokenSource()
+ : new CancellationTokenSource(milliSeconds);
+ return (_defaultDaemonHost.Run("host", 8123, false, "token", cancelSource.Token), cancelSource);
+ }
+
+ public async Task RunDefauldDaemonUntilCanceled(short milliSeconds = 100, bool overrideDebugNotCancel = false)
+ {
+ var cancelSource = Debugger.IsAttached && !overrideDebugNotCancel
+ ? new CancellationTokenSource()
+ : new CancellationTokenSource(milliSeconds);
+ try
+ {
+ await InitApps();
+ await _defaultDaemonHost.Run("host", 8123, false, "token", cancelSource.Token).ConfigureAwait(false);
+ }
+ catch (TaskCanceledException)
+ {
+ // Expected behaviour
+ }
+ }
+
+ public async Task WaitUntilCanceled(Task task)
+ {
+ try
+ {
+ await task.ConfigureAwait(false);
+ }
+ catch (TaskCanceledException)
+ {
+ // Expected behaviour
+ }
+ }
+
+ public async Task InitApps()
+ {
+ foreach (var inst in DefaultDaemonHost.InternalAllAppInstances)
+ {
+ await inst.Value.StartUpAsync(_defaultDaemonHost);
+ }
+
+ foreach (var inst in DefaultDaemonHost.InternalRunningAppInstances)
+ {
+ await inst.Value.InitializeAsync();
+ inst.Value.Initialize();
+ }
+ }
+
+ protected async Task GetConnectedNetDaemonTask(short milliSeconds = 100, bool overrideDebugNotCancel = false)
+ {
+ var cancelSource = Debugger.IsAttached && !overrideDebugNotCancel
+ ? new CancellationTokenSource()
+ : new CancellationTokenSource(milliSeconds);
+
+ await InitApps();
+
+ var daemonTask = _defaultDaemonHost.Run("host", 8123, false, "token", cancelSource.Token);
+ await WaitForDefaultDaemonToConnect(DefaultDaemonHost, cancelSource.Token);
+ return daemonTask;
+ }
+
+ protected async Task WaitForDefaultDaemonToConnect(NetDaemonHost daemonHost, CancellationToken stoppingToken)
+ {
+ var nrOfTimesCheckForConnectedState = 0;
+
+ while (!daemonHost.Connected && !stoppingToken.IsCancellationRequested)
+ {
+ await Task.Delay(50, stoppingToken).ConfigureAwait(false);
+ if (nrOfTimesCheckForConnectedState++ > 100)
+ break;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/NetDaemon.Test/HassClientMock.cs b/tests/NetDaemon.Test/HassClientMock.cs
new file mode 100644
index 000000000..7cc10ffa2
--- /dev/null
+++ b/tests/NetDaemon.Test/HassClientMock.cs
@@ -0,0 +1,245 @@
+using JoySoftware.HomeAssistant.Client;
+using Moq;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using NetDaemon.Common;
+using NetDaemon.Common.Fluent;
+using Xunit;
+
+namespace NetDaemon.Daemon.Test
+{
+ public class HassClientMock : Mock
+ {
+ internal HassAreas Areas = new HassAreas();
+ internal HassDevices Devices = new HassDevices();
+ internal HassEntities Entities = new HassEntities();
+ internal ConcurrentQueue FakeEvents = new ConcurrentQueue();
+ internal ConcurrentDictionary FakeStates = new ConcurrentDictionary();
+
+ ///
+ /// Default constructor
+ ///
+ public HassClientMock()
+ {
+#pragma warning disable 8619, 8620
+ // Setup common mocks
+ Setup(x => x.ConnectAsync(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(true);
+
+ SetupGet(x => x.States).Returns(FakeStates);
+
+ Setup(x => x.GetAllStates(It.IsAny()))
+ .ReturnsAsync(() => { return (IEnumerable)FakeStates.Values; });
+
+ Setup(x => x.ReadEventAsync())
+ .ReturnsAsync(() => { return FakeEvents.TryDequeue(out var ev) ? ev : null; });
+
+ Setup(x => x.ReadEventAsync(It.IsAny()))
+ .ReturnsAsync(() => { return FakeEvents.TryDequeue(out var ev) ? ev : null; });
+
+ Setup(x => x.GetConfig()).ReturnsAsync(new HassConfig { State = "RUNNING" });
+
+ Setup(x => x.SetState(It.IsAny(), It.IsAny(), It.IsAny