Skip to content
Merged
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
420 changes: 327 additions & 93 deletions Source/MaterialModOptions.cs

Large diffs are not rendered by default.

94 changes: 94 additions & 0 deletions Source/OptionRules.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using System.Globalization;
using System.Reflection;

namespace Celeste.Mod.MicroblocksQolUtils;

internal enum OptionKind {
Switch,
Dropdown,
Range
}

internal static class OptionRules {
internal static OptionKind Classify(bool isBool, bool isSlider, bool isIntOption,
bool? enumBacked, IReadOnlyList<string> labels, IReadOnlyList<object> entries) {
if (isBool) return OptionKind.Switch;
if (!isSlider && !isIntOption) return OptionKind.Dropdown;
if (enumBacked is true) return OptionKind.Dropdown;
if (isSlider && enumBacked is false) return OptionKind.Range;
return HasConsecutiveInts(entries) && labels.Count > 0 && labels.All(LooksNumeric)
? OptionKind.Range
: OptionKind.Dropdown;
}

internal static bool? ChangeTargetsEnum(Delegate? change) {
object? target = change?.Target;
if (target is null) return null;
bool sawEnum = false;
bool sawNumeric = false;
foreach (FieldInfo field in target.GetType().GetFields(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) {
object? value;
try {
value = field.GetValue(target);
} catch {
continue;
}
if (value is PropertyInfo property) {
if (property.PropertyType.IsEnum) sawEnum = true;
else if (IsNumericType(property.PropertyType)) sawNumeric = true;
} else if (value is Array array && array.GetType().GetElementType()?.IsEnum == true) {
sawEnum = true;
} else if (field.FieldType.IsEnum) {
sawEnum = true;
}
}
if (sawEnum && !sawNumeric) return true;
if (sawNumeric && !sawEnum) return false;
return null;
}

internal static bool IsNumericType(Type type) =>
type == typeof(int) || type == typeof(float) || type == typeof(double)
|| type == typeof(long) || type == typeof(short) || type == typeof(byte);

internal static bool HasConsecutiveInts(IReadOnlyList<object> entries) {
int? previous = null;
foreach (object entry in entries) {
if (EntryValue(entry) is not int value) return false;
if (previous.HasValue && value != previous.Value + 1) return false;
previous = value;
}
return previous.HasValue;
}

internal static bool LooksNumeric(string value) {
string text = value.Trim();
if (double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands,
CultureInfo.InvariantCulture, out double parsed) && double.IsFinite(parsed))
return true;
int index = text.StartsWith('-') || text.StartsWith('+') ? 1 : 0;
bool digits = false;
while (index < text.Length && char.IsDigit(text[index])) {
index++;
digits = true;
}
if (!digits) return false;
if (index < text.Length && text[index] == '.') {
index++;
int fractionStart = index;
while (index < text.Length && char.IsDigit(text[index])) index++;
if (index == fractionStart) return false;
}
// Try to parse a short unit suffix ["1x", "80%", "10 fps"],
// but reject notes ["1 (recommended)"] and compound labels ["1280×720"].
// This is based on heuristics and not guaranteed to be accurate.
string suffix = text[index..].Trim();
return suffix.Length == 0 || suffix is "%" or "×"
|| suffix.Length <= 6 && suffix.All(char.IsLetter);
}

internal static object? EntryValue(object entry) =>
entry.GetType().GetProperty("Item2")?.GetValue(entry)
?? entry.GetType().GetField("Item2")?.GetValue(entry);
}
16 changes: 16 additions & 0 deletions Tests/ModOptions.Integration/ModOptions.Integration.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<CelesteRoot Condition="'$(CelesteRoot)' == ''">$(CELESTE_ROOT)</CelesteRoot>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../Source/MicroblocksQolUtils.csproj" />
<Reference Include="Celeste"><HintPath>$(CelesteRoot)/Celeste.dll</HintPath></Reference>
<Reference Include="FNA"><HintPath>$(CelesteRoot)/FNA.dll</HintPath></Reference>
<Content Include="$(CelesteRoot)/*.dll" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
91 changes: 91 additions & 0 deletions Tests/ModOptions.Integration/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System.Reflection;
using Celeste;
using Celeste.Mod.MicroblocksQolUtils;

int assertions = 0;
void Check(bool condition, string message) {
assertions++;
if (!condition) throw new Exception(message);
}

const BindingFlags PrivateStatic = BindingFlags.Static | BindingFlags.NonPublic;
Type optionsType = typeof(MaterialModOptions);
var tryGetOption = optionsType.GetMethod("TryGetOption", PrivateStatic)!;

(string Kind, string[] Labels) Snapshot(TextMenu.Item item) {
object?[] arguments = [item, null];
Check((bool)tryGetOption.Invoke(null, arguments)!, $"no option snapshot: {item.GetType().Name}");
object snapshot = arguments[1]!;
Type snapshotType = snapshot.GetType();
string kind = snapshotType.GetProperty("Kind")!.GetValue(snapshot)!.ToString()!;
var options = (List<string>)snapshotType.GetProperty("Options")!.GetValue(snapshot)!;
return (kind, options.ToArray());
}

