4 Commits
10 changed files with 536 additions and 2 deletions

No files matched your search

+25 -1
View File
@@ -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
@@ -52,9 +66,19 @@ jobs:
dotnet-version: '8.0.x'
- name: Install OpenOrbis PS4 Toolchain
env:
# Unauthenticated GitHub API calls are capped at 60/hour PER IP, and GitHub-hosted
# runners share IP pools across the whole platform -- that budget gets exhausted by
# unrelated workflows constantly. Authenticating with the auto-provided GITHUB_TOKEN
# raises it to 5000/hour, scoped to this run.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
URL=$(curl -s https://api.github.com/repos/OpenOrbis/OpenOrbis-PS4-Toolchain/releases/latest | grep browser_download_url | cut -d '"' -f4)
URL=$(curl -s -H "Authorization: Bearer $GH_TOKEN" https://api.github.com/repos/OpenOrbis/OpenOrbis-PS4-Toolchain/releases/latest | grep browser_download_url | cut -d '"' -f4)
if [ -z "$URL" ]; then
echo "::error::Could not resolve the OpenOrbis toolchain download URL (GitHub API call returned no browser_download_url)."
exit 1
fi
curl -L -o toolchain.tar.gz "$URL"
mkdir -p /tmp/oo-extract
tar -xzf toolchain.tar.gz -C /tmp/oo-extract
+1
View File
@@ -3,3 +3,4 @@ obj/
publish/
.vs/
*.user
TestResults/
+3
View File
@@ -4,6 +4,9 @@
<Project Path="src/OrbisForge.Core/OrbisForge.Core.csproj" />
<Project Path="src/OrbisForge.Gui/OrbisForge.Gui.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/OrbisForge.Core.Tests/OrbisForge.Core.Tests.csproj" />
</Folder>
<Project Path="external/UIFramework/src/UIFramework.Controls/UIFramework.Controls.csproj" />
<Project Path="external/UIFramework/src/UIFramework.Core/UIFramework.Core.csproj" />
</Solution>
+14 -1
View File
@@ -34,7 +34,8 @@ A Windows GUI wizard and a cross-platform CLI both scaffold the same project lay
or the GUI checkbox. No credentials baked in by design — most PS4 FTP servers run anonymous.
- **GUI wizard (Windows)** — pick a name, type, output folder, optional `.pkg` packaging, and
optional FTP deploy target; detects your WSL distro and the OpenOrbis toolchain automatically,
and surfaces the latest available OpenOrbis release as a reference point.
and surfaces the latest available OpenOrbis release as a reference point. Light/dark mode
toggle, remembered across launches.
- **VS Code integration** — generated projects include a build task (Ctrl+Shift+B) and IntelliSense
configuration for the toolchain's unusual target triple.
- **CLI scaffolder (Windows + Linux + WSL)** — `orbisforge new`, plus `orbisforge build`/
@@ -85,9 +86,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
+45
View File
@@ -0,0 +1,45 @@
using System;
using System.IO;
namespace OrbisForge.Gui
{
/// <summary>Tiny persisted preference store -- currently just the light/dark choice.
/// Not worth a full settings framework for one boolean.</summary>
internal static class GuiSettings
{
private static string FilePath
{
get
{
string dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OrbisForge");
Directory.CreateDirectory(dir);
return Path.Combine(dir, "settings.txt");
}
}
public static bool LoadDarkMode()
{
try
{
return File.Exists(FilePath) && File.ReadAllText(FilePath).Trim() == "dark";
}
catch
{
return false;
}
}
public static void SaveDarkMode(bool isDark)
{
try
{
File.WriteAllText(FilePath, isDark ? "dark" : "light");
}
catch
{
// Not worth surfacing to the user -- worst case the choice doesn't persist.
}
}
}
}
+47
View File
@@ -5,6 +5,8 @@ using System.IO;
using System.Windows.Forms;
using UIFramework.Controls;
using UIFramework.Core.Controls;
using UIFramework.Core.Skinning;
using UIFramework.Core.Skinning.Skins;
using OrbisForge.Core;
@@ -23,6 +25,7 @@ namespace OrbisForge.Gui
private readonly SkinLabel _envLabel = new SkinLabel();
private readonly SkinButton _generateButton = new SkinButton();
private readonly TextBox _statusBox = new TextBox();
private readonly CheckBox _darkModeCheck = new CheckBox();
private WslDetectionResult _wsl;
@@ -42,6 +45,20 @@ namespace OrbisForge.Gui
const int fieldWidth = 320;
int y = 16;
_darkModeCheck.Text = "Dark mode";
_darkModeCheck.AutoSize = true;
_darkModeCheck.FlatStyle = FlatStyle.Flat;
_darkModeCheck.Anchor = AnchorStyles.Top | AnchorStyles.Right;
_darkModeCheck.Location = new Point(ClientSize.Width - 110, y);
_darkModeCheck.Checked = GuiSettings.LoadDarkMode();
_darkModeCheck.CheckedChanged += (s, e) =>
{
ApplyTheme(_darkModeCheck.Checked, save: true);
RefreshPlainControlColors();
};
root.Controls.Add(_darkModeCheck);
ApplyTheme(_darkModeCheck.Checked, save: false);
var nameLabel = new SkinLabel { Text = "Project name:", Location = new Point(left, y), AutoSize = true };
y += 20;
_nameBox.Location = new Point(left, y);
@@ -72,11 +89,13 @@ namespace OrbisForge.Gui
_pkgCheck.Text = "Also generate full .pkg installer (EXE only)";
_pkgCheck.Location = new Point(left, y);
_pkgCheck.AutoSize = true;
_pkgCheck.FlatStyle = FlatStyle.Flat;
y += 28;
_deployCheck.Text = "Deploy to PS4 via FTP after build";
_deployCheck.Location = new Point(left, y);
_deployCheck.AutoSize = true;
_deployCheck.FlatStyle = FlatStyle.Flat;
_deployCheck.CheckedChanged += (s, e) => UpdateDeployFieldsEnabled();
y += 24;
@@ -137,9 +156,37 @@ namespace OrbisForge.Gui
UpdatePkgCheckEnabled();
UpdateDeployFieldsEnabled();
RefreshPlainControlColors();
Shown += (s, e) => DetectEnvironment();
}
private static void ApplyTheme(bool dark, bool save)
{
SkinManager.Current = dark ? (ISkin)new DarkSkin() : new LightSkin();
if (save) GuiSettings.SaveDarkMode(dark);
}
// _statusBox and the CheckBoxes are plain WinForms controls -- UIFramework has no
// skinned equivalents for them (no multiline/scrollable SkinTextBox variant, no
// SkinCheckBox at all) -- so they don't auto-repaint on a skin change like every
// other control here does. Left at their WinForms defaults, they stay bright white
// regardless of the active skin, which looks broken next to a dark-themed form.
private void RefreshPlainControlColors()
{
var textAppearance = SkinManager.Current.GetAppearance(ElementKeys.TextBox, ElementState.Normal);
_statusBox.BackColor = textAppearance.Background;
_statusBox.ForeColor = textAppearance.ForeColor;
var panelAppearance = SkinManager.Current.GetAppearance(ElementKeys.Panel, ElementState.Normal);
var labelAppearance = SkinManager.Current.GetAppearance(ElementKeys.Label, ElementState.Normal);
foreach (var checkBox in new[] { _pkgCheck, _deployCheck, _darkModeCheck })
{
checkBox.UseVisualStyleBackColor = false;
checkBox.BackColor = panelAppearance.Background;
checkBox.ForeColor = labelAppearance.ForeColor;
}
}
private void UpdatePkgCheckEnabled()
{
bool isExe = SelectedType() == OrbisTargetType.Exe;
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OrbisForge.Core\OrbisForge.Core.csproj" />
</ItemGroup>
</Project>
@@ -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));
}
}
@@ -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);
}
}
@@ -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);
}
}