Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/Aetherphone.Tests/EmulatorFirmwareBoundaryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using System.Text.RegularExpressions;
using Aetherphone.Core.Emulation;
using Xunit;

namespace Aetherphone.Tests;

public sealed class EmulatorFirmwareBoundaryTests
{
private static readonly string[] ForbiddenNames =
{
"bios", "firmware", "syscard", "scph", "neogeo.zip", "aes.zip", "neocd", "disksys.rom",
"dmg_boot", "cgb_boot", "sgb.boot", "dsp1", "dsp2", "dsp3", "dsp4", "st010", "st011", "st018",
"cx4", "gba_bios", "bios7", "bios9", "ipl.n64",
};

private static readonly string[] AllowedExtensions = { ".cs", ".txt", ".md", ".json", ".csproj" };

[Fact]
public void NoConsoleFirmwareIsShippedWithThePlugin()
{
var offenders = new List<string>();
foreach (var file in Directory.EnumerateFiles(PluginRoot(), "*", SearchOption.AllDirectories))
{
if (IsBuildOutput(file))
{
continue;
}

var name = Path.GetFileName(file);
if (!Array.Exists(ForbiddenNames, term => name.Contains(term, StringComparison.OrdinalIgnoreCase)))
{
continue;
}

if (Array.Exists(AllowedExtensions,
extension => name.EndsWith(extension, StringComparison.OrdinalIgnoreCase)))
{
continue;
}

offenders.Add(Path.GetRelativePath(PluginRoot(), file));
}

Assert.True(offenders.Count == 0,
$"Console firmware must never ship with the plugin. Found: {string.Join(", ", offenders)}");
}

[Fact]
public void EveryDeclaredFirmwareFileIsSuppliedByTheUser()
{
var declared = new List<string>();
for (var index = 0; index < EmulatorSystemCatalog.All.Count; index++)
{
var system = EmulatorSystemCatalog.All[index];
for (var entry = 0; entry < system.Firmware.Count; entry++)
{
declared.Add(system.Firmware[entry].FileName);
}
}

Assert.NotEmpty(declared);
foreach (var fileName in declared)
{
var matches = Directory.EnumerateFiles(PluginRoot(), Path.GetFileName(fileName),
SearchOption.AllDirectories);
Assert.True(!matches.Any(candidate => !IsBuildOutput(candidate)),
$"{fileName} is declared as user-supplied firmware but ships with the plugin.");
}
}

[Fact]
public void NothingInTheSourceDownloadsConsoleFirmware()
{
var urls = new List<string>();
foreach (var file in Directory.EnumerateFiles(PluginRoot(), "*.cs", SearchOption.AllDirectories))
{
if (IsBuildOutput(file))
{
continue;
}

foreach (Match match in Regex.Matches(File.ReadAllText(file), @"https?://[^\s""']+"))
{
urls.Add(match.Value);
}
}

Assert.NotEmpty(urls);
foreach (var url in urls)
{
Assert.False(
Array.Exists(ForbiddenNames, term => url.Contains(term, StringComparison.OrdinalIgnoreCase)),
$"{url} looks like a console firmware download. Firmware must always come from the user.");
}
}

private static bool IsBuildOutput(string path) =>
path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}",
StringComparison.OrdinalIgnoreCase) ||
path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}",
StringComparison.OrdinalIgnoreCase);

private static string PluginRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, "src", "Aetherphone");
if (Directory.Exists(candidate))
{
return candidate;
}

directory = directory.Parent;
}

throw new DirectoryNotFoundException($"Could not locate src/Aetherphone above '{AppContext.BaseDirectory}'.");
}
}
185 changes: 185 additions & 0 deletions src/Aetherphone.Tests/EmulatorSettingsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
using Aetherphone.Core.Emulation;
using Aetherphone.Core.Theme;
using Aetherphone.Apps.Games.Syrcus;
using System.Numerics;
using System.Text.Json;
using Xunit;

namespace Aetherphone.Tests;

