From a9f2e65afc3d63bfd6a8de0406d3d96d46c61933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Tue, 18 Feb 2025 14:07:27 +0200 Subject: [PATCH] Initial code --- src/.editorconfig | 304 ++++++++++++++++++ src/.runsettings | 18 ++ src/WeakEvent.Tests/WeakEvent.Tests.csproj | 27 ++ src/WeakEvent.Tests/WeakEventGenericTests.cs | 100 ++++++ .../WeakEventHandlerGenericTests.cs | 76 +++++ .../WeakEventHandlerNonGenericTests.cs | 75 +++++ .../WeakEventNonGenericTests.cs | 98 ++++++ src/WeakEvent.sln | 35 ++ src/WeakEvent/AssemblyInfo.cs | 3 + src/WeakEvent/PACKAGE.md | 160 +++++++++ src/WeakEvent/WeakEvent.cs | 162 ++++++++++ src/WeakEvent/WeakEvent.csproj | 46 +++ src/WeakEvent/WeakEventHandler.cs | 95 ++++++ 13 files changed, 1199 insertions(+) create mode 100644 src/.editorconfig create mode 100644 src/.runsettings create mode 100644 src/WeakEvent.Tests/WeakEvent.Tests.csproj create mode 100644 src/WeakEvent.Tests/WeakEventGenericTests.cs create mode 100644 src/WeakEvent.Tests/WeakEventHandlerGenericTests.cs create mode 100644 src/WeakEvent.Tests/WeakEventHandlerNonGenericTests.cs create mode 100644 src/WeakEvent.Tests/WeakEventNonGenericTests.cs create mode 100644 src/WeakEvent.sln create mode 100644 src/WeakEvent/AssemblyInfo.cs create mode 100644 src/WeakEvent/PACKAGE.md create mode 100644 src/WeakEvent/WeakEvent.cs create mode 100644 src/WeakEvent/WeakEvent.csproj create mode 100644 src/WeakEvent/WeakEventHandler.cs diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 0000000..b364e2a --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,304 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = tab +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Code Actions #### + +# Type members +dotnet_hide_advanced_members = false +dotnet_member_insertion_location = with_other_members_of_the_same_kind +dotnet_property_generation_behavior = prefer_auto_properties + +# Symbol search +dotnet_search_reference_assemblies = true + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:warning +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_property = false:warning + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:suggestion +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:suggestion + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +# Expression-level preferences +dotnet_prefer_system_hash_code = true +dotnet_style_coalesce_expression = true +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true:warning +dotnet_style_namespace_match_folder = true +dotnet_style_null_propagation = true +dotnet_style_object_initializer = true +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match +dotnet_style_prefer_compound_assignment = true +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed +dotnet_style_prefer_inferred_anonymous_type_member_names = false:silent +dotnet_style_prefer_inferred_tuple_names = false:silent +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true + +# Field preferences +dotnet_style_readonly_field = true + +# Parameter preferences +dotnet_code_quality_unused_parameters = non_public + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +# New line preferences +dotnet_style_allow_multiple_blank_lines_experimental = true +dotnet_style_allow_statement_immediately_after_block_experimental = true + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = true:suggestion +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:suggestion +csharp_style_expression_bodied_constructors = false:warning +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = when_on_single_line:silent +csharp_style_expression_bodied_operators = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = true:suggestion + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_anonymous_function = true:suggestion +csharp_prefer_static_local_function = true:suggestion +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion + +# Code-block preferences +csharp_prefer_braces = true:warning +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_system_threading_lock = true:suggestion +csharp_style_namespace_declarations = file_scoped:warning +csharp_style_prefer_method_group_conversion = true:suggestion +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_top_level_statements = true:silent + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:warning +csharp_style_deconstructed_variable_declaration = true:silent +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:error + +# New line preferences +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false:warning +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = false:warning +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = false:warning +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent +csharp_style_allow_embedded_statements_on_same_line_experimental = false:warning + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = error +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.private_static_property_field_should_be_begins_with__.severity = error +dotnet_naming_rule.private_static_property_field_should_be_begins_with__.symbols = private_static_property_field +dotnet_naming_rule.private_static_property_field_should_be_begins_with__.style = begins_with__ + +dotnet_naming_rule.private_property_field_should_be_begins_with__.severity = error +dotnet_naming_rule.private_property_field_should_be_begins_with__.symbols = private_property_field +dotnet_naming_rule.private_property_field_should_be_begins_with__.style = begins_with__ + +dotnet_naming_rule.types_should_be_pascal_case.severity = error +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = error +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, method, event +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.private_property_field.applicable_kinds = property, field +dotnet_naming_symbols.private_property_field.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_property_field.required_modifiers = + +dotnet_naming_symbols.private_static_property_field.applicable_kinds = property, field +dotnet_naming_symbols.private_static_property_field.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_property_field.required_modifiers = static + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.begins_with__.required_prefix = _ +dotnet_naming_style.begins_with__.required_suffix = +dotnet_naming_style.begins_with__.word_separator = +dotnet_naming_style.begins_with__.capitalization = camel_case + +[*.{cs,vb}] +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_explicit_tuple_names = true:warning +dotnet_style_prefer_inferred_tuple_names = false:silent +dotnet_style_prefer_inferred_anonymous_type_member_names = false:silent +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 +end_of_line = crlf +dotnet_style_readonly_field = true:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_allow_multiple_blank_lines_experimental = true:silent +dotnet_style_allow_statement_immediately_after_block_experimental = true:silent +dotnet_code_quality_unused_parameters = non_public:suggestion +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:suggestion +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning +dotnet_diagnostic.CA1040.severity = none +dotnet_diagnostic.CA1034.severity = none \ No newline at end of file diff --git a/src/.runsettings b/src/.runsettings new file mode 100644 index 0000000..4af084f --- /dev/null +++ b/src/.runsettings @@ -0,0 +1,18 @@ + + + + + + + + + True + True + True + False + + + + + + \ No newline at end of file diff --git a/src/WeakEvent.Tests/WeakEvent.Tests.csproj b/src/WeakEvent.Tests/WeakEvent.Tests.csproj new file mode 100644 index 0000000..c85678b --- /dev/null +++ b/src/WeakEvent.Tests/WeakEvent.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0;net6.0 + latest + enable + enable + false + ByteAether.WeakEvent.Tests + + + + + + + + + + + + + + + + + + diff --git a/src/WeakEvent.Tests/WeakEventGenericTests.cs b/src/WeakEvent.Tests/WeakEventGenericTests.cs new file mode 100644 index 0000000..f67a89d --- /dev/null +++ b/src/WeakEvent.Tests/WeakEventGenericTests.cs @@ -0,0 +1,100 @@ +namespace ByteAether.WeakEvent.Tests; + +public class WeakEventGenericTests +{ + [Fact] + public void Subscribe_NullHandler_ThrowsArgumentNullException() + { + var weakEvent = new WeakEvent(); + Assert.Throws(() => weakEvent.Subscribe(null!)); + } + + [Fact] + public void Unsubscribe_NullHandler_ThrowsArgumentNullException() + { + var weakEvent = new WeakEvent(); + Assert.Throws(() => weakEvent.Unsubscribe(null!)); + } + + [Fact] + public void Unsubscribe_NonExistentHandler_DoesNotThrow() + { + var weakEvent = new WeakEvent(); + Action handler = msg => { }; + var exception = Record.Exception(() => weakEvent.Unsubscribe(handler)); + Assert.Null(exception); + } + + [Fact] + public void Send_InvokesSubscribedHandler() + { + var weakEvent = new WeakEvent(); + var received = string.Empty; + weakEvent.Subscribe(msg => received = msg); + + weakEvent.Send("Hello"); + + Assert.Equal("Hello", received); + } + + [Fact] + public void Unsubscribe_RemovesHandler() + { + var weakEvent = new WeakEvent(); + var count = 0; + Action handler = msg => count++; + weakEvent.Subscribe(handler); + + weakEvent.Unsubscribe(handler); + weakEvent.Send("Test"); + + Assert.Equal(0, count); + } + + [Fact] + public void MultipleHandlers_AreInvoked() + { + var weakEvent = new WeakEvent(); + var count = 0; + weakEvent.Subscribe(i => count += i); + weakEvent.Subscribe(i => count += i * 2); + + weakEvent.Send(5); + + // Expected: 5 + 10 = 15 + Assert.Equal(15, count); + } + + [Fact] + public void DeadHandler_IsNotInvoked_AfterGarbageCollection() + { + var weakEvent = new WeakEvent(); + var callCount = 0; + CreateSubscriber(weakEvent, () => callCount++); + + // Force garbage collection to reclaim the subscriber instance. + GC.Collect(); + GC.WaitForPendingFinalizers(); + + weakEvent.Send("Test"); + + Assert.Equal(0, callCount); + } + + private static void CreateSubscriber(WeakEvent weakEvent, Action onEvent) + { + var subscriber = new GenericSubscriber(onEvent); + weakEvent.Subscribe(subscriber.Handler); + // The subscriber goes out of scope after this method, allowing it to be GC’d. + } + + private class GenericSubscriber(Action onEvent) + { + private readonly Action _onEvent = onEvent; + + public void Handler(string _) + { + _onEvent(); + } + } +} diff --git a/src/WeakEvent.Tests/WeakEventHandlerGenericTests.cs b/src/WeakEvent.Tests/WeakEventHandlerGenericTests.cs new file mode 100644 index 0000000..b0b7f31 --- /dev/null +++ b/src/WeakEvent.Tests/WeakEventHandlerGenericTests.cs @@ -0,0 +1,76 @@ +namespace ByteAether.WeakEvent.Tests; +public class WeakEventHandlerGenericTests +{ + [Fact] + public void StaticHandler_GetHandler_ReturnsHandlerAndMatches() + { + Action handler = StaticHandler; + var weh = new WeakEventHandler(handler); + + var retrieved = weh.GetHandler(); + Assert.NotNull(retrieved); + retrieved("Hello"); + Assert.Equal("Hello", _staticResult); + + Assert.True(weh.Matches(handler)); + + // Create a new delegate instance for the same static method. + Action newDelegate = StaticHandler; + Assert.True(weh.Matches(newDelegate)); + } + + private static string _staticResult = string.Empty; + + private static void StaticHandler(string msg) + { + _staticResult = msg; + } + + [Fact] + public void InstanceHandler_GetHandler_ReturnsHandlerAndMatches() + { + var instance = new InstanceTest(); + Action handler = instance.Handler; + var weh = new WeakEventHandler(handler); + + var retrieved = weh.GetHandler(); + Assert.NotNull(retrieved); + retrieved("World"); + Assert.Equal("World", instance.Result); + + Assert.True(weh.Matches(handler)); + + // Create a new delegate instance from the same instance method. + Action newDelegate = instance.Handler; + Assert.True(weh.Matches(newDelegate)); + } + + [Fact] + public void InstanceHandler_GetHandler_ReturnsNullAfterGC() + { + CreateWeakInstance(out var weh); + GC.Collect(); + GC.WaitForPendingFinalizers(); + // Give GC a moment to reclaim the instance. + Thread.Sleep(100); + var handler = weh.GetHandler(); + Assert.Null(handler); + } + + private static void CreateWeakInstance(out WeakEventHandler weh) + { + var instance = new InstanceTest(); + Action handler = instance.Handler; + weh = new WeakEventHandler(handler); + // instance goes out of scope after this method, allowing GC to reclaim it. + } + + private class InstanceTest + { + public string? Result { get; private set; } + public void Handler(string msg) + { + Result = msg; + } + } +} \ No newline at end of file diff --git a/src/WeakEvent.Tests/WeakEventHandlerNonGenericTests.cs b/src/WeakEvent.Tests/WeakEventHandlerNonGenericTests.cs new file mode 100644 index 0000000..4c41d3b --- /dev/null +++ b/src/WeakEvent.Tests/WeakEventHandlerNonGenericTests.cs @@ -0,0 +1,75 @@ +namespace ByteAether.WeakEvent.Tests; +public class WeakEventHandlerNonGenericTests +{ + [Fact] + public void StaticHandler_GetHandler_ReturnsHandlerAndMatches() + { + Action handler = StaticHandler; + var weh = new WeakEventHandler(handler); + + var retrieved = weh.GetHandler(); + Assert.NotNull(retrieved); + retrieved(); + Assert.True(_staticInvoked); + + Assert.True(weh.Matches(handler)); + + Action newDelegate = StaticHandler; + Assert.True(weh.Matches(newDelegate)); + } + + private static bool _staticInvoked = false; + + private static void StaticHandler() + { + _staticInvoked = true; + } + + [Fact] + public void InstanceHandler_GetHandler_ReturnsHandlerAndMatches() + { + var instance = new InstanceTest(); + Action handler = instance.Handler; + var weh = new WeakEventHandler(handler); + + var retrieved = weh.GetHandler(); + Assert.NotNull(retrieved); + retrieved(); + Assert.True(instance.Invoked); + + Assert.True(weh.Matches(handler)); + + Action newDelegate = instance.Handler; + Assert.True(weh.Matches(newDelegate)); + } + + [Fact] + public void InstanceHandler_GetHandler_ReturnsNullAfterGC() + { + CreateWeakInstance(out var weh); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + Thread.Sleep(100); + + var handler = weh.GetHandler(); + Assert.Null(handler); + } + + private static void CreateWeakInstance(out WeakEventHandler weh) + { + var instance = new InstanceTest(); + Action handler = instance.Handler; + weh = new WeakEventHandler(handler); + // instance goes out of scope after this method. + } + + private class InstanceTest + { + public bool Invoked { get; private set; } + public void Handler() + { + Invoked = true; + } + } +} \ No newline at end of file diff --git a/src/WeakEvent.Tests/WeakEventNonGenericTests.cs b/src/WeakEvent.Tests/WeakEventNonGenericTests.cs new file mode 100644 index 0000000..1afa4b6 --- /dev/null +++ b/src/WeakEvent.Tests/WeakEventNonGenericTests.cs @@ -0,0 +1,98 @@ +namespace ByteAether.WeakEvent.Tests; +public class WeakEventNonGenericTests +{ + [Fact] + public void Subscribe_NullHandler_ThrowsArgumentNullException() + { + var weakEvent = new WeakEvent(); + Assert.Throws(() => weakEvent.Subscribe(null!)); + } + + [Fact] + public void Unsubscribe_NullHandler_ThrowsArgumentNullException() + { + var weakEvent = new WeakEvent(); + Assert.Throws(() => weakEvent.Unsubscribe(null!)); + } + + [Fact] + public void Unsubscribe_NonExistentHandler_DoesNotThrow() + { + var weakEvent = new WeakEvent(); + Action handler = () => { }; + var exception = Record.Exception(() => weakEvent.Unsubscribe(handler)); + Assert.Null(exception); + } + + [Fact] + public void Send_InvokesSubscribedHandler() + { + var weakEvent = new WeakEvent(); + var invoked = false; + weakEvent.Subscribe(() => invoked = true); + + weakEvent.Send(); + + Assert.True(invoked); + } + + [Fact] + public void Unsubscribe_RemovesHandler() + { + var weakEvent = new WeakEvent(); + var count = 0; + Action handler = () => count++; + weakEvent.Subscribe(handler); + + weakEvent.Unsubscribe(handler); + weakEvent.Send(); + + Assert.Equal(0, count); + } + + [Fact] + public void MultipleHandlers_AreInvoked() + { + var weakEvent = new WeakEvent(); + var count = 0; + weakEvent.Subscribe(() => count += 1); + weakEvent.Subscribe(() => count += 2); + + weakEvent.Send(); + + Assert.Equal(3, count); + } + + [Fact] + public void DeadHandler_IsNotInvoked_AfterGarbageCollection() + { + var weakEvent = new WeakEvent(); + var callCount = 0; + CreateSubscriber(weakEvent, () => callCount++); + + // Force garbage collection to reclaim the subscriber instance. + GC.Collect(); + GC.WaitForPendingFinalizers(); + + weakEvent.Send(); + + Assert.Equal(0, callCount); + } + + private static void CreateSubscriber(WeakEvent weakEvent, Action onEvent) + { + var subscriber = new NonGenericSubscriber(onEvent); + weakEvent.Subscribe(subscriber.Handler); + // The subscriber goes out of scope after this method, allowing it to be GC’d. + } + + private class NonGenericSubscriber(Action onEvent) + { + private readonly Action _onEvent = onEvent; + + public void Handler() + { + _onEvent(); + } + } +} diff --git a/src/WeakEvent.sln b/src/WeakEvent.sln new file mode 100644 index 0000000..c17eefa --- /dev/null +++ b/src/WeakEvent.sln @@ -0,0 +1,35 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35527.113 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WeakEvent", "WeakEvent\WeakEvent.csproj", "{7A7916C6-1FB8-427C-B339-FCA8830CC8E9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WeakEvent.Tests", "WeakEvent.Tests\WeakEvent.Tests.csproj", "{08C7EA2E-ACA8-4501-BBF4-577495BDFA96}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E60FE10B-D00E-45AF-B3C9-5AFE96381753}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + ..\README.md = ..\README.md + .runsettings = .runsettings + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7A7916C6-1FB8-427C-B339-FCA8830CC8E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A7916C6-1FB8-427C-B339-FCA8830CC8E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A7916C6-1FB8-427C-B339-FCA8830CC8E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A7916C6-1FB8-427C-B339-FCA8830CC8E9}.Release|Any CPU.Build.0 = Release|Any CPU + {08C7EA2E-ACA8-4501-BBF4-577495BDFA96}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08C7EA2E-ACA8-4501-BBF4-577495BDFA96}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08C7EA2E-ACA8-4501-BBF4-577495BDFA96}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08C7EA2E-ACA8-4501-BBF4-577495BDFA96}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/WeakEvent/AssemblyInfo.cs b/src/WeakEvent/AssemblyInfo.cs new file mode 100644 index 0000000..635fbbf --- /dev/null +++ b/src/WeakEvent/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("WeakEvent.Tests")] \ No newline at end of file diff --git a/src/WeakEvent/PACKAGE.md b/src/WeakEvent/PACKAGE.md new file mode 100644 index 0000000..628573a --- /dev/null +++ b/src/WeakEvent/PACKAGE.md @@ -0,0 +1,160 @@ +# ByteAether ULID + +A high-performance .NET implementation of ULIDs (Universally Unique Lexicographically Sortable Identifiers) that fully complies with the [official ULID specification](https://github.com/ulid/spec). + +For more detailed documentation, visit our [GitHub repository](https://github.com/ByteAether/Ulid). + +## Features + +![.NET 9.0](https://img.shields.io/badge/.NET-9.0-brightgreen) +![.NET 8.0](https://img.shields.io/badge/.NET-8.0-brightgreen) +![.NET 7.0](https://img.shields.io/badge/.NET-7.0-green) +![.NET 6.0](https://img.shields.io/badge/.NET-6.0-green) +![.NET 5.0](https://img.shields.io/badge/.NET-5.0-yellow) +![.NET Standard 2.1](https://img.shields.io/badge/.NET-Standard_2.1-yellow) +![.NET Standard 2.0](https://img.shields.io/badge/.NET-Standard_2.0-green) + +- **Universally Unique**: Ensures global uniqueness across systems. +- **Sortable**: Lexicographically ordered for time-based sorting. +- **Fast and Efficient**: Optimized for high performance and low memory usage. +- **Specification-Compliant**: Fully adheres to the ULID specification. +- **Interoperable**: Includes conversion methods to and from GUIDs, [Crockford's Base32](https://www.crockford.com/base32.html) strings, and byte arrays. +- **Error-Free Generation**: Prevents overflow exceptions by incrementing timestamps during random part overflow. + +## Installation + +Install the latest stable package via NuGet: + +```sh +dotnet add package ByteAether.Ulid +``` + +Use the `--version` option to specify a [preview version](https://www.nuget.org/packages/ByteAether.Ulid/absoluteLatest) to install. + +## Usage + +Here is a basic example of how to use the ULID implementation: + +```csharp +using System; + +class Program +{ + static void Main() + { + // Create a new ULID + var ulid = Ulid.New(); + + // Convert to byte array and back + byte[] byteArray = ulid.ToByteArray(); + var ulidFromByteArray = Ulid.New(byteArray); + + // Convert to GUID and back + Guid guid = ulid.ToGuid(); + var ulidFromGuid = Ulid.New(guid); + + // Convert to string and back + string ulidString = ulid.ToString(); + var ulidFromString = Ulid.Parse(ulidString); + + Console.WriteLine($"ULID: {ulid}, GUID: {guid}, String: {ulidString}"); + } +} +``` + +## API + +The `Ulid` implementation provides the following properties and methods: + +### Creation + +- `Ulid.New(bool isMonotonic = true)`\ +Generates a new ULID. If `isMonotonic` is `true`, ensures monotonicity during timestamp collisions. +- `Ulid.New(DateTimeOffset dateTimeOffset, bool isMonotonic = true)`\ +Generates a new ULID using the specified `DateTimeOffset`. +- `Ulid.New(long timestamp, bool isMonotonic = true)`\ +Generates a new ULID using the specified Unix timestamp in milliseconds (`long`). +- `Ulid.New(DateTimeOffset dateTimeOffset, Span random)`\ +Generates a new ULID using the specified `DateTimeOffset` and a pre-existing random byte array. +- `Ulid.New(long timestamp, Span random)`\ +Generates a new ULID using the specified Unix timestamp in milliseconds (`long`) and a pre-existing random byte array. +- `Ulid.New(ReadOnlySpan bytes)`\ +Creates a ULID from an existing byte array. +- `Ulid.New(Guid guid)`\ +Create from existing `Guid`. + +### Checking Validity + +- `Ulid.IsValid(string ulidString)`\ +Validates if the given string is a valid ULID. +- `Ulid.IsValid(ReadOnlySpan ulidString)`\ +Validates if the given span of characters is a valid ULID. +- `Ulid.IsValid(ReadOnlySpan ulidBytes)`\ +Validates if the given byte array represents a valid ULID. + +### Parsing + +- `Ulid.Parse(ReadOnlySpan chars, IFormatProvider? provider = null)`\ +Parses a ULID from a character span in canonical format. The `IFormatProvider` is ignored. +- `Ulid.TryParse(ReadOnlySpan s, IFormatProvider? provider, out Ulid result)`\ +Tries to parse a ULID from a character span in canonical format. Returns `true` if successful. +- `Ulid.Parse(string s, IFormatProvider? provider = null)`\ +Parses a ULID from a string in canonical format. The `IFormatProvider` is ignored. +- `Ulid.TryParse(string? s, IFormatProvider? provider, out Ulid result)`\ +Tries to parse a ULID from a string in canonical format. Returns `true` if successful. + +### Properties + +- `.Time`\ +Gets the timestamp component of the ULID as a `DateTimeOffset`. +- `.TimeBytes`\ +Gets the time component of the ULID as a `ReadOnlySpan`. +- `.Random`\ +Gets the random component of the ULID as a `ReadOnlySpan`. + +### Conversion Methods + +- `.AsByteSpan()`\ +Provides a `ReadOnlySpan` representing the contents of the ULID. +- `.ToByteArray()`\ +Converts the ULID to a byte array. +- `.ToGuid()`\ +Converts the ULID to a `Guid`. +- `.ToString(string? format = null, IFormatProvider? formatProvider = null)`\ +Converts the ULID to a canonical string representation. Format arguments are ignored. + +### Comparison Operators + +- Supports all comparison operators:\ +`==`, `!=`, `<`, `<=`, `>`, `>=`. +- Implements standard comparison and equality methods:\ +`CompareTo`, `Equals`, `GetHashCode`. +- Provides implicit operators to and from `Guid`. + +## Integration with Other Libraries + +### ASP.NET Core + +Supports seamless integration as a route or query parameter with built-in `TypeConverter`. + +### System.Text.Json + +Includes a `JsonConverter` for easy serialization and deserialization. + +### Other Libraries + +Check out [README in GitHub repository](https://github.com/ByteAether/Ulid/blob/main/README.md) for examples to integrate with Entity Framework Core, Dapper, MessagePack and Newtonsoft.Json. + +## Prior Art + +Much of this implementation is either based on or inspired by existing works. This library is standing on the shoulders of giants. + + * [NetUlid](https://github.com/ultimicro/netulid) + * [Ulid](https://github.com/Cysharp/Ulid) + * [NUlid](https://github.com/RobThree/NUlid) + * [Official ULID specification](https://github.com/ulid/spec) + * [Crockford's Base32](https://www.crockford.com/base32.html) + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. \ No newline at end of file diff --git a/src/WeakEvent/WeakEvent.cs b/src/WeakEvent/WeakEvent.cs new file mode 100644 index 0000000..6be4157 --- /dev/null +++ b/src/WeakEvent/WeakEvent.cs @@ -0,0 +1,162 @@ +namespace ByteAether.WeakEvent; + +/// +/// Represents a weak event that stores its subscribers using weak references. +/// Subscribers register an and when an event is raised, +/// only those whose target is still alive will be invoked. +/// +/// The type of the event arguments. +public class WeakEvent +{ + private readonly List> _handlers = []; + +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + + /// + /// Subscribes the specified handler to the event. + /// + /// + /// The handler to subscribe. It will be invoked when the event is raised, + /// provided that the target is still alive. + /// + /// Thrown when is null. + public void Subscribe(Action handler) + { + ArgumentNullException.ThrowIfNull(handler); + + _handlers.Add(new WeakEventHandler(handler)); + } + + /// + /// Unsubscribes the specified handler from the event. + /// + /// The handler to unsubscribe. + /// Thrown when is null. + public void Unsubscribe(Action handler) + { + ArgumentNullException.ThrowIfNull(handler); + + _handlers.RemoveAll(weh => weh.Matches(handler)); + } + + /// + /// Raises the event, invoking all live subscribers with the provided event data. + /// Dead subscribers (whose targets have been garbage-collected) are removed. + /// + /// The event data to send to the subscribers. + public void Send(TEvent eventData) + { + List>? deadHandlers = null; + + lock (_lock) + { + foreach (var weakHandler in _handlers) + { + var handler = weakHandler.GetHandler(); + if (handler != null) + { + // Invoke the live handler. + handler(eventData); + } + else + { + // Remember handlers whose target has been garbage-collected. + deadHandlers ??= []; + deadHandlers.Add(weakHandler); + } + } + + // Remove dead handlers from the list. + if (deadHandlers != null) + { + foreach (var dead in deadHandlers) + { + _handlers.Remove(dead); + } + } + } + } +} + +/// +/// Represents a weak event that stores its subscribers using weak references. +/// Subscribers register an and when an event is raised, +/// only those whose target is still alive will be invoked. +/// +public class WeakEvent +{ + private readonly List _handlers = []; +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + + /// + /// Subscribes the specified handler to the event. + /// + /// + /// The handler to subscribe. It will be invoked when the event is raised, + /// provided that the target is still alive. + /// + /// Thrown when is null. + public void Subscribe(Action handler) + { + ArgumentNullException.ThrowIfNull(handler); + + _handlers.Add(new WeakEventHandler(handler)); + } + + /// + /// Unsubscribes the specified handler from the event. + /// + /// The handler to unsubscribe. + /// Thrown when is null. + public void Unsubscribe(Action handler) + { + ArgumentNullException.ThrowIfNull(handler); + + _handlers.RemoveAll(weh => weh.Matches(handler)); + } + + /// + /// Raises the event, invoking all live subscribers. + /// Dead subscribers (whose targets have been garbage-collected) are removed. + /// + public void Send() + { + List? deadHandlers = null; + + lock (_lock) + { + foreach (var weakHandler in _handlers) + { + var handler = weakHandler.GetHandler(); + if (handler != null) + { + // Invoke the live handler. + handler(); + } + else + { + // Remember handlers whose target has been garbage-collected. + deadHandlers ??= []; + deadHandlers.Add(weakHandler); + } + } + + // Remove dead handlers from the list. + if (deadHandlers != null) + { + foreach (var dead in deadHandlers) + { + _handlers.Remove(dead); + } + } + } + } +} \ No newline at end of file diff --git a/src/WeakEvent/WeakEvent.csproj b/src/WeakEvent/WeakEvent.csproj new file mode 100644 index 0000000..4b95873 --- /dev/null +++ b/src/WeakEvent/WeakEvent.csproj @@ -0,0 +1,46 @@ + + + + Library + net9.0;net6.0 + latest + enable + enable + ByteAether.WeakEvent + + True + True + True + snupkg + + WeakEvent + Joonatan Uusväli + ByteAether + Weakly referenced event subscribers. Keep your .NET events lean and memory-safe. + https://github.com/ByteAether/WeakEvent + $(PackageProjectUrl) + git + + ByteAether.WeakEvent + weak;event;manager;handler;weak-event;weak-event-manager;weak-event-handler;WeakEvent;WeakEventManager;WeakEventHandler;blazor + PACKAGE.md + LICENSE + logo.png + + + + + True + \ + + + True + \ + + + True + \ + + + + diff --git a/src/WeakEvent/WeakEventHandler.cs b/src/WeakEvent/WeakEventHandler.cs new file mode 100644 index 0000000..e7862f2 --- /dev/null +++ b/src/WeakEvent/WeakEventHandler.cs @@ -0,0 +1,95 @@ +using System.Reflection; + +namespace ByteAether.WeakEvent; + +internal readonly struct WeakEventHandler +{ + private readonly WeakReference? _target; + private readonly MethodInfo? _method; + private readonly Action? _staticHandler; + + public WeakEventHandler(Action handler) + { + // If the handler is a static method, there is no target. + if (handler.Target == null) + { + _staticHandler = handler; + } + else + { + _target = new WeakReference(handler.Target); + _method = handler.Method; + } + } + + public Action? GetHandler() + { + if (_staticHandler != null) + { + return _staticHandler; + } + + var target = _target!.Target; + if (target != null) + { + // Recreate a delegate with the target and stored method. + return (Action)Delegate.CreateDelegate( + typeof(Action), target, _method!); + } + + return null; + } + + public bool Matches(Action handler) + { + return handler.Target == null + ? _staticHandler != null && _staticHandler.Equals(handler) + : _target?.Target == handler.Target && _method!.Equals(handler.Method); + } +} + +internal readonly struct WeakEventHandler +{ + private readonly WeakReference? _target; + private readonly MethodInfo? _method; + private readonly Action? _staticHandler; + + public WeakEventHandler(Action handler) + { + // If the handler is a static method, there is no target. + if (handler.Target == null) + { + _staticHandler = handler; + } + else + { + _target = new WeakReference(handler.Target); + _method = handler.Method; + } + } + + public Action? GetHandler() + { + if (_staticHandler != null) + { + return _staticHandler; + } + + var target = _target!.Target; + if (target != null) + { + // Recreate a delegate with the target and stored method. + return (Action)Delegate.CreateDelegate( + typeof(Action), target, _method!); + } + + return null; + } + + public bool Matches(Action handler) + { + return handler.Target == null + ? _staticHandler != null && _staticHandler.Equals(handler) + : _target?.Target == handler.Target && _method!.Equals(handler.Method); + } +}