diff --git a/.github/skills/reactiveproperty-maintainer/SKILL.md b/.github/skills/reactiveproperty-maintainer/SKILL.md new file mode 100644 index 00000000..2a242a59 --- /dev/null +++ b/.github/skills/reactiveproperty-maintainer/SKILL.md @@ -0,0 +1,34 @@ +--- +name: reactiveproperty-maintainer +description: >- + Repository-specific guidance for maintaining ReactiveProperty source, tests, + documentation, samples, and release artifacts. Use before making changes in + the runceel/ReactiveProperty repository. +user-invocable: true +--- + +# ReactiveProperty maintainer + +Use this skill when changing ReactiveProperty source code, tests, samples, documentation, packaging, or release notes. + +## Repository orientation + +- Main source code is under `Source/`. +- Tests are under `Test/`. +- User documentation is under `docs/docs/` and is built with VuePress from the `docs/` directory. +- Samples are under `Samples/` and snippets are under `Snippet/`. +- The repository contains English and Japanese top-level README and release note files. + +## Maintenance guidance + +- Preserve public API compatibility unless the task explicitly requires a breaking change. +- Keep target framework and package changes aligned with existing project files. +- Update documentation when behavior, supported platforms, package usage, or samples change. +- For decisions that affect project structure, compatibility, packaging, or repeated maintenance work, add an ADR under `docs/docs/adr/`. +- Prefer small, focused changes and avoid unrelated cleanup. + +## Validation guidance + +- For documentation-only changes, build the documentation from `docs/` with `npm run docs:build` when practical. +- For source or test changes, run the relevant .NET build and tests for the affected solution or project. +- Scan changed files for secrets before committing. diff --git a/Directory.Packages.props b/Directory.Packages.props index c4e6dd76..0620df43 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,8 @@ + + diff --git a/ReactiveProperty.slnx b/ReactiveProperty.slnx index 41cbe4c0..361d4198 100644 --- a/ReactiveProperty.slnx +++ b/ReactiveProperty.slnx @@ -64,6 +64,18 @@ + + + + + + + + + + + + @@ -79,6 +91,9 @@ + + + diff --git a/Source/ReactiveProperty.R3.Compatibility.Collections/ReactiveProperty.R3.Compatibility.Collections.csproj b/Source/ReactiveProperty.R3.Compatibility.Collections/ReactiveProperty.R3.Compatibility.Collections.csproj new file mode 100644 index 00000000..40c915aa --- /dev/null +++ b/Source/ReactiveProperty.R3.Compatibility.Collections/ReactiveProperty.R3.Compatibility.Collections.csproj @@ -0,0 +1,13 @@ + + + ReactiveProperty.R3.Compatibility.Collections + net8.0;net9.0 + ReactiveProperty.R3.Compatibility.Collections + Minimal R3 collection compatibility APIs for ReactiveProperty migration. + + + + + + + diff --git a/Source/ReactiveProperty.R3.Compatibility.Collections/ReadOnlyReactiveCollectionCompat.cs b/Source/ReactiveProperty.R3.Compatibility.Collections/ReadOnlyReactiveCollectionCompat.cs new file mode 100644 index 00000000..ae7983c6 --- /dev/null +++ b/Source/ReactiveProperty.R3.Compatibility.Collections/ReadOnlyReactiveCollectionCompat.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; +using R3; +using Reactive.Bindings.R3Compat; + +namespace Reactive.Bindings.R3Compat.Collections; + +public sealed class ReadOnlyReactiveCollectionCompat : ReadOnlyObservableCollection, IDisposable +{ + private readonly ObservableCollection _source; + private readonly IDisposable _subscription; + private readonly bool _disposeElement; + private bool _isDisposed; + + private ReadOnlyReactiveCollectionCompat(ObservableCollection source, IDisposable subscription, bool disposeElement) + : base(source) + { + _source = source; + _subscription = subscription; + _disposeElement = disposeElement; + CompatibilityTelemetry.Track("R3Compat.Collections.ReadOnlyReactiveCollectionCompat", ".ctor"); + } + + public static ReadOnlyReactiveCollectionCompat Create( + Observable> source, + Func converter, + bool disposeElement = true) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(converter); + + var collection = new ObservableCollection(); + IDisposable? subscription = null; + var result = new ReadOnlyReactiveCollectionCompat( + collection, + new DeferredDisposable(() => subscription), + disposeElement); + subscription = source.Subscribe(result.Apply(converter)); + return result; + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + _subscription.Dispose(); + if (_disposeElement) + { + foreach (var item in _source.OfType().ToArray()) + { + item.Dispose(); + } + } + } + + private Action> Apply(Func converter) => change => + { + if (_isDisposed) + { + return; + } + + switch (change.Action) + { + case NotifyCollectionChangedAction.Add: + var addIndex = NormalizeAddIndex(change.Index); + foreach (var item in change.Values.Select(converter)) + { + _source.Insert(addIndex++, item); + } + + break; + case NotifyCollectionChangedAction.Remove: + DisposeItem(_source[change.Index]); + _source.RemoveAt(change.Index); + break; + case NotifyCollectionChangedAction.Replace: + DisposeItem(_source[change.Index]); + _source[change.Index] = converter(change.Value!); + break; + case NotifyCollectionChangedAction.Move: + _source.Move(change.OldIndex, change.Index); + break; + case NotifyCollectionChangedAction.Reset: + foreach (var item in _source.ToArray()) + { + DisposeItem(item); + } + + _source.Clear(); + if (change.Source != null) + { + foreach (var item in change.Source.Select(converter)) + { + _source.Add(item); + } + } + + break; + } + }; + + private int NormalizeAddIndex(int index) => index < 0 || index > _source.Count ? _source.Count : index; + + private void DisposeItem(T item) + { + if (_disposeElement && item is IDisposable disposable) + { + disposable.Dispose(); + } + } + + private sealed class DeferredDisposable(Func getDisposable) : IDisposable + { + public void Dispose() => getDisposable()?.Dispose(); + } +} + +public sealed class CollectionChanged +{ + public static CollectionChanged Reset { get; } = new() { Action = NotifyCollectionChangedAction.Reset }; + + public static CollectionChanged ResetWithSource(IEnumerable? source) => new() + { + Action = NotifyCollectionChangedAction.Reset, + Source = source?.ToArray(), + }; + + public static CollectionChanged Add(int index, T value) => Add(index, new[] { value }); + + public static CollectionChanged Add(int index, IEnumerable values) => new() + { + Action = NotifyCollectionChangedAction.Add, + Index = index, + Values = values.ToArray(), + }; + + public static CollectionChanged Remove(int index, T value) => new() + { + Action = NotifyCollectionChangedAction.Remove, + Index = index, + Values = new[] { value }, + }; + + public static CollectionChanged Replace(int index, T value) => new() + { + Action = NotifyCollectionChangedAction.Replace, + Index = index, + Values = new[] { value }, + }; + + public static CollectionChanged Move(int oldIndex, int newIndex, T value) => new() + { + Action = NotifyCollectionChangedAction.Move, + OldIndex = oldIndex, + Index = newIndex, + Values = new[] { value }, + }; + + public IEnumerable? Source { get; init; } + + public IEnumerable Values { get; init; } = []; + + public T? Value => Values.FirstOrDefault(); + + public int Index { get; init; } + + public int OldIndex { get; init; } + + public NotifyCollectionChangedAction Action { get; init; } +} diff --git a/Source/ReactiveProperty.R3.Compatibility.Commands/AsyncReactiveCommandCompat.cs b/Source/ReactiveProperty.R3.Compatibility.Commands/AsyncReactiveCommandCompat.cs new file mode 100644 index 00000000..ad39906a --- /dev/null +++ b/Source/ReactiveProperty.R3.Compatibility.Commands/AsyncReactiveCommandCompat.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Input; +using R3; +using Reactive.Bindings.R3Compat; + +namespace Reactive.Bindings.R3Compat.Commands; + +public sealed class AsyncReactiveCommandCompat : ICommand, IDisposable +{ + private readonly IReactiveProperty _sharedCanExecute; + private readonly List> _actions = []; + private readonly List _subscriptions = []; + private readonly R3.ReactiveProperty _canExecuteState; + private bool _sourceCanExecute; + private bool _isDisposed; + + public AsyncReactiveCommandCompat(Observable? canExecuteSource = null) + : this(canExecuteSource, new CompatReactiveProperty(true)) + { + } + + public AsyncReactiveCommandCompat(IReactiveProperty sharedCanExecute) + : this(null, sharedCanExecute) + { + } + + private AsyncReactiveCommandCompat(Observable? canExecuteSource, IReactiveProperty sharedCanExecute) + { + _sharedCanExecute = sharedCanExecute; + _sourceCanExecute = canExecuteSource is null; + _canExecuteState = new R3.ReactiveProperty(CurrentCanExecute()); + _sharedCanExecute.PropertyChanged += SharedCanExecutePropertyChanged; + if (canExecuteSource != null) + { + _subscriptions.Add(canExecuteSource.Subscribe(value => + { + _sourceCanExecute = value; + PublishCanExecute(); + })); + } + + CompatibilityTelemetry.Track("R3Compat.Commands.AsyncReactiveCommandCompat", ".ctor"); + } + + public event EventHandler? CanExecuteChanged; + + public Observable CanExecute => _canExecuteState; + + public AsyncReactiveCommandCompat WithSubscribe(Func asyncAction) + { + ArgumentNullException.ThrowIfNull(asyncAction); + _actions.Add(asyncAction); + return this; + } + + public async ValueTask ExecuteAsync(T parameter) + { + if (!CurrentCanExecute()) + { + return; + } + + _sharedCanExecute.Value = false; + try + { + foreach (var action in _actions.ToArray()) + { + await action(parameter); + } + } + finally + { + if (!_isDisposed) + { + _sharedCanExecute.Value = true; + } + } + } + + bool ICommand.CanExecute(object? parameter) => CurrentCanExecute(); + + async void ICommand.Execute(object? parameter) => await ExecuteAsync((T)parameter!); + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + _sharedCanExecute.PropertyChanged -= SharedCanExecutePropertyChanged; + foreach (var subscription in _subscriptions) + { + subscription.Dispose(); + } + + _canExecuteState.Value = false; + _canExecuteState.Dispose(); + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + } + + private bool CurrentCanExecute() => !_isDisposed && _sharedCanExecute.Value && _sourceCanExecute; + + private void SharedCanExecutePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(IReactiveProperty.Value)) + { + PublishCanExecute(); + } + } + + private void PublishCanExecute() + { + var value = CurrentCanExecute(); + if (_canExecuteState.Value == value) + { + return; + } + + _canExecuteState.Value = value; + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + } +} diff --git a/Source/ReactiveProperty.R3.Compatibility.Commands/ReactiveCommandSlimCompat.cs b/Source/ReactiveProperty.R3.Compatibility.Commands/ReactiveCommandSlimCompat.cs new file mode 100644 index 00000000..34a363ff --- /dev/null +++ b/Source/ReactiveProperty.R3.Compatibility.Commands/ReactiveCommandSlimCompat.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Input; +using R3; +using Reactive.Bindings.R3Compat; + +namespace Reactive.Bindings.R3Compat.Commands; + +public sealed class ReactiveCommandSlimCompat : ICommand, IDisposable +{ + private readonly List> _actions = []; + private readonly List _subscriptions = []; + private bool _canExecute; + private bool _isDisposed; + + public ReactiveCommandSlimCompat(Observable? canExecuteSource = null) + { + _canExecute = canExecuteSource is null; + if (canExecuteSource != null) + { + _subscriptions.Add(canExecuteSource.Subscribe(value => + { + if (_canExecute == value) + { + return; + } + + _canExecute = value; + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + })); + } + + CompatibilityTelemetry.Track("R3Compat.Commands.ReactiveCommandSlimCompat", ".ctor"); + } + + public event EventHandler? CanExecuteChanged; + + public ReactiveCommandSlimCompat WithSubscribe(Action action) + { + ArgumentNullException.ThrowIfNull(action); + _actions.Add(action); + return this; + } + + public bool CanExecute(T parameter) => !_isDisposed && _canExecute; + + public void Execute(T parameter) + { + if (!CanExecute(parameter)) + { + return; + } + + foreach (var action in _actions.ToArray()) + { + action(parameter); + } + } + + bool ICommand.CanExecute(object? parameter) => CanExecute((T)parameter!); + + void ICommand.Execute(object? parameter) => Execute((T)parameter!); + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + foreach (var subscription in _subscriptions) + { + subscription.Dispose(); + } + + if (_canExecute) + { + _canExecute = false; + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + } + } +} diff --git a/Source/ReactiveProperty.R3.Compatibility.Commands/ReactiveProperty.R3.Compatibility.Commands.csproj b/Source/ReactiveProperty.R3.Compatibility.Commands/ReactiveProperty.R3.Compatibility.Commands.csproj new file mode 100644 index 00000000..7bb1f9b1 --- /dev/null +++ b/Source/ReactiveProperty.R3.Compatibility.Commands/ReactiveProperty.R3.Compatibility.Commands.csproj @@ -0,0 +1,12 @@ + + + ReactiveProperty.R3.Compatibility.Commands + net8.0;net9.0 + ReactiveProperty.R3.Compatibility.Commands + Minimal R3 command compatibility APIs for ReactiveProperty migration. + + + + + + diff --git a/Source/ReactiveProperty.R3.Compatibility.Core/CompatibilityOptions.cs b/Source/ReactiveProperty.R3.Compatibility.Core/CompatibilityOptions.cs new file mode 100644 index 00000000..c00d6b9c --- /dev/null +++ b/Source/ReactiveProperty.R3.Compatibility.Core/CompatibilityOptions.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace Reactive.Bindings.R3Compat; + +[Flags] +public enum CompatibilityMode +{ + None = 0, + DistinctUntilChanged = 1, + RaiseLatestValueOnSubscribe = 2, + IgnoreInitialValidationError = 4, + IgnoreException = 8, + Default = DistinctUntilChanged | RaiseLatestValueOnSubscribe, +} + +public sealed class CompatibilityOptions +{ + public CompatibilityMode Mode { get; init; } = CompatibilityMode.Default; + + public TimeProvider TimeProvider { get; init; } = TimeProvider.System; + + public bool IgnoreInitialValidationError => Mode.HasFlag(CompatibilityMode.IgnoreInitialValidationError); +} + +public static class CompatibilityTelemetry +{ + private static readonly ConcurrentDictionary Usage = new(); + private static readonly ConcurrentDictionary> UsageByCallSite = new(); + + public static void Track(string compatApiId, string callSite) + { + ArgumentException.ThrowIfNullOrWhiteSpace(compatApiId); + ArgumentException.ThrowIfNullOrWhiteSpace(callSite); + Usage.AddOrUpdate(compatApiId, 1, static (_, count) => count + 1); + UsageByCallSite.GetOrAdd(compatApiId, static _ => new ConcurrentDictionary()) + .AddOrUpdate(callSite, 1, static (_, count) => count + 1); + } + + public static IReadOnlyDictionary Snapshot() => new ReadOnlyDictionary(Usage.ToDictionary()); + + public static void WriteUsageReport(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(path, JsonSerializer.Serialize(Snapshot(), new JsonSerializerOptions { WriteIndented = true })); + } +} + +public interface IReactiveProperty : INotifyPropertyChanged +{ + T Value { get; set; } +} + +public sealed class CompatReactiveProperty : IReactiveProperty, IDisposable +{ + private T _value; + private bool _isDisposed; + + public CompatReactiveProperty(T value) => _value = value; + + public event PropertyChangedEventHandler? PropertyChanged; + + public T Value + { + get => _value; + set + { + if (_isDisposed || EqualityComparer.Default.Equals(_value, value)) + { + return; + } + + _value = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); + } + } + + public void Dispose() => _isDisposed = true; +} diff --git a/Source/ReactiveProperty.R3.Compatibility.Core/MigrationRuleEngine.cs b/Source/ReactiveProperty.R3.Compatibility.Core/MigrationRuleEngine.cs new file mode 100644 index 00000000..42679e5e --- /dev/null +++ b/Source/ReactiveProperty.R3.Compatibility.Core/MigrationRuleEngine.cs @@ -0,0 +1,58 @@ +using System; + +namespace Reactive.Bindings.R3Compat; + +public enum MigrationCategory +{ + Auto, + Compat, + Manual, +} + +public sealed record MigrationRule( + string RuleId, + string Symbol, + MigrationCategory Category, + bool RequiresCompatibilityLayer, + string DowngradeWhen, + string DowngradeTarget, + string Deprecation, + bool AutoFix = false, + string? Replacement = null); + +public sealed record MigrationContext(bool DowngradeWhenSatisfied); + +public sealed record MigrationClassification( + string RuleId, + MigrationCategory Category, + bool RequiresCompatibilityLayer, + string? Replacement, + string DowngradeWhen, + string Deprecation); + +public static class MigrationRuleEngine +{ + public static MigrationClassification Classify(MigrationRule rule, MigrationContext context) + { + ArgumentNullException.ThrowIfNull(rule); + + if (context.DowngradeWhenSatisfied) + { + return new MigrationClassification( + rule.RuleId, + MigrationCategory.Auto, + RequiresCompatibilityLayer: false, + rule.DowngradeTarget, + rule.DowngradeWhen, + rule.Deprecation); + } + + return new MigrationClassification( + rule.RuleId, + rule.Category, + rule.RequiresCompatibilityLayer, + rule.Replacement, + rule.DowngradeWhen, + rule.Deprecation); + } +} diff --git a/Source/ReactiveProperty.R3.Compatibility.Core/ReactiveProperty.R3.Compatibility.Core.csproj b/Source/ReactiveProperty.R3.Compatibility.Core/ReactiveProperty.R3.Compatibility.Core.csproj new file mode 100644 index 00000000..81a7a74a --- /dev/null +++ b/Source/ReactiveProperty.R3.Compatibility.Core/ReactiveProperty.R3.Compatibility.Core.csproj @@ -0,0 +1,11 @@ + + + ReactiveProperty.R3.Compatibility.Core + net8.0;net9.0 + ReactiveProperty.R3.Compatibility.Core + Minimal R3 migration compatibility core for ReactiveProperty. + + + + + diff --git a/Source/ReactiveProperty.R3.Compatibility.Validation/CompatValidatableProperty.cs b/Source/ReactiveProperty.R3.Compatibility.Validation/CompatValidatableProperty.cs new file mode 100644 index 00000000..0a8942b3 --- /dev/null +++ b/Source/ReactiveProperty.R3.Compatibility.Validation/CompatValidatableProperty.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Linq.Expressions; +using R3; +using Reactive.Bindings.R3Compat; + +namespace Reactive.Bindings.R3Compat.Validation; + +public sealed class CompatValidatableProperty : IDisposable, INotifyDataErrorInfo +{ + private readonly CompatibilityOptions _options; + private readonly BehaviorSubject _valueSource; + private readonly R3.ReactiveProperty _hasErrors; + private readonly List> _validators = []; + private readonly List _subscriptions = []; + private IReadOnlyList _errors = []; + private T _value; + private bool _isDisposed; + + public CompatValidatableProperty(T initialValue = default!, CompatibilityOptions? options = null) + { + _options = options ?? new CompatibilityOptions(); + _value = initialValue; + _valueSource = new BehaviorSubject(initialValue); + _hasErrors = new R3.ReactiveProperty(false); + CompatibilityTelemetry.Track("R3Compat.Validation.CompatValidatableProperty", ".ctor"); + } + + public event EventHandler? ErrorsChanged; + + public T Value + { + get => _value; + set + { + if (_isDisposed) + { + return; + } + + _value = value; + _valueSource.OnNext(value); + if (_validators.Count > 0) + { + ForceValidate(); + } + } + } + + public bool HasErrors => _errors.Count > 0; + + public Observable ObserveHasErrors => _hasErrors; + + public CompatValidatableProperty SetValidateNotifyError(Func validate) + { + ArgumentNullException.ThrowIfNull(validate); + _validators.Add(value => ToErrors(validate(value))); + if (!_options.IgnoreInitialValidationError) + { + ForceValidate(); + } + + return this; + } + + public CompatValidatableProperty SetValidateNotifyError(Func, Observable> validate) + { + ArgumentNullException.ThrowIfNull(validate); + _subscriptions.Add(validate(_valueSource).Subscribe(ApplyErrors)); + return this; + } + + public CompatValidatableProperty SetValidateAttribute(Expression>> selfSelector) + { + ArgumentNullException.ThrowIfNull(selfSelector); + var member = selfSelector.Body as MemberExpression; + if (member == null && selfSelector.Body is UnaryExpression unary) + { + member = unary.Operand as MemberExpression; + } + + var attributes = member?.Member.GetCustomAttributes(typeof(ValidationAttribute), inherit: true) + .OfType() + .ToArray() ?? []; + + _validators.Add(value => + { + var results = new List(); + var context = new ValidationContext(this) { MemberName = nameof(Value) }; + return Validator.TryValidateValue(value, context, results, attributes) + ? null + : results.Select(x => x.ErrorMessage ?? x.ToString()).ToArray(); + }); + + if (!_options.IgnoreInitialValidationError) + { + ForceValidate(); + } + + return this; + } + + public void ForceValidate() + { + var errors = _validators.SelectMany(x => ToObjects(x(Value))).ToArray(); + ApplyErrors(errors.Length == 0 ? null : errors); + } + + public void ForceNotify() + { + if (_isDisposed) + { + return; + } + + _valueSource.OnNext(Value); + ForceValidate(); + } + + public BindableReactiveProperty AsR3() => new(Value); + + public IEnumerable GetErrors(string? propertyName) + { + if (propertyName is null or nameof(Value) or "") + { + return _errors; + } + + return Array.Empty(); + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + foreach (var subscription in _subscriptions) + { + subscription.Dispose(); + } + + _valueSource.Dispose(); + _hasErrors.Dispose(); + } + + private static IEnumerable? ToErrors(string? error) => error is null ? null : new[] { error }; + + private static IEnumerable ToObjects(IEnumerable? errors) + { + if (errors == null) + { + return []; + } + + return errors.Cast().Where(x => x != null).ToArray(); + } + + private void ApplyErrors(IEnumerable? errors) + { + if (_isDisposed) + { + return; + } + + _errors = ToObjects(errors).ToArray(); + _hasErrors.Value = HasErrors; + ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value))); + } +} diff --git a/Source/ReactiveProperty.R3.Compatibility.Validation/ReactiveProperty.R3.Compatibility.Validation.csproj b/Source/ReactiveProperty.R3.Compatibility.Validation/ReactiveProperty.R3.Compatibility.Validation.csproj new file mode 100644 index 00000000..2899ee52 --- /dev/null +++ b/Source/ReactiveProperty.R3.Compatibility.Validation/ReactiveProperty.R3.Compatibility.Validation.csproj @@ -0,0 +1,12 @@ + + + ReactiveProperty.R3.Compatibility.Validation + net8.0;net9.0 + ReactiveProperty.R3.Compatibility.Validation + Minimal R3 validation compatibility APIs for ReactiveProperty migration. + + + + + + diff --git a/Test/ReactiveProperty.R3.Compatibility.Tests/CompatibilityTests.cs b/Test/ReactiveProperty.R3.Compatibility.Tests/CompatibilityTests.cs new file mode 100644 index 00000000..9ba20960 --- /dev/null +++ b/Test/ReactiveProperty.R3.Compatibility.Tests/CompatibilityTests.cs @@ -0,0 +1,171 @@ +using System; +using System.Threading.Tasks; +using System.Collections; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Windows.Input; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using R3; +using Reactive.Bindings.R3Compat; +using Reactive.Bindings.R3Compat.Collections; +using Reactive.Bindings.R3Compat.Commands; +using Reactive.Bindings.R3Compat.Validation; + +namespace ReactiveProperty.R3.Compatibility.Tests; + +[TestClass] +public sealed class CompatibilityTests +{ + [TestMethod] + public void IgnoreInitialValidationErrorSuppressesInitialError() + { + using var property = new CompatValidatableProperty("", new CompatibilityOptions + { + Mode = CompatibilityMode.Default | CompatibilityMode.IgnoreInitialValidationError, + }).SetValidateNotifyError(x => string.IsNullOrEmpty(x) ? "required" : null); + + Assert.IsFalse(property.HasErrors); + property.ForceValidate(); + CollectionAssert.AreEqual(new[] { "required" }, property.GetErrors("Value").Cast().ToArray()); + } + + [TestMethod] + public void StreamValidationAggregatesMultipleErrors() + { + using var property = new CompatValidatableProperty("") + .SetValidateNotifyError(values => values.Select(_ => (IEnumerable?)new[] { "required", "too short" })); + + property.Value = "x"; + + CollectionAssert.AreEqual(new[] { "required", "too short" }, property.GetErrors("Value").Cast().ToArray()); + } + + [TestMethod] + public void AttributeValidationUsesValueMemberContext() + { + var target = new AttributeValidationTarget(); + using var property = target.Name.SetValidateAttribute(() => target.Name); + + Assert.IsTrue(property.HasErrors); + CollectionAssert.AreEqual(new[] { "Name is required" }, property.GetErrors("Value").Cast().ToArray()); + } + + [TestMethod] + public async Task AsyncCommandDisablesCanExecuteWhileRunning() + { + var gate = new TaskCompletionSource(); + using var command = new AsyncReactiveCommandCompat().WithSubscribe(async _ => await gate.Task); + + var executing = command.ExecuteAsync(1); + + Assert.IsFalse(((ICommand)command).CanExecute(1)); + gate.SetResult(); + await executing; + Assert.IsTrue(((ICommand)command).CanExecute(1)); + } + + [TestMethod] + public async Task SharedCanExecuteLinksTwoAsyncCommands() + { + var shared = new CompatReactiveProperty(true); + var gate = new TaskCompletionSource(); + using var command1 = new AsyncReactiveCommandCompat(shared).WithSubscribe(async _ => await gate.Task); + using var command2 = new AsyncReactiveCommandCompat(shared).WithSubscribe(_ => ValueTask.CompletedTask); + + var executing = command1.ExecuteAsync(1); + + Assert.IsFalse(((ICommand)command1).CanExecute(1)); + Assert.IsFalse(((ICommand)command2).CanExecute(1)); + gate.SetResult(); + await executing; + Assert.IsTrue(((ICommand)command1).CanExecute(1)); + Assert.IsTrue(((ICommand)command2).CanExecute(1)); + } + + [TestMethod] + public async Task DisposedAsyncCommandIgnoresExecute() + { + var count = 0; + var command = new AsyncReactiveCommandCompat().WithSubscribe(_ => + { + count++; + return ValueTask.CompletedTask; + }); + command.Dispose(); + + await command.ExecuteAsync(1); + + Assert.AreEqual(0, count); + } + + [TestMethod] + public void CanExecuteSourceRaisesCanExecuteChanged() + { + var source = new global::R3.ReactiveProperty(false); + using var command = new ReactiveCommandSlimCompat(source).WithSubscribe(_ => { }); + var raised = 0; + command.CanExecuteChanged += (_, _) => raised++; + + source.Value = true; + + Assert.IsTrue(command.CanExecute(1)); + Assert.AreEqual(1, raised); + } + + [TestMethod] + public void CollectionAppliesAddAndResetProjection() + { + using var source = new Subject>(); + using var collection = ReadOnlyReactiveCollectionCompat.Create(source, x => $"#{x}", disposeElement: false); + + source.OnNext(CollectionChanged.Add(0, 1)); + source.OnNext(CollectionChanged.ResetWithSource(new[] { 2, 3 })); + + CollectionAssert.AreEqual(new[] { "#2", "#3" }, collection.ToArray()); + } + + [TestMethod] + public void CollectionDisposesElementsWhenEnabled() + { + using var source = new Subject>(); + using var collection = ReadOnlyReactiveCollectionCompat.Create(source, x => x); + var item = new DisposableItem(); + + source.OnNext(CollectionChanged.Add(0, item)); + collection.Dispose(); + + Assert.IsTrue(item.IsDisposed); + } + + [TestMethod] + public void RuleEngineDowngradesWhenPredicateMatches() + { + var rule = new MigrationRule( + "RP-VAL-001", + "ReactiveProperty.SetValidateNotifyError(stream)", + MigrationCategory.Compat, + RequiresCompatibilityLayer: true, + DowngradeWhen: "validator is synchronous and single-value", + DowngradeTarget: "BindableReactiveProperty.EnableValidation", + Deprecation: "remove when stream-validator usage == 0"); + + var result = MigrationRuleEngine.Classify(rule, new MigrationContext(DowngradeWhenSatisfied: true)); + + Assert.AreEqual(MigrationCategory.Auto, result.Category); + Assert.IsFalse(result.RequiresCompatibilityLayer); + Assert.AreEqual("BindableReactiveProperty.EnableValidation", result.Replacement); + } + + private sealed class AttributeValidationTarget + { + [Required(ErrorMessage = "Name is required")] + public CompatValidatableProperty Name { get; } = new(""); + } + + private sealed class DisposableItem : IDisposable + { + public bool IsDisposed { get; private set; } + public void Dispose() => IsDisposed = true; + } +} diff --git a/Test/ReactiveProperty.R3.Compatibility.Tests/ReactiveProperty.R3.Compatibility.Tests.csproj b/Test/ReactiveProperty.R3.Compatibility.Tests/ReactiveProperty.R3.Compatibility.Tests.csproj new file mode 100644 index 00000000..f2f20e48 --- /dev/null +++ b/Test/ReactiveProperty.R3.Compatibility.Tests/ReactiveProperty.R3.Compatibility.Tests.csproj @@ -0,0 +1,29 @@ + + + + net9.0 + ReactiveProperty.R3.Compatibility.Tests + ReactiveProperty.R3.Compatibility.Tests + true + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/docs/docs/.vuepress/config.js b/docs/docs/.vuepress/config.js index c4e14de6..3f917a02 100644 --- a/docs/docs/.vuepress/config.js +++ b/docs/docs/.vuepress/config.js @@ -39,6 +39,13 @@ module.exports = { { text: 'Work with other MVVM Frameworks', link: '/advanced/work-with-other-mvvm-framwork.html' }, ] }, + { + text: 'Development', + items: [ + { text: 'ADRs and Agent Skills', link: '/development/adr-and-agent-skills.html' }, + { text: 'ADR 0001', link: '/adr/0001-document-architecture-decisions-and-agent-skills.html' }, + ] + }, { text: 'Samples', link: '/samples.html' }, ], lastUpdated: true, diff --git a/docs/docs/README.md b/docs/docs/README.md index 342ce890..66f9745c 100644 --- a/docs/docs/README.md +++ b/docs/docs/README.md @@ -113,6 +113,11 @@ And learn the core features on following links. - [Commanding](features/Commanding.md) - [Collections](features/Collections.md) +Repository maintenance guidance is available on following links. + +- [ADRs and Agent Skills](development/adr-and-agent-skills.md) +- [ADR 0001: Document architecture decisions and agent skills](adr/0001-document-architecture-decisions-and-agent-skills.md) + ## NuGet packages diff --git a/docs/docs/adr/0001-document-architecture-decisions-and-agent-skills.md b/docs/docs/adr/0001-document-architecture-decisions-and-agent-skills.md new file mode 100644 index 00000000..df0b8f09 --- /dev/null +++ b/docs/docs/adr/0001-document-architecture-decisions-and-agent-skills.md @@ -0,0 +1,23 @@ +# 0001. Document architecture decisions and agent skills + +- Status: Accepted +- Date: 2026-06-20 + +## Context + +ReactiveProperty has documentation for users who consume the NuGet packages, but the repository did not have a place to record project-level architecture decisions. The repository also did not contain guidance that an AI agent can load as a reusable skill before changing the library, documentation, samples, or release artifacts. + +Without these files, future contributors and agents must infer decisions from source code, pull requests, and release history. That makes repeated maintenance tasks slower and increases the chance of inconsistent changes. + +## Decision + +Use Architecture Decision Records (ADRs) under `docs/docs/adr/` to capture project-level decisions that affect implementation, packaging, compatibility, or maintenance. Each ADR should include the decision status, date, context, decision, and consequences. + +Add repository documentation under `docs/docs/development/` that explains how to use ADRs and Agent Skills. Add a ReactiveProperty maintenance Agent Skill under `.github/skills/reactiveproperty-maintainer/SKILL.md` so agents have repository-specific guidance for future work. + +## Consequences + +- Contributors have a stable location for architecture and maintenance decisions. +- Agent tasks can reuse repository-specific guidance instead of rediscovering the same context. +- Documentation navigation must include the new development and ADR pages so they are discoverable. +- ADRs should be updated only by adding new records or superseding existing records; accepted ADRs should not be rewritten except to correct mistakes. diff --git a/docs/docs/development/adr-and-agent-skills.md b/docs/docs/development/adr-and-agent-skills.md new file mode 100644 index 00000000..72250421 --- /dev/null +++ b/docs/docs/development/adr-and-agent-skills.md @@ -0,0 +1,33 @@ +# ADRs and Agent Skills + +This repository keeps long-lived maintenance guidance in two places: + +- Architecture Decision Records (ADRs) in `docs/docs/adr/` +- Agent Skills in `.github/skills/` + +## Architecture Decision Records + +Use an ADR when a decision affects project structure, public API compatibility, packaging, target frameworks, documentation strategy, or recurring maintenance work. + +Each ADR should include: + +- Status +- Date +- Context +- Decision +- Consequences + +Accepted ADRs should remain stable. If a decision changes, create a new ADR that supersedes the old one instead of rewriting history. + +## Agent Skills + +Agent Skills provide reusable instructions for AI agents working in the repository. They should describe durable repository practices, validation expectations, and important paths. + +Add or update an Agent Skill when repository-specific guidance would help repeated maintenance tasks, for example: + +- choosing the right solution or package file +- updating documentation and samples consistently +- selecting validation commands +- avoiding unsupported target framework or API changes + +The ReactiveProperty maintenance skill is located at `.github/skills/reactiveproperty-maintainer/SKILL.md`.