feat: Enhance device testing and environment handling with new interfaces and fakes

This commit is contained in:
Neil Dorin 2026-04-08 13:21:15 -06:00
parent 24df4e7a03
commit 37961b9eac
11 changed files with 421 additions and 33 deletions

View file

@ -29,6 +29,13 @@ public interface ICrestronEnvironment
/// <summary>Gets the application root directory path.</summary> /// <summary>Gets the application root directory path.</summary>
string GetApplicationRootDirectory(); string GetApplicationRootDirectory();
/// <summary>
/// Returns <c>true</c> when running on real Crestron hardware.
/// Returns <c>false</c> in test / dev environments so that SDK-specific
/// sinks and enrichers can be safely skipped.
/// </summary>
bool IsHardwareRuntime { get; }
} }
/// <summary> /// <summary>

View file

@ -0,0 +1,323 @@
using FluentAssertions;
using PepperDash.Core;
using Xunit;
namespace PepperDash.Core.Tests.Devices;
/// <summary>
/// Tests for <see cref="Device"/> — the base class for all PepperDash devices.
/// These run without Crestron hardware; Debug is initialized with fakes via TestInitializer.
/// </summary>
public class DeviceTests
{
// -----------------------------------------------------------------------
// Construction
// -----------------------------------------------------------------------
[Fact]
public void Constructor_SingleArg_SetsKey()
{
var device = new Device("my-device");
device.Key.Should().Be("my-device");
}
[Fact]
public void Constructor_SingleArg_SetsNameToEmpty()
{
var device = new Device("my-device");
device.Name.Should().BeEmpty();
}
[Fact]
public void Constructor_TwoArg_SetsKeyAndName()
{
var device = new Device("my-device", "My Device");
device.Key.Should().Be("my-device");
device.Name.Should().Be("My Device");
}
[Fact]
public void Constructor_KeyWithDot_StillSetsKey()
{
// The dot triggers a debug log warning but must not prevent construction.
var device = new Device("parent.child");
device.Key.Should().Be("parent.child");
}
// -----------------------------------------------------------------------
// ToString
// -----------------------------------------------------------------------
[Fact]
public void ToString_WithName_FormatsKeyDashName()
{
var device = new Device("cam-01", "Front Camera");
device.ToString().Should().Be("cam-01 - Front Camera");
}
[Fact]
public void ToString_WithoutName_UsesDashPlaceholder()
{
var device = new Device("cam-01");
device.ToString().Should().Be("cam-01 - ---");
}
// -----------------------------------------------------------------------
// DefaultDevice
// -----------------------------------------------------------------------
[Fact]
public void DefaultDevice_IsNotNull()
{
Device.DefaultDevice.Should().NotBeNull();
}
[Fact]
public void DefaultDevice_HasKeyDefault()
{
Device.DefaultDevice.Key.Should().Be("Default");
}
// -----------------------------------------------------------------------
// CustomActivate / Activate / Deactivate / Initialize
// -----------------------------------------------------------------------
[Fact]
public void CustomActivate_DefaultReturnTrue()
{
var device = new TestDevice("d1");
device.CallCustomActivate().Should().BeTrue();
}
[Fact]
public void Deactivate_DefaultReturnsTrue()
{
var device = new Device("d1");
device.Deactivate().Should().BeTrue();
}
[Fact]
public void Activate_CallsCustomActivate_AndReturnsItsResult()
{
var stub = new ActivateTrackingDevice("d1", result: false);
stub.Activate().Should().BeFalse();
stub.CustomActivateCalled.Should().BeTrue();
}
[Fact]
public void Activate_TrueWhenCustomActivateReturnsTrue()
{
var stub = new ActivateTrackingDevice("d1", result: true);
stub.Activate().Should().BeTrue();
}
[Fact]
public void Initialize_DoesNotThrow()
{
var device = new TestDevice("d1");
var act = () => device.CallInitialize();
act.Should().NotThrow();
}
// -----------------------------------------------------------------------
// PreActivate
// -----------------------------------------------------------------------
[Fact]
public void PreActivate_NoActions_DoesNotThrow()
{
var device = new TestDevice("d1");
var act = () => device.PreActivate();
act.Should().NotThrow();
}
[Fact]
public void PreActivate_RunsRegisteredActionsInOrder()
{
var device = new TestDevice("d1");
var order = new List<int>();
device.AddPreActivationAction(() => order.Add(1));
device.AddPreActivationAction(() => order.Add(2));
device.AddPreActivationAction(() => order.Add(3));
device.PreActivate();
order.Should().Equal(1, 2, 3);
}
[Fact]
public void PreActivate_ContinuesAfterFaultingAction()
{
var device = new TestDevice("d1");
var reached = false;
device.AddPreActivationAction(() => throw new InvalidOperationException("boom"));
device.AddPreActivationAction(() => reached = true);
var act = () => device.PreActivate();
act.Should().NotThrow("exceptions in individual actions must be caught internally");
reached.Should().BeTrue("actions after a faulting action must still run");
}
// -----------------------------------------------------------------------
// PostActivate
// -----------------------------------------------------------------------
[Fact]
public void PostActivate_NoActions_DoesNotThrow()
{
var device = new TestDevice("d1");
var act = () => device.PostActivate();
act.Should().NotThrow();
}
[Fact]
public void PostActivate_RunsRegisteredActionsInOrder()
{
var device = new TestDevice("d1");
var order = new List<int>();
device.AddPostActivationAction(() => order.Add(1));
device.AddPostActivationAction(() => order.Add(2));
device.PostActivate();
order.Should().Equal(1, 2);
}
[Fact]
public void PostActivate_ContinuesAfterFaultingAction()
{
var device = new TestDevice("d1");
var reached = false;
device.AddPostActivationAction(() => throw new Exception("boom"));
device.AddPostActivationAction(() => reached = true);
var act = () => device.PostActivate();
act.Should().NotThrow();
reached.Should().BeTrue();
}
// -----------------------------------------------------------------------
// Pre and Post actions are independent lists
// -----------------------------------------------------------------------
[Fact]
public void PreActivationActions_DoNotRunOnPostActivate()
{
var device = new TestDevice("d1");
var preRan = false;
device.AddPreActivationAction(() => preRan = true);
device.PostActivate();
preRan.Should().BeFalse();
}
[Fact]
public void PostActivationActions_DoNotRunOnPreActivate()
{
var device = new TestDevice("d1");
var postRan = false;
device.AddPostActivationAction(() => postRan = true);
device.PreActivate();
postRan.Should().BeFalse();
}
// -----------------------------------------------------------------------
// OnFalse
// -----------------------------------------------------------------------
[Fact]
public void OnFalse_FiresAction_WhenBoolIsFalse()
{
var device = new Device("d1");
var fired = false;
device.OnFalse(false, () => fired = true);
fired.Should().BeTrue();
}
[Fact]
public void OnFalse_DoesNotFireAction_WhenBoolIsTrue()
{
var device = new Device("d1");
var fired = false;
device.OnFalse(true, () => fired = true);
fired.Should().BeFalse();
}
[Fact]
public void OnFalse_DoesNotFireAction_ForNonBoolType()
{
var device = new Device("d1");
var fired = false;
device.OnFalse("not a bool", () => fired = true);
device.OnFalse(0, () => fired = true);
device.OnFalse(null!, () => fired = true);
fired.Should().BeFalse();
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
/// <summary>
/// Exposes protected Device members so test methods can call them directly.
/// </summary>
private class TestDevice : Device
{
public TestDevice(string key) : base(key) { }
public TestDevice(string key, string name) : base(key, name) { }
public void AddPreActivationAction(Action act) => base.AddPreActivationAction(act);
public void AddPostActivationAction(Action act) => base.AddPostActivationAction(act);
public bool CallCustomActivate() => base.CustomActivate();
public void CallInitialize() => base.Initialize();
}
/// <summary>
/// Records whether CustomActivate was invoked and returns a configured result.
/// Used to verify Activate() correctly delegates to CustomActivate().
/// </summary>
private sealed class ActivateTrackingDevice : Device
{
private readonly bool _result;
public bool CustomActivateCalled { get; private set; }
public ActivateTrackingDevice(string key, bool result = true) : base(key)
{
_result = result;
}
protected override bool CustomActivate()
{
CustomActivateCalled = true;
return _result;
}
}
}

View file

@ -19,6 +19,9 @@ public class FakeCrestronEnvironment : ICrestronEnvironment
public string GetApplicationRootDirectory() => System.IO.Path.GetTempPath(); public string GetApplicationRootDirectory() => System.IO.Path.GetTempPath();
/// <inheritdoc/>
public bool IsHardwareRuntime => false;
/// <summary>Simulates a program status event for tests.</summary> /// <summary>Simulates a program status event for tests.</summary>
public void RaiseProgramStatus(ProgramStatusEventType type) => public void RaiseProgramStatus(ProgramStatusEventType type) =>
ProgramStatusChanged?.Invoke(this, new ProgramStatusEventArgs(type)); ProgramStatusChanged?.Invoke(this, new ProgramStatusEventArgs(type));

View file

@ -21,5 +21,9 @@
<!-- Reference the Abstractions project only — no Crestron SDK dependency --> <!-- Reference the Abstractions project only — no Crestron SDK dependency -->
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PepperDash.Core.Abstractions\PepperDash.Core.Abstractions.csproj" /> <ProjectReference Include="..\PepperDash.Core.Abstractions\PepperDash.Core.Abstractions.csproj" />
<!-- PepperDash.Core is referenced so we can test Device and other concrete types.
DebugServiceRegistration.Register() is called via a [ModuleInitializer] before any
test runs, so Debug's static constructor uses fakes and never touches the Crestron SDK. -->
<ProjectReference Include="..\PepperDash.Core\PepperDash.Core.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,29 @@
using System.Runtime.CompilerServices;
using PepperDash.Core.Abstractions;
using PepperDash.Core.Tests.Fakes;
namespace PepperDash.Core.Tests;
/// <summary>
/// Runs once before any type in this assembly is accessed.
/// Registers fake Crestron service implementations with <see cref="DebugServiceRegistration"/>
/// so that the <c>Debug</c> static constructor uses them instead of the real Crestron SDK.
/// This must remain a module initializer (not a test fixture) because the static constructor
/// fires the first time <em>any</em> type in PepperDash.Core is referenced — before xUnit
/// has a chance to run fixture setup code.
/// </summary>
internal static class TestInitializer
{
[ModuleInitializer]
internal static void Initialize()
{
DebugServiceRegistration.Register(
new FakeCrestronEnvironment
{
DevicePlatform = DevicePlatform.Server, // avoids any appliance-only code paths
RuntimeEnvironment = RuntimeEnvironment.Other, // skips console command registration
},
new NoOpCrestronConsole(),
new InMemoryCrestronDataStore());
}
}

View file

@ -57,6 +57,9 @@ public sealed class CrestronEnvironmentAdapter : PdCore.ICrestronEnvironment
public string GetApplicationRootDirectory() => public string GetApplicationRootDirectory() =>
Crestron.SimplSharp.CrestronIO.Directory.GetApplicationRootDirectory(); Crestron.SimplSharp.CrestronIO.Directory.GetApplicationRootDirectory();
/// <inheritdoc/>
public bool IsHardwareRuntime => true;
private static PdCore.ProgramStatusEventType MapProgramStatus(eProgramStatusEventType type) => private static PdCore.ProgramStatusEventType MapProgramStatus(eProgramStatusEventType type) =>
type switch type switch
{ {

View file

@ -75,7 +75,7 @@ public class Device : IKeyName
/// Adds a pre activation action /// Adds a pre activation action
/// </summary> /// </summary>
/// <param name="act"></param> /// <param name="act"></param>
public void AddPreActivationAction(Action act) protected void AddPreActivationAction(Action act)
{ {
if (_PreActivationActions == null) if (_PreActivationActions == null)
_PreActivationActions = new List<Action>(); _PreActivationActions = new List<Action>();
@ -89,7 +89,7 @@ public class Device : IKeyName
/// <summary> /// <summary>
/// AddPostActivationAction method /// AddPostActivationAction method
/// </summary> /// </summary>
public void AddPostActivationAction(Action act) protected void AddPostActivationAction(Action act)
{ {
if (_PostActivationActions == null) if (_PostActivationActions == null)
_PostActivationActions = new List<Action>(); _PostActivationActions = new List<Action>();
@ -156,7 +156,7 @@ public class Device : IKeyName
/// <summary> /// <summary>
/// CustomActivate method /// CustomActivate method
/// </summary> /// </summary>
public virtual bool CustomActivate() { return true; } protected virtual bool CustomActivate() { return true; }
/// <summary> /// <summary>
/// Call to deactivate device - unlink events, etc. Overriding classes do not /// Call to deactivate device - unlink events, etc. Overriding classes do not
@ -168,7 +168,7 @@ public class Device : IKeyName
/// <summary> /// <summary>
/// Call this method to start communications with a device. Overriding classes do not need to call base.Initialize() /// Call this method to start communications with a device. Overriding classes do not need to call base.Initialize()
/// </summary> /// </summary>
public virtual void Initialize() protected virtual void Initialize()
{ {
} }

View file

@ -96,7 +96,7 @@ public static class Debug
/// <summary> /// <summary>
/// The name of the file containing the current debug settings. /// The name of the file containing the current debug settings.
/// </summary> /// </summary>
public static string FileName = string.Format(@"app{0}Debug.json", InitialParametersClass.ApplicationNumber); public static string FileName = "app0Debug.json"; // default; updated in static ctor using _environment.ApplicationNumber
/// <summary> /// <summary>
/// Debug level to set for a given program. /// Debug level to set for a given program.
@ -161,6 +161,9 @@ public static class Debug
IsRunningOnAppliance = _environment?.DevicePlatform == PdCore.DevicePlatform.Appliance; IsRunningOnAppliance = _environment?.DevicePlatform == PdCore.DevicePlatform.Appliance;
// Update FileName now that _environment is available (avoids Crestron SDK ref in field initializer).
FileName = $"app{_environment?.ApplicationNumber ?? 0}Debug.json";
_dataStore?.InitStore(); _dataStore?.InitStore();
consoleDebugTimer = new Timer(defaultConsoleDebugTimeoutMin * 60000) { AutoReset = false }; consoleDebugTimer = new Timer(defaultConsoleDebugTimeoutMin * 60000) { AutoReset = false };
@ -180,7 +183,7 @@ public static class Debug
errorLogLevelSwitch = new LoggingLevelSwitch(initialMinimumLevel: defaultErrorLogLevel); errorLogLevelSwitch = new LoggingLevelSwitch(initialMinimumLevel: defaultErrorLogLevel);
fileLoggingLevelSwitch = new LoggingLevelSwitch(initialMinimumLevel: defaultFileLogLevel); fileLoggingLevelSwitch = new LoggingLevelSwitch(initialMinimumLevel: defaultFileLogLevel);
websocketSink = new DebugWebsocketSink(new JsonFormatter(renderMessage: true)); websocketSink = TryCreateWebsocketSink();
var appRoot = _environment?.GetApplicationRootDirectory() var appRoot = _environment?.GetApplicationRootDirectory()
?? System.IO.Path.GetTempPath(); ?? System.IO.Path.GetTempPath();
@ -200,7 +203,6 @@ public static class Debug
.MinimumLevel.Verbose() .MinimumLevel.Verbose()
.Enrich.FromLogContext() .Enrich.FromLogContext()
.WriteTo.Sink(new DebugConsoleSink(new ExpressionTemplate("[{@t:yyyy-MM-dd HH:mm:ss.fff}][{@l:u4}][{App}]{#if Key is not null}[{Key}]{#end} {@m}{#if @x is not null}\r\n{@x}{#end}")), levelSwitch: consoleLoggingLevelSwitch) .WriteTo.Sink(new DebugConsoleSink(new ExpressionTemplate("[{@t:yyyy-MM-dd HH:mm:ss.fff}][{@l:u4}][{App}]{#if Key is not null}[{Key}]{#end} {@m}{#if @x is not null}\r\n{@x}{#end}")), levelSwitch: consoleLoggingLevelSwitch)
.WriteTo.Sink(websocketSink, levelSwitch: websocketLoggingLevelSwitch)
.WriteTo.File(new RenderedCompactJsonFormatter(), logFilePath, .WriteTo.File(new RenderedCompactJsonFormatter(), logFilePath,
rollingInterval: RollingInterval.Day, rollingInterval: RollingInterval.Day,
restrictedToMinimumLevel: LogEventLevel.Debug, restrictedToMinimumLevel: LogEventLevel.Debug,
@ -208,8 +210,12 @@ public static class Debug
levelSwitch: fileLoggingLevelSwitch levelSwitch: fileLoggingLevelSwitch
); );
// Add Crestron-specific enricher and error-log sink only when running on hardware. // Websocket sink is null when DebugWebsocketSink failed to construct (e.g. test env).
if (_environment != null) if (websocketSink != null)
_defaultLoggerConfiguration.WriteTo.Sink(websocketSink, levelSwitch: websocketLoggingLevelSwitch);
// Add Crestron-specific enricher and error-log sink only on real hardware.
if (_environment?.IsHardwareRuntime == true)
{ {
var errorLogTemplate = IsRunningOnAppliance var errorLogTemplate = IsRunningOnAppliance
? "{@t:fff}ms [{@l:u4}]{#if Key is not null}[{Key}]{#end} {@m}{#if @x is not null}\r\n{@x}{#end}" ? "{@t:fff}ms [{@l:u4}]{#if Key is not null}[{Key}]{#end} {@m}{#if @x is not null}\r\n{@x}{#end}"
@ -271,8 +277,34 @@ public static class Debug
{ {
// _logger may not have been initialized yet — do not call LogError here. // _logger may not have been initialized yet — do not call LogError here.
// _console may also be null; fall back to CrestronConsole as last resort. // _console may also be null; fall back to CrestronConsole as last resort.
// IMPORTANT: this catch block must not throw — any exception escaping a static
// constructor permanently faults the type, making the entire class unusable.
try { _console?.PrintLine($"Exception in Debug static constructor: {ex.Message}\r\n{ex.StackTrace}"); } try { _console?.PrintLine($"Exception in Debug static constructor: {ex.Message}\r\n{ex.StackTrace}"); }
catch { CrestronConsole.PrintLine($"Exception in Debug static constructor: {ex.Message}\r\n{ex.StackTrace}"); } catch
{
try { CrestronConsole.PrintLine($"Exception in Debug static constructor: {ex.Message}\r\n{ex.StackTrace}"); }
catch { /* CrestronConsole unavailable (test/dev env) — swallow to keep type initializer healthy */ }
}
}
finally
{
// Guarantee _logger is never null — all Debug.Log* calls are safe even if the
// ctor failed partway through (e.g. on a dev machine without Crestron hardware).
_logger ??= new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger();
}
}
/// <summary>Creates the WebSocket sink, returning null if construction fails in a test/dev environment.</summary>
private static DebugWebsocketSink? TryCreateWebsocketSink()
{
try
{
return new DebugWebsocketSink(new JsonFormatter(renderMessage: true));
}
catch
{
_console?.PrintLine("DebugWebsocketSink could not be created in this environment; websocket logging disabled.");
return null;
} }
} }

View file

@ -91,13 +91,18 @@ public class DebugWebsocketSink : ILogEventSink, IKeyed
if (!File.Exists(CertPath)) if (!File.Exists(CertPath))
CreateCert(); CreateCert();
CrestronEnvironment.ProgramStatusEventHandler += type => try
{ {
if (type == eProgramStatusEventType.Stopping) CrestronEnvironment.ProgramStatusEventHandler += type =>
{ {
StopServer(); if (type == eProgramStatusEventType.Stopping)
} StopServer();
}; };
}
catch
{
// CrestronEnvironment is not available in test / dev environments — safe to skip.
}
} }
private static void CreateCert() private static void CreateCert()

View file

@ -8,20 +8,6 @@ namespace PepperDash.Essentials.AppServer.Messengers
/// </summary> /// </summary>
public class DeviceStateMessageBase : DeviceMessageBase public class DeviceStateMessageBase : DeviceMessageBase
{ {
/// <summary>
/// The interfaces implmented by the device sending the messsage
/// </summary>
[JsonProperty("interfaces")]
public List<string> Interfaces { get; private set; }
/// <summary>
/// Sets the interfaces implemented by the device sending the message
/// </summary>
/// <param name="interfaces"></param>
public void SetInterfaces(List<string> interfaces)
{
Interfaces = interfaces;
}
} }
} }

View file

@ -258,8 +258,6 @@ namespace PepperDash.Essentials.AppServer.Messengers
throw new ArgumentNullException("device"); throw new ArgumentNullException("device");
} }
message.SetInterfaces(_deviceInterfaces);
message.Key = _device.Key; message.Key = _device.Key;
message.Name = _device.Name; message.Name = _device.Name;
@ -285,8 +283,6 @@ namespace PepperDash.Essentials.AppServer.Messengers
{ {
try try
{ {
deviceState.SetInterfaces(_deviceInterfaces);
deviceState.Key = _device.Key; deviceState.Key = _device.Key;
deviceState.Name = _device.Name; deviceState.Name = _device.Name;