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); + } +}