public sealed class EmulatorSettingsTests
{
[Fact]
public void DefaultsMatchThePlayableKeyboardLayout()
{
var settings = new EmulatorSettings();

Assert.Equal(0x26, settings.KeyFor(EmulatorButtons.Up));
Assert.Equal(0x58, settings.KeyFor(EmulatorButtons.A));
Assert.Equal(0x5A, settings.KeyFor(EmulatorButtons.B));
Assert.Equal(0x43, settings.KeyFor(EmulatorButtons.X));
Assert.Equal(0x56, settings.KeyFor(EmulatorButtons.Y));
Assert.Equal(0x0D, settings.KeyFor(EmulatorButtons.Start));
Assert.Equal(EmulatorVideoFilter.Smooth, settings.VideoFilter);
Assert.Equal(EmulatorGameplayOrientation.Landscape, settings.GameplayOrientation);
Assert.Equal(0.5f, settings.Layout.Screen.X);
Assert.Equal(0.30f, settings.Layout.Screen.Y);
Assert.Equal(0.82f, settings.Layout.A.X);
Assert.Equal(0.75f, settings.Layout.X.X);
Assert.True(settings.AutoSaveState);
Assert.True(settings.AutoLoadState);
Assert.Equal(2, settings.FastForwardSpeed);
Assert.True(settings.FastForwardShortcut.IsEmpty);
Assert.True(settings.SaveStateShortcut.IsEmpty);
Assert.True(settings.LoadStateShortcut.IsEmpty);
Assert.Equal(0.85f, settings.Layout.FastForward.X);
Assert.Equal(0.5f, settings.LandscapeLayout.Screen.X);
Assert.Equal(0.5f, settings.LandscapeLayout.Screen.Y);
Assert.Equal(0.09f, settings.LandscapeLayout.Dpad.X);
Assert.Equal(0.95f, settings.LandscapeLayout.A.X);
}

[Fact]
public void VideoFilterValuesRemainBackwardCompatible()
{
Assert.Equal(0, (byte)EmulatorVideoFilter.Pixel);
Assert.Equal(1, (byte)EmulatorVideoFilter.Smooth);
Assert.Equal(2, (byte)EmulatorVideoFilter.Sharp);
Assert.Equal(3, (byte)EmulatorVideoFilter.Balanced);
}

[Fact]
public void LegacySettingsGainLandscapeWithoutLosingThePortraitLayout()
{
const string json = """
{"Layout":{"Screen":{"X":0.25,"Y":0.35,"Scale":1.1}}}
""";

var restored = JsonSerializer.Deserialize<EmulatorSettings>(json)!;
restored.Normalize();

Assert.Equal(EmulatorGameplayOrientation.Landscape, restored.GameplayOrientation);
Assert.Equal(0.25f, restored.Layout.Screen.X);
Assert.Equal(0.35f, restored.Layout.Screen.Y);
Assert.Equal(1.1f, restored.Layout.Screen.Scale);
Assert.Equal(0.5f, restored.LandscapeLayout.Screen.X);
Assert.Equal(0.5f, restored.LandscapeLayout.Screen.Y);
}

[Fact]
public void RemappedKeysCanBeRestored()
{
var settings = new EmulatorSettings();
settings.SetKey(EmulatorButtons.A, 0x43);
settings.SetKey(EmulatorButtons.Start, 0x20);

Assert.Equal(0x43, settings.KeyFor(EmulatorButtons.A));
Assert.Equal(0x20, settings.KeyFor(EmulatorButtons.Start));

settings.ResetKeys();

Assert.Equal(0x58, settings.KeyFor(EmulatorButtons.A));
Assert.Equal(0x0D, settings.KeyFor(EmulatorButtons.Start));
}

[Fact]
public void SettingsSurviveAJsonRoundTrip()
{
var settings = new EmulatorSettings
{
VideoFilter = EmulatorVideoFilter.Smooth,
GameplayOrientation = EmulatorGameplayOrientation.Portrait,
};
settings.Layout.Screen.Y = 0.74f;
settings.Layout.A.X = 0.31f;
settings.Layout.A.Scale = 1.4f;
settings.LandscapeLayout.Screen.Scale = 0.95f;
settings.SetKey(EmulatorButtons.B, 0x43);
settings.AutoLoadState = false;
settings.FastForwardSpeed = 4;
settings.FastForwardShortcut.Set(new[] { 0x11, 0x46 }, 0x0200);
settings.SaveStateShortcut.Set(new[] { 0x74 }, 0);
settings.RomFolders.Add(@"C:\Games\Game Boy");

var json = JsonSerializer.Serialize(settings);
var restored = JsonSerializer.Deserialize<EmulatorSettings>(json)!;

Assert.Equal(EmulatorVideoFilter.Smooth, restored.VideoFilter);
Assert.Equal(EmulatorGameplayOrientation.Portrait, restored.GameplayOrientation);
Assert.Equal(0.74f, restored.Layout.Screen.Y);
Assert.Equal(0.31f, restored.Layout.A.X);
Assert.Equal(1.4f, restored.Layout.A.Scale);
Assert.Equal(0.95f, restored.LandscapeLayout.Screen.Scale);
Assert.Equal(0x43, restored.KeyFor(EmulatorButtons.B));
Assert.False(restored.AutoLoadState);
Assert.Equal(4, restored.FastForwardSpeed);
Assert.Equal(new[] { 0x11, 0x46 }, restored.FastForwardShortcut.Keys);
Assert.Equal((ushort)0x0200, restored.FastForwardShortcut.GamepadButtons);
Assert.Equal(new[] { 0x74 }, restored.SaveStateShortcut.Keys);
Assert.True(restored.LoadStateShortcut.IsEmpty);
Assert.Equal(@"C:\Games\Game Boy", Assert.Single(restored.RomFolders));
}

[Theory]
[InlineData(0.1f, 0.5f)]
[InlineData(1.0f, 1.0f)]
[InlineData(3.0f, 2.0f)]
public void ElementScalesAreClamped(float configured, float expected)
{
var element = new EmulatorElementLayout { Scale = configured };

Assert.Equal(expected, element.SafeScale);
}

[Fact]
public void CoreSettingsAndLibrariesAreIndependent()
{
var root = new EmulatorSettings();
root.MigrateToPerCoreSettings(EmulatorSystemCatalog.All);
var gba = root.ForCore(EmulatorSystemCatalog.GameBoyAdvance);
var n64 = root.ForCore(EmulatorSystemCatalog.Nintendo64);

gba.VideoFilter = EmulatorVideoFilter.Smooth;
gba.GameplayOrientation = EmulatorGameplayOrientation.Portrait;
gba.RomFolders.Add(@"C:\Games\GBA");
n64.CoreOptions["mupen64plus-pak1"] = "rumble";

Assert.Equal(EmulatorVideoFilter.Smooth, n64.VideoFilter);
Assert.Equal(EmulatorGameplayOrientation.Landscape, n64.GameplayOrientation);
Assert.Empty(n64.RomFolders);
Assert.Empty(gba.CoreOptions);
Assert.Equal("rumble", n64.CoreOptions["mupen64plus-pak1"]);
}

[Fact]
public void PhoneSizeStaysPortraitSoLandscapeIsItsRotation()
{
var portrait = PhoneSizeCatalog.SizeFor(PhoneSizeCatalog.DesignWidth);

Assert.Equal(PhoneSizeCatalog.DesignWidth, portrait.X);
Assert.True(portrait.Y > portrait.X);
}

[Fact]
public void EmulatorScreenFitPreservesAspectRatioAtTheViewportEdge()
{
var fitted = SyrcusApp.FitSizeWithin(new Vector2(600f, 500f), new Vector2(900f, 340f));

Assert.Equal(408f, fitted.X, 3);
Assert.Equal(340f, fitted.Y, 3);
Assert.Equal(1.2f, fitted.X / fitted.Y, 3);
}

[Fact]
public void RecentGamesMoveToTheFrontWithoutDuplicates()
{
var settings = new EmulatorSettings();
settings.AddRecent(EmulatorSystemCatalog.GameBoy, @"C:\Games\one.gb");
settings.AddRecent(EmulatorSystemCatalog.GameBoyAdvance, @"C:\Games\two.gba");
settings.AddRecent(EmulatorSystemCatalog.GameBoy, @"C:\Games\one.gb");

Assert.Equal(2, settings.RecentGames.Count);
Assert.Equal("gb", settings.RecentGames[0].SystemId);
Assert.Equal(@"C:\Games\one.gb", settings.RecentGames[0].Path);
}
}
75 changes: 75 additions & 0 deletions src/Aetherphone.Tests/EmulatorStateStoreTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using Aetherphone.Core.Emulation;
using Xunit;