Check(Snapshot(new TextMenu.Option<bool>("toggle").Add("Off", false, true).Add("On", true)).Kind
== "Switch", "bool option should be a switch");
Check(Snapshot(new TextMenu.Option<string>("mode").Add("Off", "off", true).Add("On", "on")).Kind
== "Dropdown", "string option should be a dropdown");
Check(Snapshot(new TextMenu.Option<int>("int-range")
.Add("0", 0, true).Add("1", 1).Add("2", 2)).Kind
== "Range", "consecutive numeric int option should be a range");
Check(Snapshot(new TextMenu.Option<int>("int-sparse")
.Add("0", 0, true).Add("5", 5).Add("10", 10)).Kind
== "Dropdown", "sparse int option should be a dropdown");
Check(Snapshot(new TextMenu.Option<int>("int-named")
.Add("Low", 0, true).Add("Mid", 1).Add("High", 2)).Kind
== "Dropdown", "named int option should be a dropdown");
Check(Snapshot(new TextMenu.Slider("slider", value => value.ToString(), 0, 2)).Kind
== "Range", "numeric slider should be a range");
Check(Snapshot(new TextMenu.Slider("named-slider",
value => new[] { "Low", "Mid", "High" }[value], 0, 2)).Kind
== "Dropdown", "named slider should be a dropdown");

// The cached classification must follow a change handler that is attached
// after the item has already been shown (Everest exposes Change() on the item).
var enumProperty = typeof(TestModeHolder).GetProperty(nameof(TestModeHolder.Value))!;
var intProperty = typeof(TestIntHolder).GetProperty(nameof(TestIntHolder.Value))!;

var lateEnum = new TextMenu.Slider("late-enum", value => value.ToString(), 0, 2);
Check(Snapshot(lateEnum).Kind == "Range", "numeric slider should start as a range");
lateEnum.OnValueChange = new Action<int>(new PropertyHolder { Property = enumProperty }.Apply);
Check(Snapshot(lateEnum).Kind == "Dropdown", "cached kind did not follow a late enum change handler");

var lateInt = new TextMenu.Slider("late-int", value => value.ToString(), 0, 2);
lateInt.OnValueChange = new Action<int>(new PropertyHolder { Property = intProperty }.Apply);
Check(Snapshot(lateInt).Kind == "Range", "int-backed slider should stay a range");

// Values can also be mutated in place; labels and kind must follow on the next read.
var mutable = new TextMenu.Slider("mutable", value => value.ToString(), 0, 2);
Check(Snapshot(mutable).Kind == "Range", "mutable slider should start as a range");
mutable.Values[0] = Tuple.Create("Low", 0);
Check(Snapshot(mutable).Labels[0] == "Low", "cached labels did not follow a replaced value");
Check(Snapshot(mutable).Kind == "Dropdown", "cached kind did not follow a replaced value");

mutable.Values.Clear();
mutable.Values.Add(Tuple.Create("Off", 0));
mutable.Values.Add(Tuple.Create("On", 1));
(string Kind, string[] Labels) refreshed = Snapshot(mutable);
Check(refreshed.Kind == "Dropdown", "cached kind did not follow a resized value list");
Check(string.Join(',', refreshed.Labels) == "Off,On", "cached labels did not follow a resized value list");

Console.WriteLine($"ModOptions integration tests passed ({assertions} assertions)");

enum TestMode {
Off,
On,
Auto
}

static class TestModeHolder {
public static TestMode Value { get; set; }
}

static class TestIntHolder {
public static int Value { get; set; }
}

sealed class PropertyHolder {
public PropertyInfo? Property;
public void Apply(int value) { }
}
12 changes: 12 additions & 0 deletions Tests/ModOptions/ModOptions.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<Compile Include="../../Source/OptionRules.cs" Link="OptionRules.cs" />
</ItemGroup>
</Project>
86 changes: 86 additions & 0 deletions Tests/ModOptions/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System.Reflection;
using Celeste.Mod.MicroblocksQolUtils;

int assertions = 0;
void Check(bool condition, string message) {
assertions++;
if (!condition) throw new Exception(message);
}

List<object> Entries(params (string Label, int Value)[] pairs) =>
pairs.Select(pair => (object)Tuple.Create(pair.Label, pair.Value)).ToList();

foreach (string value in new[] {
"0", "42", "-5", "+7", "1.5", "1,000", "1e3", "1x", "2×", "80%", "10 fps", "1.5 ms"
}) {
Check(OptionRules.LooksNumeric(value), $"expected a numeric label: \"{value}\"");
}
foreach (string value in new[] {
"", " ", "-", "+", "1.2.3", "1 (recommended)", "1280×720", "low", "NaN", "Infinity"
}) {
Check(!OptionRules.LooksNumeric(value), $"expected a non-numeric label: \"{value}\"");
}

