diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 121a3cd..662964c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,6 +8,20 @@ on:
workflow_dispatch:
jobs:
+ unit-tests:
+ name: Unit tests (OrbisForge.Core)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install .NET SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Run tests
+ run: dotnet test tests/OrbisForge.Core.Tests --logger "console;verbosity=normal"
+
generate-and-build:
name: Generate + build (${{ matrix.name }})
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index 0e1f3ce..b9be2d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ obj/
publish/
.vs/
*.user
+TestResults/
diff --git a/OrbisForge.slnx b/OrbisForge.slnx
index 2f9ec5c..875db24 100644
--- a/OrbisForge.slnx
+++ b/OrbisForge.slnx
@@ -4,6 +4,9 @@
+
+
+
diff --git a/README.md b/README.md
index 7da250f..7c7c047 100644
--- a/README.md
+++ b/README.md
@@ -85,9 +85,21 @@ dotnet publish src/OrbisForge.Cli -c Release -r win-x64 --self-contained -p:Pu
dotnet publish src/OrbisForge.Cli -c Release -r linux-x64 --self-contained -p:PublishSingleFile=true -o publish/linux-x64
```
+## Testing
+
+```
+dotnet test tests/OrbisForge.Core.Tests
+```
+
+Unit tests cover `OrbisForge.Core`'s scaffolding logic directly (file sets per target type,
+template content, sanitization, warnings) — fast, no toolchain required. CI's generate-and-build
+matrix is the complementary end-to-end layer: it proves the *output* actually compiles against
+the real OpenOrbis toolchain.
+
## Project layout
- `src/OrbisForge.Core` — scaffolding logic (multi-targeted `net8.0`/`netstandard2.0`)
+- `tests/OrbisForge.Core.Tests` — xUnit tests for Core
- `src/OrbisForge.Cli` — cross-platform console scaffolder
- `src/OrbisForge.Gui` — Windows wizard (WinForms + UIFramework)
- `external/UIFramework` — git submodule
diff --git a/tests/OrbisForge.Core.Tests/OrbisForge.Core.Tests.csproj b/tests/OrbisForge.Core.Tests/OrbisForge.Core.Tests.csproj
new file mode 100644
index 0000000..6849b81
--- /dev/null
+++ b/tests/OrbisForge.Core.Tests/OrbisForge.Core.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/OrbisForge.Core.Tests/OrbisTargetTypeTests.cs b/tests/OrbisForge.Core.Tests/OrbisTargetTypeTests.cs
new file mode 100644
index 0000000..8ce4e6b
--- /dev/null
+++ b/tests/OrbisForge.Core.Tests/OrbisTargetTypeTests.cs
@@ -0,0 +1,60 @@
+using OrbisForge.Core;
+using Xunit;
+
+namespace OrbisForge.Core.Tests;
+
+public class OrbisTargetTypeTests
+{
+ [Theory]
+ [InlineData(OrbisTargetType.Exe, "EXE")]
+ [InlineData(OrbisTargetType.Prx, "PRX")]
+ [InlineData(OrbisTargetType.Sprx, "SPRX")]
+ public void ToCMakeString_ReturnsExpectedToken(OrbisTargetType type, string expected)
+ {
+ Assert.Equal(expected, type.ToCMakeString());
+ }
+
+ [Theory]
+ [InlineData(OrbisTargetType.Exe, false)]
+ [InlineData(OrbisTargetType.Prx, true)]
+ [InlineData(OrbisTargetType.Sprx, true)]
+ public void IsLibrary_OnlyTrueForPrxAndSprx(OrbisTargetType type, bool expected)
+ {
+ Assert.Equal(expected, type.IsLibrary());
+ }
+
+ [Theory]
+ [InlineData("exe", OrbisTargetType.Exe)]
+ [InlineData("EXE", OrbisTargetType.Exe)]
+ [InlineData("bin", OrbisTargetType.Exe)]
+ [InlineData(" prx ", OrbisTargetType.Prx)]
+ [InlineData("SPRX", OrbisTargetType.Sprx)]
+ public void TryParse_AcceptsKnownAliasesCaseInsensitively(string text, OrbisTargetType expected)
+ {
+ Assert.True(OrbisTargetTypeExtensions.TryParse(text, out var type));
+ Assert.Equal(expected, type);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("elf")]
+ [InlineData("dll")]
+ public void TryParse_RejectsUnknownText(string text)
+ {
+ Assert.False(OrbisTargetTypeExtensions.TryParse(text, out _));
+ }
+
+ [Fact]
+ public void ArtifactRelativePath_Exe_IsEbootBin()
+ {
+ Assert.Equal("build/eboot.bin", OrbisTargetType.Exe.ArtifactRelativePath("anything"));
+ }
+
+ [Theory]
+ [InlineData(OrbisTargetType.Prx, "my_lib", "build/my_lib.prx")]
+ [InlineData(OrbisTargetType.Sprx, "my_plugin", "build/my_plugin.sprx")]
+ public void ArtifactRelativePath_Libraries_UseNameInFilename(OrbisTargetType type, string name, string expected)
+ {
+ Assert.Equal(expected, type.ArtifactRelativePath(name));
+ }
+}
diff --git a/tests/OrbisForge.Core.Tests/ProjectGeneratorTests.cs b/tests/OrbisForge.Core.Tests/ProjectGeneratorTests.cs
new file mode 100644
index 0000000..928a849
--- /dev/null
+++ b/tests/OrbisForge.Core.Tests/ProjectGeneratorTests.cs
@@ -0,0 +1,279 @@
+using System.Text.Json;
+using OrbisForge.Core;
+using Xunit;
+
+namespace OrbisForge.Core.Tests;
+
+// xUnit creates a fresh instance of this class per test method, so the constructor/Dispose
+// pair gives each test its own isolated temp directory without any shared-state risk.
+public class ProjectGeneratorTests : IDisposable
+{
+ private readonly string _outputDir;
+
+ public ProjectGeneratorTests()
+ {
+ _outputDir = Path.Combine(Path.GetTempPath(), "orbisforge-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_outputDir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_outputDir, recursive: true); } catch { /* best effort cleanup */ }
+ }
+
+ [Fact]
+ public void Generate_Exe_WritesMainCppAndCoreFiles()
+ {
+ var result = ProjectGenerator.Generate(new ProjectOptions
+ {
+ Name = "my_exe",
+ Type = OrbisTargetType.Exe,
+ OutputDirectory = _outputDir,
+ });
+
+ Assert.Equal(Path.Combine(_outputDir, "my_exe"), result.RootDirectory);
+ AssertFileExists(result, "src/main.cpp");
+ AssertFileExists(result, "CMakeLists.txt");
+ AssertFileExists(result, "CMakePresets.json");
+ AssertFileExists(result, "cmake/oo-ps4-toolchain.cmake");
+ AssertFileExists(result, "cmake/OrbisTargets.cmake");
+ AssertFileExists(result, "build.bat");
+ AssertFileExists(result, "build.sh");
+ AssertFileExists(result, ".vscode/tasks.json");
+ AssertFileExists(result, ".vscode/c_cpp_properties.json");
+ AssertFileExists(result, ".gitignore");
+ AssertFileExists(result, "README.md");
+
+ AssertFileMissing(result, "src/my_exe.cpp");
+ AssertFileMissing(result, "deploy.bat");
+ AssertFileMissing(result, "sce_sys/icon0.png");
+ }
+
+ [Theory]
+ [InlineData(OrbisTargetType.Prx)]
+ [InlineData(OrbisTargetType.Sprx)]
+ public void Generate_Library_WritesNamedSourceAndHeaderInsteadOfMain(OrbisTargetType type)
+ {
+ var result = ProjectGenerator.Generate(new ProjectOptions
+ {
+ Name = "my_lib",
+ Type = type,
+ OutputDirectory = _outputDir,
+ });
+
+ AssertFileExists(result, "src/my_lib.cpp");
+ AssertFileExists(result, "src/my_lib.h");
+ AssertFileMissing(result, "src/main.cpp");
+ }
+
+ [Theory]
+ [InlineData(OrbisTargetType.Exe, "TYPE EXE")]
+ [InlineData(OrbisTargetType.Prx, "TYPE PRX")]
+ [InlineData(OrbisTargetType.Sprx, "TYPE SPRX")]
+ public void Generate_CMakeLists_DeclaresCorrectType(OrbisTargetType type, string expectedSnippet)
+ {
+ var result = ProjectGenerator.Generate(new ProjectOptions
+ {
+ Name = "typed_project",
+ Type = type,
+ OutputDirectory = _outputDir,
+ });
+
+ var content = ReadFile(result, "CMakeLists.txt");
+ Assert.Contains(expectedSnippet, content);
+ }
+
+ [Fact]
+ public void Generate_WithPtypeOverride_FlowsIntoCMakeLists()
+ {
+ var result = ProjectGenerator.Generate(new ProjectOptions
+ {
+ Name = "custom_ptype",
+ Type = OrbisTargetType.Sprx,
+ OutputDirectory = _outputDir,
+ PType = "secure_module",
+ });
+
+ var content = ReadFile(result, "CMakeLists.txt");
+ Assert.Contains("PTYPE secure_module", content);
+ }
+
+ [Fact]
+ public void Generate_WithoutPkg_DoesNotAddPackagingBlockOrAssets()
+ {
+ var result = ProjectGenerator.Generate(new ProjectOptions
+ {
+ Name = "no_pkg",
+ Type = OrbisTargetType.Exe,
+ OutputDirectory = _outputDir,
+ IncludePackaging = false,
+ });
+
+ var content = ReadFile(result, "CMakeLists.txt");
+ Assert.DoesNotContain("add_orbis_package", content);
+ AssertFileMissing(result, "sce_sys/icon0.png");
+ Assert.Empty(result.Warnings);
+ }
+
+ [Fact]
+ public void Generate_PkgOnPrx_IsIgnoredByGenerator()
+ {
+ // ProjectGenerator only wires packaging for EXE targets even if the flag is set --
+ // the CLI/GUI are expected to reject this combination before calling Generate, but
+ // Generate itself should not blow up or silently produce a broken PRX+pkg mix.
+ var result = ProjectGenerator.Generate(new ProjectOptions
+ {
+ Name = "prx_with_pkg_flag",
+ Type = OrbisTargetType.Prx,
+ OutputDirectory = _outputDir,
+ IncludePackaging = true,
+ });
+
+ AssertFileMissing(result, "sce_sys/icon0.png");
+ var content = ReadFile(result, "CMakeLists.txt");
+ Assert.DoesNotContain("add_orbis_package", content);
+ }
+
+ [Fact]
+ public void Generate_PkgWithMissingToolchain_WarnsPerAssetAndStillWritesOtherFiles()
+ {
+ var bogusToolchain = Path.Combine(_outputDir, "does-not-exist");
+
+ var result = ProjectGenerator.Generate(new ProjectOptions
+ {
+ Name = "pkg_project",
+ Type = OrbisTargetType.Exe,
+ OutputDirectory = _outputDir,
+ IncludePackaging = true,
+ ToolchainPathOverride = bogusToolchain,
+ });
+
+ Assert.Equal(4, result.Warnings.Count); // icon0.png, right.sprx, libSceFios2.prx, libc.prx
+ Assert.All(result.Warnings, w => Assert.Contains("not found, skipped", w));
+
+ var content = ReadFile(result, "CMakeLists.txt");
+ Assert.Contains("add_orbis_package", content);
+ AssertFileExists(result, "src/main.cpp"); // rest of generation still completed
+ }
+
+ [Fact]
+ public void Generate_WithDeployHost_WritesDeployScriptsWithConfiguredValues()
+ {
+ var result = ProjectGenerator.Generate(new ProjectOptions
+ {
+ Name = "deploy_me",
+ Type = OrbisTargetType.Exe,
+ OutputDirectory = _outputDir,
+ DeployHost = "192.168.1.50",
+ DeployPort = 2121,
+ DeployRemotePath = "/data/homebrew/deploy_me/",
+ });
+
+ AssertFileExists(result, "deploy.bat");
+ AssertFileExists(result, "deploy.sh");
+
+ var bat = ReadFile(result, "deploy.bat");
+ Assert.Contains("ftp://192.168.1.50:2121/data/homebrew/deploy_me/", bat);
+ Assert.Contains(@"build\eboot.bin", bat);
+
+ var sh = ReadFile(result, "deploy.sh");
+ Assert.Contains("ftp://192.168.1.50:2121/data/homebrew/deploy_me/", sh);
+ Assert.Contains("build/eboot.bin", sh);
+ }
+
+ [Fact]
+ public void Generate_WithoutDeployHost_DoesNotWriteDeployScripts()
+ {
+ var result = ProjectGenerator.Generate(new ProjectOptions
+ {
+ Name = "no_deploy",
+ Type = OrbisTargetType.Exe,
+ OutputDirectory = _outputDir,
+ });
+
+ AssertFileMissing(result, "deploy.bat");
+ AssertFileMissing(result, "deploy.sh");
+ }
+
+ [Fact]
+ public void Generate_GitignoreExcludesBuildDirectory()
+ {
+ var result = ProjectGenerator.Generate(new ProjectOptions
+ {
+ Name = "gi_project",
+ Type = OrbisTargetType.Exe,
+ OutputDirectory = _outputDir,
+ });
+
+ var content = ReadFile(result, ".gitignore");
+ Assert.Contains("build/", content);
+ }
+
+ [Fact]
+ public void Generate_VsCodeFiles_AreValidJson()
+ {
+ var result = ProjectGenerator.Generate(new ProjectOptions
+ {
+ Name = "vscode_project",
+ Type = OrbisTargetType.Exe,
+ OutputDirectory = _outputDir,
+ });
+
+ // JsonDocument.Parse throws on malformed JSON -- that's the assertion.
+ using var tasks = JsonDocument.Parse(ReadFile(result, ".vscode/tasks.json"));
+ using var cpp = JsonDocument.Parse(ReadFile(result, ".vscode/c_cpp_properties.json"));
+
+ Assert.True(tasks.RootElement.TryGetProperty("tasks", out _));
+ Assert.True(cpp.RootElement.TryGetProperty("configurations", out _));
+ }
+
+ [Fact]
+ public void Generate_SanitizesNameForDirectoryAndCMakeTarget()
+ {
+ var result = ProjectGenerator.Generate(new ProjectOptions
+ {
+ Name = "my cool game!",
+ Type = OrbisTargetType.Exe,
+ OutputDirectory = _outputDir,
+ });
+
+ Assert.Equal(Path.Combine(_outputDir, "my_cool_game_"), result.RootDirectory);
+ var content = ReadFile(result, "CMakeLists.txt");
+ Assert.Contains("project(my_cool_game_ CXX)", content);
+ // The human-readable name is preserved verbatim elsewhere (e.g. the printf greeting).
+ Assert.Contains("my cool game!", ReadFile(result, "src/main.cpp"));
+ }
+
+ [Fact]
+ public void Generate_FilesWrittenList_MatchesActualFilesOnDisk()
+ {
+ var result = ProjectGenerator.Generate(new ProjectOptions
+ {
+ Name = "verify_list",
+ Type = OrbisTargetType.Sprx,
+ OutputDirectory = _outputDir,
+ DeployHost = "10.0.0.1",
+ });
+
+ Assert.NotEmpty(result.FilesWritten);
+ Assert.All(result.FilesWritten, f => Assert.True(File.Exists(f), $"Listed but missing on disk: {f}"));
+ }
+
+ private static void AssertFileExists(GeneratedProject result, string relativePath)
+ {
+ var full = Path.Combine(result.RootDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
+ Assert.True(File.Exists(full), $"Expected file not found: {relativePath}");
+ }
+
+ private static void AssertFileMissing(GeneratedProject result, string relativePath)
+ {
+ var full = Path.Combine(result.RootDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
+ Assert.False(File.Exists(full), $"Expected file NOT to exist: {relativePath}");
+ }
+
+ private static string ReadFile(GeneratedProject result, string relativePath)
+ {
+ var full = Path.Combine(result.RootDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
+ return File.ReadAllText(full);
+ }
+}
diff --git a/tests/OrbisForge.Core.Tests/ProjectOptionsTests.cs b/tests/OrbisForge.Core.Tests/ProjectOptionsTests.cs
new file mode 100644
index 0000000..408579a
--- /dev/null
+++ b/tests/OrbisForge.Core.Tests/ProjectOptionsTests.cs
@@ -0,0 +1,42 @@
+using OrbisForge.Core;
+using Xunit;
+
+namespace OrbisForge.Core.Tests;
+
+public class ProjectOptionsTests
+{
+ [Theory]
+ [InlineData("my_project", "my_project")]
+ [InlineData("my project", "my_project")]
+ [InlineData(" spaced ", "spaced")]
+ [InlineData("a/b\\c", "a_b_c")]
+ [InlineData("kebab-case_2", "kebab-case_2")]
+ [InlineData("", "orbis_project")]
+ [InlineData(" ", "orbis_project")]
+ [InlineData("!!!", "___")]
+ public void SanitizedName_ReplacesNonAlphanumericSeparators(string input, string expected)
+ {
+ var options = new ProjectOptions { Name = input };
+ Assert.Equal(expected, options.SanitizedName);
+ }
+
+ [Fact]
+ public void SanitizedName_PreservesUnicodeLetters()
+ {
+ // char.IsLetterOrDigit is Unicode-aware, so accented letters pass through as-is
+ // rather than getting collapsed to underscores -- documenting that, not just assuming it.
+ var options = new ProjectOptions { Name = "héllo" };
+ Assert.Equal("héllo", options.SanitizedName);
+ }
+
+ [Theory]
+ [InlineData(null, false)]
+ [InlineData("", false)]
+ [InlineData(" ", false)]
+ [InlineData("192.168.1.50", true)]
+ public void IncludeDeploy_TracksWhetherHostIsSet(string? host, bool expected)
+ {
+ var options = new ProjectOptions { Name = "x", DeployHost = host };
+ Assert.Equal(expected, options.IncludeDeploy);
+ }
+}