namespace Aetherphone.Tests;

public sealed class EmulatorStateStoreTests
{
[Fact]
public void ManualAndAutomaticStatesUseSeparateFiles()
{
var root = Path.Combine(Path.GetTempPath(), "AetherphoneStateTests", Guid.NewGuid().ToString("N"));
try
{
var store = new EmulatorStateStore(root, Path.Combine(root, "Pokemon.gba"));
store.WriteSlot(1, new byte[] { 1, 2, 3 });
store.WriteAuto(new byte[] { 4, 5, 6 });

Assert.True(store.HasSlot(1));
Assert.True(store.HasAuto);
Assert.Equal(new byte[] { 1, 2, 3 }, store.ReadSlot(1));
Assert.Equal(new byte[] { 4, 5, 6 }, store.ReadAuto());
Assert.NotEqual(store.SlotPath(1), store.AutoPath);
}
finally
{
if (Directory.Exists(root))
{
Directory.Delete(root, recursive: true);
}
}
}

[Theory]
[InlineData(0)]
[InlineData(6)]
public void RejectsSlotsOutsideTheVisibleRange(int slot)
{
var root = Path.Combine(Path.GetTempPath(), "AetherphoneStateTests", Guid.NewGuid().ToString("N"));
try
{
var store = new EmulatorStateStore(root, Path.Combine(root, "game.gba"));
Assert.Throws<ArgumentOutOfRangeException>(() => store.SlotPath(slot));
}
finally
{
if (Directory.Exists(root))
{
Directory.Delete(root, recursive: true);
}
}
}


[Fact]
public void StatesFromDifferentCoresDoNotCollide()
{
var root = Path.Combine(Path.GetTempPath(), "AetherphoneStateTests", Guid.NewGuid().ToString("N"));
try
{
var rom = Path.Combine(root, "Pokemon.gba");
var gpsp = new EmulatorStateStore(root, rom, "gpsp_libretro");
var other = new EmulatorStateStore(root, rom, "other_core");

Assert.NotEqual(gpsp.AutoPath, other.AutoPath);
Assert.NotEqual(gpsp.SlotPath(1), other.SlotPath(1));
}
finally
{
if (Directory.Exists(root))
{
Directory.Delete(root, recursive: true);
}
}
}
}
4 changes: 4 additions & 0 deletions src/Aetherphone/Aetherphone.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
<Content Include="Sounds\**\*.mp3;Sounds\**\*.wav">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Cores\*-LICENSE.txt;Cores\*-SOURCE.txt;Cores\CORE-SOURCES.txt;Cores\GPL-*.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>

<ItemGroup>
Expand Down
Loading