Check(OptionRules.EntryValue(Tuple.Create("label", 7)) is 7, "EntryValue should read Item2");

Check(OptionRules.Classify(true, false, false, null, [], []) == OptionKind.Switch,
"bool option should be a switch");
Check(OptionRules.Classify(false, false, false, null, ["Off", "On"], []) == OptionKind.Dropdown,
"non-int option should be a dropdown");
Check(OptionRules.Classify(false, false, true, null, ["0", "1", "2"],
Entries(("0", 0), ("1", 1), ("2", 2))) == OptionKind.Range,
"consecutive numeric int option should be a range");
Check(OptionRules.Classify(false, false, true, null, ["0", "5", "10"],
Entries(("0", 0), ("5", 5), ("10", 10))) == OptionKind.Dropdown,
"sparse int option should be a dropdown");
Check(OptionRules.Classify(false, false, true, null, ["Low", "Mid", "High"],
Entries(("Low", 0), ("Mid", 1), ("High", 2))) == OptionKind.Dropdown,
"named int option should be a dropdown");
Check(OptionRules.Classify(false, true, true, null, ["0", "1", "2"],
Entries(("0", 0), ("1", 1), ("2", 2))) == OptionKind.Range,
"numeric slider with an unknown backing should be a range");
Check(OptionRules.Classify(false, true, true, false, ["Low", "Mid"],
Entries(("Low", 0), ("Mid", 1))) == OptionKind.Range,
"int-backed slider should be a range");
Check(OptionRules.Classify(false, true, true, true, ["0", "1", "2"],
Entries(("0", 0), ("1", 1), ("2", 2))) == OptionKind.Dropdown,
"enum-backed slider should be a dropdown");
Check(OptionRules.Classify(false, false, true, true, ["Low", "Mid"],
Entries(("Low", 0), ("Mid", 1))) == OptionKind.Dropdown,
"enum-backed int option should be a dropdown");

var enumProperty = typeof(TestModeHolder).GetProperty(nameof(TestModeHolder.Value))!;
var intProperty = typeof(TestIntHolder).GetProperty(nameof(TestIntHolder.Value))!;
var enumChange = new Action<int>(new PropertyHolder { EnumProperty = enumProperty }.Apply);
var intChange = new Action<int>(new PropertyHolder { IntProperty = intProperty }.Apply);
var mixedChange = new Action<int>(new PropertyHolder {
EnumProperty = enumProperty,
IntProperty = intProperty
}.Apply);

Check(OptionRules.ChangeTargetsEnum(null) is null, "a missing handler should be unknown");
Check(OptionRules.ChangeTargetsEnum(enumChange) is true, "enum property should be detected");
Check(OptionRules.ChangeTargetsEnum(intChange) is false, "int property should be detected");
Check(OptionRules.ChangeTargetsEnum(mixedChange) is null, "mixed captures should be ambiguous");

Console.WriteLine($"OptionRules tests passed ({assertions} assertions)");

enum TestMode {
Off,
On,
Auto
}

static class TestModeHolder {
public static TestMode Value { get; set; }
}

static class TestIntHolder {
public static int Value { get; set; }
}

sealed class PropertyHolder {
public PropertyInfo? EnumProperty;
public PropertyInfo? IntProperty;
public void Apply(int value) { }
}
11 changes: 11 additions & 0 deletions scripts/build-qol-mod.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const argumentValue = (name) => {
};
const target = argumentValue("--target");
const disableFfmpeg = process.argv.includes("--no-ffmpeg");
const disableTests = process.argv.includes("--no-tests");
const integrationTests = process.argv.includes("--integration-tests");
if (disableFfmpeg && process.argv.includes("--install")) {
throw new Error("Cannot install a --no-ffmpeg build: it cannot record videos. Remove --no-ffmpeg to build and install the complete mod.");
}
Expand Down Expand Up @@ -108,6 +110,15 @@ if (!existsSync(nativeOutput)) {
throw new Error(`The native library was not produced at ${nativeOutput}`);
}
run("dotnet", ["build", resolve(root, "Source/MicroblocksQolUtils.csproj"), "-c", "Release"]);
if (!disableTests) {
const testEnvironment = { ...process.env, CELESTE_ROOT: celesteRoot };
const projects = ["Tests/ModOptions/ModOptions.csproj"];
if (integrationTests) projects.push("Tests/ModOptions.Integration/ModOptions.Integration.csproj");
for (const project of projects) {
console.log(`Testing ${project}`);
run("dotnet", ["run", "--project", resolve(root, project), "-c", "Release"], testEnvironment);
}
}
rmSync(output, { recursive: true, force: true });
mkdirSync(resolve(output, "Code"), { recursive: true });
cpSync(dll, resolve(output, "Code/MicroblocksQolUtils.dll"));
Expand Down
Loading