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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Built with Unity 6.5

### Added

- Extensible shared plugin settings sections for paths and multiple mixer channels, with canonical plugin-owned values and provider validation for global edits.

- Smooth 100 ms master fades when opening and closing the Player pause menu, with rapid-toggle cancellation and independent sound previews.

- Player-owned stereo DSP with per-bus/output parametric EQ, linked soft-knee compression, a shared Signalsmith FDN room return, and final linked sample-peak limiting. Sound settings offer Reference, Desktop Room, Headphones and Night presets, live preview/Apply/Cancel, per-table overrides, meters and bounded preset copy/paste. Listening preferences stay outside `.vpe` tables; physical SSF remains a separate milestone.
Expand Down
88 changes: 87 additions & 1 deletion VisualPinball.Plugins.Test/GlobalSettingsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,93 @@ public void OnlyExplicitSupportedVisibleAndCompatibleFieldsAppear()
var found = PluginGlobalSettingBinding.Discover(new[] { first, second, unsupported, game, unbounded, absent, broken }, _store, "windows", PluginGlobalSections.VolumeMixer, diagnostics.Add);
Assert.That(found.Select(item => item.Id), Is.EqualTo(new[] { "second/value", "first/value" }));
Assert.That(diagnostics.Count, Is.EqualTo(3));
Assert.That(PluginGlobalSettingBinding.Discover(new[] { first }, _store, "windows", "unknown"), Is.Empty);
}
[Test]
public void PathsAcceptFilesAndFoldersWithoutConstructingPlugins()
{
var factory = new TestFactory("library") { Schema = new PluginSettingsSchema(1, new PluginSettingsGroup("", "",
new PluginField("root", "Archive folder", SettingKind.Folder) { GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.Paths) },
new PluginField("image", "Disk image", SettingKind.File) { GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.Paths) },
new PluginField("invalid", "Wrong type", SettingKind.Boolean) { GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.Paths) },
new PluginField("game", "Game folder", SettingKind.Folder) { Scope = ConfigurationScope.GameOnly, GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.Paths) })) };
var diagnostics = new List<string>();
Assert.That(PluginGlobalSettingBinding.Discover(new[] { factory }, _store, "windows", PluginGlobalSections.Paths, diagnostics.Add).Select(item => item.Field.Id), Is.EqualTo(new[] { "image", "root" }));
Assert.That(diagnostics.Count, Is.EqualTo(2));
Assert.That(factory.Constructions, Is.Zero);
}
[TestCase(-1)]
[TestCase(999)]
public void UnsupportedSectionsAreIgnoredWithoutHidingPluginSettings(int value)
{
var section = (PluginGlobalSections)value;
var future = new PluginField("future", "Future preference", SettingKind.Boolean) { GlobalSetting = new PluginGlobalSetting(section) };
var factory = Factory();
factory.Schema = new PluginSettingsSchema(1, new PluginSettingsGroup("", "", Volume(), future));
var diagnostics = new List<string>();
Assert.That(PluginGlobalSettingBinding.Discover(new[] { factory }, _store, "windows", section, diagnostics.Add), Is.Empty);
Assert.That(PluginGlobalSettingBinding.Discover(new[] { factory }, _store, "windows", PluginGlobalSections.VolumeMixer).Single().Field.Id, Is.EqualTo("value"));
Assert.That(factory.Schema.Fields, Does.Contain(future));
Assert.That(diagnostics, Is.Empty);
Assert.That(factory.Constructions, Is.Zero);
}
[Test]
public void MultipleMixerChannelsHaveDistinctDefaultLabels()
{
var first = Volume("music"); var second = new PluginField("speech", "Speech", SettingKind.Integer) {
Minimum = 0, Maximum = 100, GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.VolumeMixer) };
var factory = new TestFactory("audio") { Schema = new PluginSettingsSchema(1, new PluginSettingsGroup("", "", first, second)) };
var bindings = PluginGlobalSettingBinding.Discover(new[] { factory }, _store, "windows", PluginGlobalSections.VolumeMixer);
Assert.That(bindings.Select(binding => binding.Label).Distinct().Count(), Is.EqualTo(2));
}
[Test]
public void SharedPathEditsHonorProviderValidation()
{
var factory = new TestFactory("library") { Schema = new PluginSettingsSchema(1, new PluginSettingsGroup("", "",
new PluginField("root", "Folder", SettingKind.Folder) { Default = "", GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.Paths) })),
SettingsProvider = new RejectingPaths() };
var host = Host(factory);
var commit = host.CommitGlobalSettingAsync(factory, "root", new JValue("unsafe")); PluginHostTests.Run(host, commit);
Assert.That(commit.Result.Success, Is.False);
Assert.That(File.Exists(_store.SettingsPath(factory.Descriptor)), Is.False);
}
private sealed class RejectingPaths : IPluginSettingsProvider
{
public System.Threading.Tasks.Task<PluginChoiceCatalog> DiscoverAsync(string provider, PluginChoiceRequest request, CancellationToken cancellation)
=> System.Threading.Tasks.Task.FromResult(new PluginChoiceCatalog("", Array.Empty<PluginChoice>()));
public System.Threading.Tasks.Task<PluginActionResult> RunActionAsync(string action, PluginChoiceRequest request, CancellationToken cancellation)
=> System.Threading.Tasks.Task.FromResult(new PluginActionResult(false, ""));
public IReadOnlyList<PluginValidationIssue> Validate(PluginChoiceRequest request) => new[] { new PluginValidationIssue("root", "Rejected by provider") };
}
[Test]
public void SharedValidationRunsOnOwnerAndKeepsUnrelatedOfflinePreferencesEditable()
{
var owner = Thread.CurrentThread.ManagedThreadId; var calls = 0; var factory = Factory();
factory.SettingsProvider = new ValidationProvider(request => {
Assert.That(Thread.CurrentThread.ManagedThreadId, Is.EqualTo(owner)); calls++;
return new[] { new PluginValidationIssue("path", "The media drive is offline") };
});
var host = Host(factory); var commit = host.CommitGlobalSettingAsync(factory, "value", new JValue(37)); PluginHostTests.Run(host, commit);
Assert.That(commit.Result.Success, Is.True); Assert.That(calls, Is.EqualTo(2));
Assert.That(_store.Effective(factory.Descriptor, factory.Schema).Integer("value"), Is.EqualTo(37));
}
[Test]
public void SharedEditCannotIntroduceAnErrorReportedOnADependentField()
{
var factory = Factory(); factory.SettingsProvider = new ValidationProvider(request => request.Settings.Integer("value") > 50
? new[] { new PluginValidationIssue("path", "This value conflicts with the selected output") } : Array.Empty<PluginValidationIssue>());
var initial = new PluginSettingsSnapshot(1); initial.Set("value", 20); _store.Save(factory.Descriptor, factory.Schema, initial);
var host = Host(factory); var commit = host.CommitGlobalSettingAsync(factory, "value", new JValue(80)); PluginHostTests.Run(host, commit);
Assert.That(commit.Result.Success, Is.False); Assert.That(_store.Effective(factory.Descriptor, factory.Schema).Integer("value"), Is.EqualTo(20));
}
private sealed class ValidationProvider : IPluginSettingsProvider
{
private readonly Func<PluginChoiceRequest, IReadOnlyList<PluginValidationIssue>> _validate;
internal ValidationProvider(Func<PluginChoiceRequest, IReadOnlyList<PluginValidationIssue>> validate) => _validate = validate;
public IReadOnlyList<PluginValidationIssue> Validate(PluginChoiceRequest request) => _validate(request);
public System.Threading.Tasks.Task<PluginChoiceCatalog> DiscoverAsync(string provider, PluginChoiceRequest request, CancellationToken cancellation)
=> System.Threading.Tasks.Task.FromResult(new PluginChoiceCatalog("", Array.Empty<PluginChoice>()));
public System.Threading.Tasks.Task<PluginActionResult> RunActionAsync(string action, PluginChoiceRequest request, CancellationToken cancellation)
=> System.Threading.Tasks.Task.FromResult(new PluginActionResult(false, ""));
}
[Test]
public void OrdinaryDraftCannotOverwriteANewerMixerValueAndResetIsExplicit()
Expand Down
Loading
Loading