diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ff8681f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,457 @@ +# editorconfig.org + +# top-most EditorConfig file +root = true + +# Default settings: +# A newline ending every file +# Use 4 spaces as indentation +[*] +insert_final_newline = true +indent_style = space +indent_size = 4 + +[project.json] +indent_size = 2 + +# C# files +[*.cs] +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +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 = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion + +# avoid this. unless absolutely necessary +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# only use var when it's obvious what the variable type is +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# prefer C# premade types. +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# static fields should have _ prefix +dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion +dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields +dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static +dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected +dotnet_naming_style.static_prefix_style.required_prefix = _ +dotnet_naming_style.static_prefix_style.capitalization = camel_case + +# internal and private fields should be _camelCase +dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion +dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields +dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style +dotnet_naming_symbols.private_internal_fields.applicable_kinds = field +dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +# Code style defaults +csharp_using_directive_placement = outside_namespace:suggestion +dotnet_sort_system_directives_first = true +csharp_prefer_braces = true:silent +csharp_preserve_single_line_blocks = true:none +csharp_preserve_single_line_statements = false:none +csharp_prefer_static_local_function = true:suggestion +csharp_prefer_simple_using_statement = false:none +csharp_style_prefer_switch_expression = true:suggestion + +# Code quality +dotnet_style_readonly_field = true:suggestion +dotnet_code_quality_unused_parameters = non_public:suggestion + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +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_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +csharp_prefer_simple_default_expression = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = true:suggestion +csharp_style_expression_bodied_constructors = true:suggestion +csharp_style_expression_bodied_operators = true:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = true:suggestion + +# Pattern matching +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +# Null checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Other features +csharp_style_prefer_index_operator = false:none +csharp_style_prefer_range_operator = false:none +csharp_style_pattern_local_over_anonymous_function = false:none + +# 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 = do_not_ignore +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 + +# analyzers +dotnet_diagnostic.AvoidAsyncVoid.severity = suggestion + +dotnet_diagnostic.CA1000.severity = none +dotnet_diagnostic.CA1001.severity = error +dotnet_diagnostic.CA1009.severity = error +dotnet_diagnostic.CA1016.severity = error +dotnet_diagnostic.CA1030.severity = none +dotnet_diagnostic.CA1031.severity = none +dotnet_diagnostic.CA1033.severity = none +dotnet_diagnostic.CA1036.severity = none +dotnet_diagnostic.CA1049.severity = error +dotnet_diagnostic.CA1056.severity = suggestion +dotnet_diagnostic.CA1060.severity = error +dotnet_diagnostic.CA1061.severity = error +dotnet_diagnostic.CA1063.severity = error +dotnet_diagnostic.CA1065.severity = error +dotnet_diagnostic.CA1301.severity = error +dotnet_diagnostic.CA1303.severity = none +dotnet_diagnostic.CA1308.severity = none +dotnet_diagnostic.CA1400.severity = error +dotnet_diagnostic.CA1401.severity = error +dotnet_diagnostic.CA1403.severity = error +dotnet_diagnostic.CA1404.severity = error +dotnet_diagnostic.CA1405.severity = error +dotnet_diagnostic.CA1410.severity = error +dotnet_diagnostic.CA1415.severity = error +dotnet_diagnostic.CA1507.severity = error +dotnet_diagnostic.CA1710.severity = suggestion +dotnet_diagnostic.CA1724.severity = none +dotnet_diagnostic.CA1810.severity = none +dotnet_diagnostic.CA1821.severity = error +dotnet_diagnostic.CA1900.severity = error +dotnet_diagnostic.CA1901.severity = error +dotnet_diagnostic.CA2000.severity = none +dotnet_diagnostic.CA2002.severity = error +dotnet_diagnostic.CA2007.severity = none +dotnet_diagnostic.CA2100.severity = error +dotnet_diagnostic.CA2101.severity = error +dotnet_diagnostic.CA2108.severity = error +dotnet_diagnostic.CA2111.severity = error +dotnet_diagnostic.CA2112.severity = error +dotnet_diagnostic.CA2114.severity = error +dotnet_diagnostic.CA2116.severity = error +dotnet_diagnostic.CA2117.severity = error +dotnet_diagnostic.CA2122.severity = error +dotnet_diagnostic.CA2123.severity = error +dotnet_diagnostic.CA2124.severity = error +dotnet_diagnostic.CA2126.severity = error +dotnet_diagnostic.CA2131.severity = error +dotnet_diagnostic.CA2132.severity = error +dotnet_diagnostic.CA2133.severity = error +dotnet_diagnostic.CA2134.severity = error +dotnet_diagnostic.CA2137.severity = error +dotnet_diagnostic.CA2138.severity = error +dotnet_diagnostic.CA2140.severity = error +dotnet_diagnostic.CA2141.severity = error +dotnet_diagnostic.CA2146.severity = error +dotnet_diagnostic.CA2147.severity = error +dotnet_diagnostic.CA2149.severity = error +dotnet_diagnostic.CA2200.severity = error +dotnet_diagnostic.CA2202.severity = error +dotnet_diagnostic.CA2207.severity = error +dotnet_diagnostic.CA2212.severity = error +dotnet_diagnostic.CA2213.severity = error +dotnet_diagnostic.CA2214.severity = error +dotnet_diagnostic.CA2216.severity = error +dotnet_diagnostic.CA2220.severity = error +dotnet_diagnostic.CA2229.severity = error +dotnet_diagnostic.CA2231.severity = error +dotnet_diagnostic.CA2232.severity = error +dotnet_diagnostic.CA2235.severity = error +dotnet_diagnostic.CA2236.severity = error +dotnet_diagnostic.CA2237.severity = error +dotnet_diagnostic.CA2238.severity = error +dotnet_diagnostic.CA2240.severity = error +dotnet_diagnostic.CA2241.severity = error +dotnet_diagnostic.CA2242.severity = error + +dotnet_diagnostic.RCS1001.severity = error +dotnet_diagnostic.RCS1018.severity = error +dotnet_diagnostic.RCS1037.severity = error +dotnet_diagnostic.RCS1055.severity = error +dotnet_diagnostic.RCS1062.severity = error +dotnet_diagnostic.RCS1066.severity = error +dotnet_diagnostic.RCS1069.severity = error +dotnet_diagnostic.RCS1071.severity = error +dotnet_diagnostic.RCS1074.severity = error +dotnet_diagnostic.RCS1090.severity = error +dotnet_diagnostic.RCS1138.severity = error +dotnet_diagnostic.RCS1139.severity = error +dotnet_diagnostic.RCS1163.severity = suggestion +dotnet_diagnostic.RCS1168.severity = suggestion +dotnet_diagnostic.RCS1188.severity = error +dotnet_diagnostic.RCS1201.severity = error +dotnet_diagnostic.RCS1207.severity = error +dotnet_diagnostic.RCS1211.severity = error +dotnet_diagnostic.RCS1507.severity = error + +dotnet_diagnostic.SA1000.severity = error +dotnet_diagnostic.SA1001.severity = error +dotnet_diagnostic.SA1002.severity = error +dotnet_diagnostic.SA1003.severity = error +dotnet_diagnostic.SA1004.severity = error +dotnet_diagnostic.SA1005.severity = error +dotnet_diagnostic.SA1006.severity = error +dotnet_diagnostic.SA1007.severity = error +dotnet_diagnostic.SA1008.severity = error +dotnet_diagnostic.SA1009.severity = error +dotnet_diagnostic.SA1010.severity = error +dotnet_diagnostic.SA1011.severity = error +dotnet_diagnostic.SA1012.severity = error +dotnet_diagnostic.SA1013.severity = error +dotnet_diagnostic.SA1014.severity = error +dotnet_diagnostic.SA1015.severity = error +dotnet_diagnostic.SA1016.severity = error +dotnet_diagnostic.SA1017.severity = error +dotnet_diagnostic.SA1018.severity = error +dotnet_diagnostic.SA1019.severity = error +dotnet_diagnostic.SA1020.severity = error +dotnet_diagnostic.SA1021.severity = error +dotnet_diagnostic.SA1022.severity = error +dotnet_diagnostic.SA1023.severity = error +dotnet_diagnostic.SA1024.severity = error +dotnet_diagnostic.SA1025.severity = error +dotnet_diagnostic.SA1026.severity = error +dotnet_diagnostic.SA1027.severity = error +dotnet_diagnostic.SA1028.severity = error +dotnet_diagnostic.SA1100.severity = error +dotnet_diagnostic.SA1101.severity = none +dotnet_diagnostic.SA1102.severity = error +dotnet_diagnostic.SA1103.severity = error +dotnet_diagnostic.SA1104.severity = error +dotnet_diagnostic.SA1105.severity = error +dotnet_diagnostic.SA1106.severity = error +dotnet_diagnostic.SA1107.severity = error +dotnet_diagnostic.SA1108.severity = error +dotnet_diagnostic.SA1110.severity = error +dotnet_diagnostic.SA1111.severity = error +dotnet_diagnostic.SA1112.severity = error +dotnet_diagnostic.SA1113.severity = error +dotnet_diagnostic.SA1114.severity = error +dotnet_diagnostic.SA1115.severity = error +dotnet_diagnostic.SA1116.severity = error +dotnet_diagnostic.SA1117.severity = error +dotnet_diagnostic.SA1118.severity = error +dotnet_diagnostic.SA1119.severity = error +dotnet_diagnostic.SA1120.severity = error +dotnet_diagnostic.SA1121.severity = error +dotnet_diagnostic.SA1122.severity = error +dotnet_diagnostic.SA1123.severity = error +dotnet_diagnostic.SA1124.severity = error +dotnet_diagnostic.SA1125.severity = error +dotnet_diagnostic.SA1127.severity = error +dotnet_diagnostic.SA1128.severity = error +dotnet_diagnostic.SA1129.severity = error +dotnet_diagnostic.SA1130.severity = error +dotnet_diagnostic.SA1131.severity = error +dotnet_diagnostic.SA1132.severity = error +dotnet_diagnostic.SA1133.severity = error +dotnet_diagnostic.SA1134.severity = error +dotnet_diagnostic.SA1135.severity = error +dotnet_diagnostic.SA1136.severity = error +dotnet_diagnostic.SA1137.severity = error +dotnet_diagnostic.SA1139.severity = error +dotnet_diagnostic.SA1200.severity = none +dotnet_diagnostic.SA1201.severity = error +dotnet_diagnostic.SA1202.severity = error +dotnet_diagnostic.SA1203.severity = error +dotnet_diagnostic.SA1204.severity = error +dotnet_diagnostic.SA1205.severity = error +dotnet_diagnostic.SA1206.severity = error +dotnet_diagnostic.SA1207.severity = error +dotnet_diagnostic.SA1208.severity = error +dotnet_diagnostic.SA1209.severity = error +dotnet_diagnostic.SA1210.severity = error +dotnet_diagnostic.SA1211.severity = error +dotnet_diagnostic.SA1212.severity = error +dotnet_diagnostic.SA1213.severity = error +dotnet_diagnostic.SA1214.severity = error +dotnet_diagnostic.SA1216.severity = error +dotnet_diagnostic.SA1217.severity = error +dotnet_diagnostic.SA1300.severity = error +dotnet_diagnostic.SA1302.severity = error +dotnet_diagnostic.SA1303.severity = error +dotnet_diagnostic.SA1304.severity = error +dotnet_diagnostic.SA1306.severity = none +dotnet_diagnostic.SA1307.severity = error +dotnet_diagnostic.SA1308.severity = error +dotnet_diagnostic.SA1309.severity = none +dotnet_diagnostic.SA1310.severity = error +dotnet_diagnostic.SA1311.severity = none +dotnet_diagnostic.SA1312.severity = error +dotnet_diagnostic.SA1313.severity = error +dotnet_diagnostic.SA1314.severity = error +dotnet_diagnostic.SA1316.severity = none +dotnet_diagnostic.SA1400.severity = error +dotnet_diagnostic.SA1401.severity = error +dotnet_diagnostic.SA1402.severity = error +dotnet_diagnostic.SA1403.severity = error +dotnet_diagnostic.SA1404.severity = error +dotnet_diagnostic.SA1405.severity = error +dotnet_diagnostic.SA1406.severity = error +dotnet_diagnostic.SA1407.severity = error +dotnet_diagnostic.SA1408.severity = error +dotnet_diagnostic.SA1410.severity = error +dotnet_diagnostic.SA1411.severity = error +dotnet_diagnostic.SA1413.severity = none +dotnet_diagnostic.SA1500.severity = error +dotnet_diagnostic.SA1501.severity = error +dotnet_diagnostic.SA1502.severity = error +dotnet_diagnostic.SA1503.severity = error +dotnet_diagnostic.SA1504.severity = error +dotnet_diagnostic.SA1505.severity = error +dotnet_diagnostic.SA1506.severity = error +dotnet_diagnostic.SA1507.severity = error +dotnet_diagnostic.SA1508.severity = error +dotnet_diagnostic.SA1509.severity = error +dotnet_diagnostic.SA1510.severity = error +dotnet_diagnostic.SA1511.severity = error +dotnet_diagnostic.SA1512.severity = error +dotnet_diagnostic.SA1513.severity = error +dotnet_diagnostic.SA1514.severity = error +dotnet_diagnostic.SA1515.severity = error +dotnet_diagnostic.SA1516.severity = error +dotnet_diagnostic.SA1517.severity = error +dotnet_diagnostic.SA1518.severity = error +dotnet_diagnostic.SA1519.severity = error +dotnet_diagnostic.SA1520.severity = error +dotnet_diagnostic.SA1600.severity = error +dotnet_diagnostic.SA1601.severity = error +dotnet_diagnostic.SA1602.severity = error +dotnet_diagnostic.SA1604.severity = error +dotnet_diagnostic.SA1605.severity = error +dotnet_diagnostic.SA1606.severity = error +dotnet_diagnostic.SA1607.severity = error +dotnet_diagnostic.SA1608.severity = error +dotnet_diagnostic.SA1610.severity = error +dotnet_diagnostic.SA1611.severity = error +dotnet_diagnostic.SA1612.severity = error +dotnet_diagnostic.SA1613.severity = error +dotnet_diagnostic.SA1614.severity = error +dotnet_diagnostic.SA1615.severity = error +dotnet_diagnostic.SA1616.severity = error +dotnet_diagnostic.SA1617.severity = error +dotnet_diagnostic.SA1618.severity = error +dotnet_diagnostic.SA1619.severity = error +dotnet_diagnostic.SA1620.severity = error +dotnet_diagnostic.SA1621.severity = error +dotnet_diagnostic.SA1622.severity = error +dotnet_diagnostic.SA1623.severity = error +dotnet_diagnostic.SA1624.severity = error +dotnet_diagnostic.SA1625.severity = error +dotnet_diagnostic.SA1626.severity = error +dotnet_diagnostic.SA1627.severity = error +dotnet_diagnostic.SA1629.severity = error +dotnet_diagnostic.SA1633.severity = error +dotnet_diagnostic.SA1634.severity = error +dotnet_diagnostic.SA1635.severity = error +dotnet_diagnostic.SA1636.severity = error +dotnet_diagnostic.SA1637.severity = none +dotnet_diagnostic.SA1638.severity = none +dotnet_diagnostic.SA1640.severity = error +dotnet_diagnostic.SA1641.severity = error +dotnet_diagnostic.SA1642.severity = error +dotnet_diagnostic.SA1643.severity = error +dotnet_diagnostic.SA1649.severity = error +dotnet_diagnostic.SA1651.severity = error + +dotnet_diagnostic.SX1101.severity = error +dotnet_diagnostic.SX1309.severity = error +dotnet_diagnostic.SX1623.severity = none + +dotnet_diagnostic.AD0001.severity = none + +# C++ Files +[*.{cpp,h,in}] +curly_bracket_next_line = true +indent_brace_style = Allman + +# Xml project files +[*.{csproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}] +indent_size = 2 + +# Xml build files +[*.builds] +indent_size = 2 + +# Xml files +[*.{xml,stylecop,resx,ruleset}] +indent_size = 2 + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 + +# Shell scripts +[*.sh] +end_of_line = lf +[*.{cmd, bat}] +end_of_line = crlf diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fdce30d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,289 @@ +# Catch all for anything we forgot. Add rules if you get CRLF to LF warnings. +* text=auto + +# Text files that should be normalized to LF in odb. +*.cs text diff=csharp +*.xaml text +*.config text +*.c text +*.h text +*.cpp text +*.hpp text +*.sln text +*.csproj text +*.vcxproj text +*.md text +*.tt text +*.sh text +*.ps1 text +*.cmd text +*.bat text +*.markdown text +*.msbuild text +# Binary files that should not be normalized or diffed +*.png binary +*.jpg binary +*.gif binary +*.ico binary +*.rc binary +*.pfx binary +*.snk binary +*.dll binary +*.exe binary +*.lib binary +*.exp binary +*.pdb binary +*.sdf binary +*.7z binary +# Generated file should just use CRLF, it's fiiine +SolutionInfo.cs text eol=crlf diff=csharp +*.mht filter=lfs diff=lfs merge=lfs -text +*.ppam filter=lfs diff=lfs merge=lfs -text +*.wmv filter=lfs diff=lfs merge=lfs -text +*.btif filter=lfs diff=lfs merge=lfs -text +*.fla filter=lfs diff=lfs merge=lfs -text +*.qt filter=lfs diff=lfs merge=lfs -text +*.xlam filter=lfs diff=lfs merge=lfs -text +*.xm filter=lfs diff=lfs merge=lfs -text +*.djvu filter=lfs diff=lfs merge=lfs -text +*.woff filter=lfs diff=lfs merge=lfs -text +*.a filter=lfs diff=lfs merge=lfs -text +*.bak filter=lfs diff=lfs merge=lfs -text +*.lha filter=lfs diff=lfs merge=lfs -text +*.mpg filter=lfs diff=lfs merge=lfs -text +*.xltm filter=lfs diff=lfs merge=lfs -text +*.eol filter=lfs diff=lfs merge=lfs -text +*.ipa filter=lfs diff=lfs merge=lfs -text +*.ttf filter=lfs diff=lfs merge=lfs -text +*.uvm filter=lfs diff=lfs merge=lfs -text +*.cmx filter=lfs diff=lfs merge=lfs -text +*.dng filter=lfs diff=lfs merge=lfs -text +*.xltx filter=lfs diff=lfs merge=lfs -text +*.fli filter=lfs diff=lfs merge=lfs -text +*.wmx filter=lfs diff=lfs merge=lfs -text +*.jxr filter=lfs diff=lfs merge=lfs -text +*.pyv filter=lfs diff=lfs merge=lfs -text +*.s7z filter=lfs diff=lfs merge=lfs -text +*.csv filter=lfs diff=lfs merge=lfs -text +*.pptm filter=lfs diff=lfs merge=lfs -text +*.rz filter=lfs diff=lfs merge=lfs -text +*.wm filter=lfs diff=lfs merge=lfs -text +*.xlsx filter=lfs diff=lfs merge=lfs -text +*.bh filter=lfs diff=lfs merge=lfs -text +*.dat filter=lfs diff=lfs merge=lfs -text +*.mid filter=lfs diff=lfs merge=lfs -text +*.mpga filter=lfs diff=lfs merge=lfs -text +*.ogg filter=lfs diff=lfs merge=lfs -text +*.s3m filter=lfs diff=lfs merge=lfs -text +*.mar filter=lfs diff=lfs merge=lfs -text +*.movie filter=lfs diff=lfs merge=lfs -text +*.pptx filter=lfs diff=lfs merge=lfs -text +*.dll filter=lfs diff=lfs merge=lfs -text +*.docm filter=lfs diff=lfs merge=lfs -text +*.m3u filter=lfs diff=lfs merge=lfs -text +*.mov filter=lfs diff=lfs merge=lfs -text +*.aac filter=lfs diff=lfs merge=lfs -text +*.jar filter=lfs diff=lfs merge=lfs -text +*.midi filter=lfs diff=lfs merge=lfs -text +*.mobi filter=lfs diff=lfs merge=lfs -text +*.potm filter=lfs diff=lfs merge=lfs -text +*.woff2 filter=lfs diff=lfs merge=lfs -text +*.cab filter=lfs diff=lfs merge=lfs -text +*.dmg filter=lfs diff=lfs merge=lfs -text +*.pdf filter=lfs diff=lfs merge=lfs -text +*.war filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.icns filter=lfs diff=lfs merge=lfs -text +*.slk filter=lfs diff=lfs merge=lfs -text +*.wbmp filter=lfs diff=lfs merge=lfs -text +*.xpm filter=lfs diff=lfs merge=lfs -text +*.xmind filter=lfs diff=lfs merge=lfs -text +*.3g2 filter=lfs diff=lfs merge=lfs -text +*.m4v filter=lfs diff=lfs merge=lfs -text +*.pic filter=lfs diff=lfs merge=lfs -text +*.uvi filter=lfs diff=lfs merge=lfs -text +*.uvp filter=lfs diff=lfs merge=lfs -text +*.xls filter=lfs diff=lfs merge=lfs -text +*.jpgv filter=lfs diff=lfs merge=lfs -text +*.mka filter=lfs diff=lfs merge=lfs -text +*.swf filter=lfs diff=lfs merge=lfs -text +*.uvs filter=lfs diff=lfs merge=lfs -text +*.wav filter=lfs diff=lfs merge=lfs -text +*.ecelp4800 filter=lfs diff=lfs merge=lfs -text +*.mng filter=lfs diff=lfs merge=lfs -text +*.pps filter=lfs diff=lfs merge=lfs -text +*.whl filter=lfs diff=lfs merge=lfs -text +*.arj filter=lfs diff=lfs merge=lfs -text +*.lzh filter=lfs diff=lfs merge=lfs -text +*.raw filter=lfs diff=lfs merge=lfs -text +*.rlc filter=lfs diff=lfs merge=lfs -text +*.sgi filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.au filter=lfs diff=lfs merge=lfs -text +*.dcm filter=lfs diff=lfs merge=lfs -text +*.GIF filter=lfs diff=lfs merge=lfs -text +*.resources filter=lfs diff=lfs merge=lfs -text +*.txz filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.sil filter=lfs diff=lfs merge=lfs -text +*.bk filter=lfs diff=lfs merge=lfs -text +*.DS_Store filter=lfs diff=lfs merge=lfs -text +*.ief filter=lfs diff=lfs merge=lfs -text +*.JPEG filter=lfs diff=lfs merge=lfs -text +*.pbm filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.sketch filter=lfs diff=lfs merge=lfs -text +*.tbz2 filter=lfs diff=lfs merge=lfs -text +*.nef filter=lfs diff=lfs merge=lfs -text +*.oga filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.ecelp7470 filter=lfs diff=lfs merge=lfs -text +*.xlt filter=lfs diff=lfs merge=lfs -text +*.exe filter=lfs diff=lfs merge=lfs -text +*.mp4 filter=lfs diff=lfs merge=lfs -text +*.pnm filter=lfs diff=lfs merge=lfs -text +*.ttc filter=lfs diff=lfs merge=lfs -text +*.wdp filter=lfs diff=lfs merge=lfs -text +*.xbm filter=lfs diff=lfs merge=lfs -text +*.ecelp9600 filter=lfs diff=lfs merge=lfs -text +*.pot filter=lfs diff=lfs merge=lfs -text +*.wvx filter=lfs diff=lfs merge=lfs -text +*.uvu filter=lfs diff=lfs merge=lfs -text +*.asf filter=lfs diff=lfs merge=lfs -text +*.dxf filter=lfs diff=lfs merge=lfs -text +*.flv filter=lfs diff=lfs merge=lfs -text +*.mdi filter=lfs diff=lfs merge=lfs -text +*.pcx filter=lfs diff=lfs merge=lfs -text +*.tiff filter=lfs diff=lfs merge=lfs -text +*.bzip2 filter=lfs diff=lfs merge=lfs -text +*.deb filter=lfs diff=lfs merge=lfs -text +*.graffle filter=lfs diff=lfs merge=lfs -text +*.h261 filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.ppm filter=lfs diff=lfs merge=lfs -text +*.tif filter=lfs diff=lfs merge=lfs -text +*.ppt filter=lfs diff=lfs merge=lfs -text +*.fbs filter=lfs diff=lfs merge=lfs -text +*.gzip filter=lfs diff=lfs merge=lfs -text +*.o filter=lfs diff=lfs merge=lfs -text +*.sub filter=lfs diff=lfs merge=lfs -text +*.z filter=lfs diff=lfs merge=lfs -text +*.alz filter=lfs diff=lfs merge=lfs -text +*.BMP filter=lfs diff=lfs merge=lfs -text +*.dotm filter=lfs diff=lfs merge=lfs -text +*.key filter=lfs diff=lfs merge=lfs -text +*.rgb filter=lfs diff=lfs merge=lfs -text +*.f4v filter=lfs diff=lfs merge=lfs -text +*.iso filter=lfs diff=lfs merge=lfs -text +*.ai filter=lfs diff=lfs merge=lfs -text +*.dtshd filter=lfs diff=lfs merge=lfs -text +*.fpx filter=lfs diff=lfs merge=lfs -text +*.shar filter=lfs diff=lfs merge=lfs -text +*.img filter=lfs diff=lfs merge=lfs -text +*.rmf filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.eot filter=lfs diff=lfs merge=lfs -text +*.wma filter=lfs diff=lfs merge=lfs -text +*.cpio filter=lfs diff=lfs merge=lfs -text +*.cr2 filter=lfs diff=lfs merge=lfs -text +*.adp filter=lfs diff=lfs merge=lfs -text +*.mpeg filter=lfs diff=lfs merge=lfs -text +*.npx filter=lfs diff=lfs merge=lfs -text +*.pdb filter=lfs diff=lfs merge=lfs -text +*.PNG filter=lfs diff=lfs merge=lfs -text +*.xwd filter=lfs diff=lfs merge=lfs -text +*.egg filter=lfs diff=lfs merge=lfs -text +*.ppsx filter=lfs diff=lfs merge=lfs -text +*.mp4a filter=lfs diff=lfs merge=lfs -text +*.pages filter=lfs diff=lfs merge=lfs -text +*.baml filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.class filter=lfs diff=lfs merge=lfs -text +*.h264 filter=lfs diff=lfs merge=lfs -text +*.lib filter=lfs diff=lfs merge=lfs -text +*.mmr filter=lfs diff=lfs merge=lfs -text +*.dot filter=lfs diff=lfs merge=lfs -text +*.gif filter=lfs diff=lfs merge=lfs -text +*.JPG filter=lfs diff=lfs merge=lfs -text +*.m4a filter=lfs diff=lfs merge=lfs -text +*.so filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.thmx filter=lfs diff=lfs merge=lfs -text +*.3ds filter=lfs diff=lfs merge=lfs -text +*.bmp filter=lfs diff=lfs merge=lfs -text +*.ogv filter=lfs diff=lfs merge=lfs -text +*.xif filter=lfs diff=lfs merge=lfs -text +*.aiff filter=lfs diff=lfs merge=lfs -text +*.dts filter=lfs diff=lfs merge=lfs -text +*.rip filter=lfs diff=lfs merge=lfs -text +*.vob filter=lfs diff=lfs merge=lfs -text +*.7z filter=lfs diff=lfs merge=lfs -text +*.fh filter=lfs diff=lfs merge=lfs -text +*.flac filter=lfs diff=lfs merge=lfs -text +*.g3 filter=lfs diff=lfs merge=lfs -text +*.jpm filter=lfs diff=lfs merge=lfs -text +*.ppsm filter=lfs diff=lfs merge=lfs -text +*.potx filter=lfs diff=lfs merge=lfs -text +*.zipx filter=lfs diff=lfs merge=lfs -text +*.dsk filter=lfs diff=lfs merge=lfs -text +*.ico filter=lfs diff=lfs merge=lfs -text +*.ktx filter=lfs diff=lfs merge=lfs -text +*.lz filter=lfs diff=lfs merge=lfs -text +*.numbers filter=lfs diff=lfs merge=lfs -text +*.3gp filter=lfs diff=lfs merge=lfs -text +*.fst filter=lfs diff=lfs merge=lfs -text +*.scpt filter=lfs diff=lfs merge=lfs -text +*.epub filter=lfs diff=lfs merge=lfs -text +*.rmvb filter=lfs diff=lfs merge=lfs -text +*.webm filter=lfs diff=lfs merge=lfs -text +*.docx filter=lfs diff=lfs merge=lfs -text +*.pgm filter=lfs diff=lfs merge=lfs -text +*.pya filter=lfs diff=lfs merge=lfs -text +*.rtf filter=lfs diff=lfs merge=lfs -text +*.smv filter=lfs diff=lfs merge=lfs -text +*.tga filter=lfs diff=lfs merge=lfs -text +*.cur filter=lfs diff=lfs merge=lfs -text +*.dwg filter=lfs diff=lfs merge=lfs -text +*.lvp filter=lfs diff=lfs merge=lfs -text +*.pyo filter=lfs diff=lfs merge=lfs -text +*.apk filter=lfs diff=lfs merge=lfs -text +*.ar filter=lfs diff=lfs merge=lfs -text +*.caf filter=lfs diff=lfs merge=lfs -text +*.doc filter=lfs diff=lfs merge=lfs -text +*.h263 filter=lfs diff=lfs merge=lfs -text +*.xlsm filter=lfs diff=lfs merge=lfs -text +*.mp3 filter=lfs diff=lfs merge=lfs -text +*.mxu filter=lfs diff=lfs merge=lfs -text +*.wax filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.mj2 filter=lfs diff=lfs merge=lfs -text +*.otf filter=lfs diff=lfs merge=lfs -text +*.udf filter=lfs diff=lfs merge=lfs -text +*.aif filter=lfs diff=lfs merge=lfs -text +*.lzma filter=lfs diff=lfs merge=lfs -text +*.pyc filter=lfs diff=lfs merge=lfs -text +*.weba filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text +*.cgm filter=lfs diff=lfs merge=lfs -text +*.mkv filter=lfs diff=lfs merge=lfs -text +*.ppa filter=lfs diff=lfs merge=lfs -text +*.uvh filter=lfs diff=lfs merge=lfs -text +*.xpi filter=lfs diff=lfs merge=lfs -text +*.psd filter=lfs diff=lfs merge=lfs -text +*.xlsb filter=lfs diff=lfs merge=lfs -text +*.tbz filter=lfs diff=lfs merge=lfs -text +*.wim filter=lfs diff=lfs merge=lfs -text +*.ape filter=lfs diff=lfs merge=lfs -text +*.avi filter=lfs diff=lfs merge=lfs -text +*.dex filter=lfs diff=lfs merge=lfs -text +*.dra filter=lfs diff=lfs merge=lfs -text +*.dvb filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.xla filter=lfs diff=lfs merge=lfs -text +*.fvt filter=lfs diff=lfs merge=lfs -text +*.lzo filter=lfs diff=lfs merge=lfs -text +*.pea filter=lfs diff=lfs merge=lfs -text +*.ras filter=lfs diff=lfs merge=lfs -text +*.tlz filter=lfs diff=lfs merge=lfs -text +*.viv filter=lfs diff=lfs merge=lfs -text +*.winmd filter=lfs diff=lfs merge=lfs -text diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9d06ed1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: +- package-ecosystem: nuget + directory: "/" + schedule: + interval: monthly + time: "00:00" + open-pull-requests-limit: 20 +- package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every weekday + interval: "monthly" diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml new file mode 100644 index 0000000..ab940ad --- /dev/null +++ b/.github/workflows/ci-build.yml @@ -0,0 +1,83 @@ +name: Build + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + configuration: Release + productNamespacePrefix: "ReactiveMarbles" + +jobs: + build: + runs-on: windows-2022 + outputs: + nbgv: ${{ steps.nbgv.outputs.SemVer2 }} + steps: + - name: Get Current Visual Studio Information + shell: bash + run: | + dotnet tool update -g dotnet-vs + echo "-- About RELEASE --" + vs where release + + - name: Update Visual Studio Latest Release + shell: bash + run: | + echo "-- Update RELEASE --" + vs update release Enterprise + vs modify release Enterprise +mobile +desktop +uwp +web + echo "-- About RELEASE Updated --" + vs where release + + - name: Checkout + uses: actions/checkout@v3.2.0 + with: + fetch-depth: 0 + lfs: true + + - name: Install .NET 6 & 7 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: | + 6.0.x + 7.0.x + + - name: NBGV + id: nbgv + uses: dotnet/nbgv@master + with: + setAllVars: true + + - name: NuGet Restore + run: dotnet restore + working-directory: src + + - name: Build + run: dotnet build --configuration=${{ env.configuration }} --verbosity=minimal --no-restore + working-directory: src + + - name: Run Unit Tests and Generate Coverage + uses: glennawatson/coverlet-msbuild@v2.1 + with: + project-files: '**/*Tests*.csproj' + no-build: true + exclude-filter: '[${{env.productNamespacePrefix}}.*.Tests.*]*' + include-filter: '[${{env.productNamespacePrefix}}*]*' + output-format: cobertura + configuration: ${{ env.configuration }} + + - name: Pack + run: dotnet pack --configuration=${{ env.configuration }} --verbosity=minimal --no-restore + working-directory: src + + - name: Upload Code Coverage + uses: codecov/codecov-action@v3 + + - name: Create NuGet Artifacts + uses: actions/upload-artifact@master + with: + name: nuget + path: '**/*.nupkg' diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml new file mode 100644 index 0000000..307b83a --- /dev/null +++ b/.github/workflows/lock.yml @@ -0,0 +1,31 @@ +name: 'Lock Threads' + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +concurrency: + group: lock + +jobs: + action: + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@v4 + with: + github-token: ${{ github.token }} + issue-inactive-days: '14' + pr-inactive-days: '14' + issue-comment: > + This issue has been automatically locked since there + has not been any recent activity after it was closed. + Please open a new issue for related bugs. + pr-comment: > + This pull request has been automatically locked since there + has not been any recent activity after it was closed. + Please open a new issue for related bugs. \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..09f282c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,98 @@ +name: Build and Release + +on: + push: + branches: [ main ] + +env: + configuration: Release + productNamespacePrefix: "ReactiveMarbles" + +jobs: + release: + runs-on: windows-2022 + environment: + name: release + outputs: + nbgv: ${{ steps.nbgv.outputs.SemVer2 }} + steps: + - name: Get Current Visual Studio Information + shell: bash + run: | + dotnet tool update -g dotnet-vs + echo "-- About RELEASE --" + vs where release + + - name: Update Visual Studio Latest Release + shell: bash + run: | + echo "-- Update RELEASE --" + vs update release Enterprise + vs modify release Enterprise +mobile +desktop +uwp +web + echo "-- About RELEASE Updated --" + vs where release + + - name: Checkout + uses: actions/checkout@v3.2.0 + with: + fetch-depth: 0 + lfs: true + + - name: Install .NET 6 & 7 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: | + 6.0.x + 7.0.x + + - name: NBGV + id: nbgv + uses: dotnet/nbgv@master + with: + setAllVars: true + + - name: NuGet Restore + run: dotnet restore + working-directory: src + + - name: Build + run: dotnet build --configuration=${{ env.configuration }} --verbosity=minimal --no-restore + working-directory: src + + - uses: nuget/setup-nuget@v1 + name: Setup NuGet + + - name: Pack + run: dotnet pack --configuration=${{ env.configuration }} --verbosity=minimal --no-restore + working-directory: src + + # Decode the base 64 encoded pfx and save the Signing_Certificate + - name: Sign NuGet packages + shell: pwsh + run: | + $pfx_cert_byte = [System.Convert]::FromBase64String("${{ secrets.SIGNING_CERTIFICATE }}") + [IO.File]::WriteAllBytes("GitHubActionsWorkflow.pfx", $pfx_cert_byte) + $secure_password = ConvertTo-SecureString ${{ secrets.SIGN_CERTIFICATE_PASSWORD }} –asplaintext –force + Import-PfxCertificate -FilePath GitHubActionsWorkflow.pfx -Password $secure_password -CertStoreLocation Cert:\CurrentUser\My + nuget sign -Timestamper http://timestamp.digicert.com -CertificateFingerprint ${{ secrets.SIGN_CERTIFICATE_HASH }} **/*.nupkg + + - name: Changelog + uses: glennawatson/ChangeLog@v1 + id: changelog + + - name: Create Release + uses: actions/create-release@v1.1.4 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token + with: + tag_name: ${{ steps.nbgv.outputs.SemVer2 }} + release_name: ${{ steps.nbgv.outputs.SemVer2 }} + body: | + ${{ steps.changelog.outputs.commitLog }} + + - name: NuGet Push + env: + NUGET_AUTH_TOKEN: ${{ secrets.NUGET_API_KEY }} + SOURCE_URL: https://api.nuget.org/v3/index.json + run: | + dotnet nuget push -s ${{ env.SOURCE_URL }} -k ${{ env.NUGET_AUTH_TOKEN }} **/*.nupkg diff --git a/images/logo.png b/images/logo.png new file mode 100644 index 0000000..70c7e75 --- /dev/null +++ b/images/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:722b25de1d0f7577e122a4d96c9d96f4019f7a34283a3612ae244a68eea7fc17 +size 47204 diff --git a/src/ReactiveMarbles.Command.Tests/FakeCommand.cs b/src/ReactiveMarbles.Command.Tests/FakeCommand.cs new file mode 100644 index 0000000..feb5714 --- /dev/null +++ b/src/ReactiveMarbles.Command.Tests/FakeCommand.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2019-2022 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Windows.Input; + +namespace ReactiveMarbles.Command.Tests; + +/// +/// A fake command that can be executed as part of a test. +/// +public class FakeCommand : ICommand +{ + /// + /// Initializes a new instance of the class. + /// + public FakeCommand() + { + CanExecuteParameter = default; + ExecuteParameter = default; + } + + /// + /// Occurs when changes occur that affect whether or not the command should execute. + /// + public event EventHandler? CanExecuteChanged; + + /// + /// Gets the can execute parameter. + /// + public object? CanExecuteParameter { get; private set; } + + /// + /// Gets the execute parameter. + /// + public object? ExecuteParameter { get; private set; } + + /// + /// Defines the method that determines whether the command can execute in its current state. + /// + /// Data used by the command. If the command does not require data to be passed, this object can be set to . + /// + /// if this command can be executed; otherwise, . + /// + public bool CanExecute(object? parameter) + { + CanExecuteParameter = parameter; + return true; + } + + /// + /// Defines the method to be called when the command is invoked. + /// + /// Data used by the command. If the command does not require data to be passed, this object can be set to . + public void Execute(object? parameter) => ExecuteParameter = parameter; + + /// + /// Notifies the can execute changed. + /// + /// The instance containing the event data. + protected virtual void NotifyCanExecuteChanged(EventArgs e) => CanExecuteChanged?.Invoke(this, e); +} diff --git a/src/ReactiveMarbles.Command.Tests/ICommandHolder.cs b/src/ReactiveMarbles.Command.Tests/ICommandHolder.cs new file mode 100644 index 0000000..835ba22 --- /dev/null +++ b/src/ReactiveMarbles.Command.Tests/ICommandHolder.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2019-2022 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Windows.Input; +using ReactiveMarbles.Mvvm; + +namespace ReactiveMarbles.Command.Tests; + +/// +/// A ReactiveObject which hosts a command. +/// +public class ICommandHolder : RxObject +{ + private ICommand? _theCommand; + + /// + /// Gets or sets the command. + /// + public ICommand? TheCommand + { + get => _theCommand; + set => RaiseAndSetIfChanged(ref _theCommand, value); + } +} diff --git a/src/ReactiveMarbles.Command.Tests/ReactiveMarbles.Command.Tests.csproj b/src/ReactiveMarbles.Command.Tests/ReactiveMarbles.Command.Tests.csproj new file mode 100644 index 0000000..b82f512 --- /dev/null +++ b/src/ReactiveMarbles.Command.Tests/ReactiveMarbles.Command.Tests.csproj @@ -0,0 +1,31 @@ + + + + net6.0 + enable + false + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/ReactiveMarbles.Command.Tests/RxCommandHolder.cs b/src/ReactiveMarbles.Command.Tests/RxCommandHolder.cs new file mode 100644 index 0000000..955cbc4 --- /dev/null +++ b/src/ReactiveMarbles.Command.Tests/RxCommandHolder.cs @@ -0,0 +1,26 @@ +// Copyright (c) 2019-2022 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive; +using ReactiveMarbles.Mvvm; + +namespace ReactiveMarbles.Command.Tests; + +/// +/// A ReactiveObject which hosts a ReactiveCommand. +/// +/// +public class RxCommandHolder : RxObject +{ + private RxCommand? _theCommand; + + /// + /// Gets or sets the command. + /// + public RxCommand? TheCommand + { + get => _theCommand; + set => RaiseAndSetIfChanged(ref _theCommand, value); + } +} diff --git a/src/ReactiveMarbles.Command.Tests/RxCommandTests.cs b/src/ReactiveMarbles.Command.Tests/RxCommandTests.cs new file mode 100644 index 0000000..0f90600 --- /dev/null +++ b/src/ReactiveMarbles.Command.Tests/RxCommandTests.cs @@ -0,0 +1,1357 @@ +// Copyright (c) 2019-2022 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; +using DynamicData; +using DynamicData.Binding; +using FluentAssertions; +using Microsoft.Reactive.Testing; +using ReactiveMarbles.Locator; +using ReactiveMarbles.Mvvm; +using ReactiveUI; +using ReactiveUI.Testing; +using Xunit; + +namespace ReactiveMarbles.Command.Tests; + +/// +/// Tests for the RxCommand class. +/// +public class RxCommandTests +{ + /// + /// Initializes a new instance of the class. + /// + public RxCommandTests() => ServiceLocator.Current().AddCoreRegistrations(() => + CoreRegistrationBuilder + .Create() + .WithMainThreadScheduler(new TestScheduler()) + .WithTaskPoolScheduler(new TestScheduler()) + .WithExceptionHandler(new DebugExceptionHandler()) + .Build()); + + /// + /// A test that determines whether this instance [can execute changed is available via ICommand]. + /// + [Fact] + public void CanExecuteChangedIsAvailableViaICommand() + { + var canExecuteSubject = new Subject(); + ICommand fixture = RxCommand.Create(() => Observable.Return(Unit.Default), canExecuteSubject, ImmediateScheduler.Instance); + var canExecuteChanged = new List(); + fixture.CanExecuteChanged += (s, e) => canExecuteChanged.Add(fixture.CanExecute(null)); + + canExecuteSubject.OnNext(true); + canExecuteSubject.OnNext(false); + + canExecuteChanged + .Should() + .HaveCount(2) + .And + .Subject + .Should() + .SatisfyRespectively(first => first.Should().BeTrue(), second => second.Should().BeFalse()); + } + + /// + /// A test that determines whether this instance [can execute is available via ICommand]. + /// + [Fact] + public void CanExecuteIsAvailableViaICommand() + { + var canExecuteSubject = new Subject(); + ICommand fixture = RxCommand.Create(() => Observable.Return(Unit.Default), canExecuteSubject, ImmediateScheduler.Instance); + + Assert.False(fixture.CanExecute(null)); + + canExecuteSubject.OnNext(true); + fixture.CanExecute(null).Should().BeTrue(); + + canExecuteSubject.OnNext(false); + + fixture.CanExecute(null).Should().BeFalse(); + } + + /// + /// Test that determines whether this instance [can execute is behavioral]. + /// + [Fact] + public void CanExecuteIsBehavioral() + { + var fixture = RxCommand.Create(() => Observable.Return(Unit.Default), outputScheduler: ImmediateScheduler.Instance); + fixture.CanCommandExecute.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var canExecute).Subscribe(); + + canExecute.Should().ContainSingle(x => x); + } + + /// + /// Test that determines whether this instance [can execute is false if already executing]. + /// + [Fact] + public void CanExecuteIsFalseIfAlreadyExecuting() => + new TestScheduler().With( + scheduler => + { + var execute = Observable.Return(Unit.Default).Delay(TimeSpan.FromSeconds(1), scheduler); + var fixture = RxCommand.Create(() => execute, outputScheduler: scheduler); + fixture.CanCommandExecute.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var canExecute) + .Subscribe(); + + fixture.Execute().Subscribe(); + scheduler.AdvanceByMs(100); + + canExecute.Should().HaveCount(2); + canExecute[1].Should().BeFalse(); + + scheduler.AdvanceByMs(901); + + canExecute.Should().HaveCount(3); + canExecute[2].Should().BeTrue(); + }); + + /// + /// Test that determines whether this instance [can execute is false if caller dictates as such]. + /// + [Fact] + public void CanExecuteIsFalseIfCallerDictatesAsSuch() + { + var canExecuteSubject = new Subject(); + var fixture = RxCommand.Create(() => Observable.Return(Unit.Default), canExecuteSubject, ImmediateScheduler.Instance); + fixture.CanCommandExecute.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var canExecute).Subscribe(); + + canExecuteSubject.OnNext(true); + canExecuteSubject.OnNext(false); + + canExecute + .Should() + .HaveCount(3) + .And + .Subject + .Should() + .SatisfyRespectively( + first => first.Should() + .BeFalse(), + second => second.Should() + .BeTrue(), + third => third.Should() + .BeFalse()); + } + + /// + /// Test that determines whether this instance [can execute is unsubscribed after command disposal]. + /// + [Fact] + public void CanExecuteIsUnsubscribedAfterCommandDisposal() + { + var canExecuteSubject = new Subject(); + var fixture = RxCommand.Create(() => Observable.Return(Unit.Default), canExecuteSubject, ImmediateScheduler.Instance); + + Assert.True(canExecuteSubject.HasObservers); + + fixture.Dispose(); + + canExecuteSubject.HasObservers.Should().BeFalse(); + } + + /// + /// Test that determines whether this instance [can execute only ticks distinct values]. + /// + [Fact] + public void CanExecuteOnlyTicksDistinctValues() + { + var canExecuteSubject = new Subject(); + var fixture = RxCommand.Create(() => Observable.Return(Unit.Default), canExecuteSubject, ImmediateScheduler.Instance); + fixture.CanCommandExecute.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var canExecute).Subscribe(); + + canExecuteSubject.OnNext(false); + canExecuteSubject.OnNext(false); + canExecuteSubject.OnNext(false); + canExecuteSubject.OnNext(false); + canExecuteSubject.OnNext(true); + canExecuteSubject.OnNext(true); + + canExecute + .Should() + .HaveCount(2) + .And.Subject.Should() + .SatisfyRespectively( + first => first.Should() + .BeFalse(), + second => second.Should() + .BeTrue()); + } + + /// + /// Test that determines whether this instance [can execute ticks failures through thrown exceptions]. + /// + [Fact] + public void CanExecuteTicksFailuresThroughThrownExceptions() + { + var canExecuteSubject = new Subject(); + var fixture = RxCommand.Create(() => Observable.Return(Unit.Default), canExecuteSubject, ImmediateScheduler.Instance); + fixture.ThrownExceptions.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var thrownExceptions) + .Subscribe(); + + canExecuteSubject.OnError(new InvalidOperationException("oops")); + + thrownExceptions.Should().ContainSingle(x => x.Message == "oops"); + } + + /// + /// Creates the task facilitates TPL integration. + /// + [Fact] + public void CreateTaskFacilitatesTPLIntegration() + { + var fixture = RxCommand.Create(_ => Task.FromResult(13), outputScheduler: ImmediateScheduler.Instance); + fixture.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var results).Subscribe(); + + fixture.Execute().Subscribe(); + + results.Should().ContainSingle(x => x == 13); + } + + /// + /// Creates the task facilitates TPL integration with parameter. + /// + [Fact] + public void CreateTaskFacilitatesTPLIntegrationWithParameter() + { + var fixture = RxCommand.Create(param => Task.FromResult(param + 1), outputScheduler: ImmediateScheduler.Instance); + fixture.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var results).Subscribe(); + + fixture.Execute(3).Subscribe(); + fixture.Execute(41).Subscribe(); + + results + .Should() + .HaveCount(2) + .And + .Subject + .Should() + .SatisfyRespectively( + first => first.Should() + .Be(4), + second => second.Should() + .Be(42)); + } + + /// + /// Creates the throws if execution parameter is null. + /// + [Fact] + public void CreateThrowsIfExecutionParameterIsNull() + { +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. +#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type. + Assert.Throws(() => RxCommand.Create((Func)null)); + Assert.Throws(() => RxCommand.Create((Action)null)); + Assert.Throws(() => RxCommand.Create((Func)null)); + Assert.Throws(() => RxCommand.Create((Func>)null)); + Assert.Throws(() => RxCommand.Create((Func>)null)); + Assert.Throws(() => RxCommand.Create((Func>)null)); + Assert.Throws(() => RxCommand.Create((Func>)null)); + Assert.Throws(() => RxCommand.Create((Func>)null)); + Assert.Throws(() => RxCommand.Create((Func>)null)); +#pragma warning restore CS8600 // Converting null literal or possible null value to non-nullable type. +#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. + } + + /// + /// Exceptions the are delivered on output scheduler. + /// + [Fact] + public void ExceptionsAreDeliveredOnOutputScheduler() => + new TestScheduler().With( + scheduler => + { + var fixture = RxCommand.Create(() => Observable.Throw(new InvalidOperationException()), outputScheduler: scheduler); + Exception? exception = null; + fixture.ThrownExceptions.Subscribe(ex => exception = ex); + fixture.Execute().Subscribe(_ => { }, _ => { }); + + Assert.Null(exception); + scheduler.Start(); + exception.Should().BeOfType(); + }); + + /// + /// Executes the can be cancelled. + /// + [Fact] + public void ExecuteCanBeCancelled() => + new TestScheduler().With( + scheduler => + { + var execute = Observable.Return(Unit.Default).Delay(TimeSpan.FromSeconds(1), scheduler); + var fixture = RxCommand.Create(() => execute, outputScheduler: scheduler); + fixture.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var executed).Subscribe(); + + var sub1 = fixture.Execute().Subscribe(); + var sub2 = fixture.Execute().Subscribe(); + scheduler.AdvanceByMs(999); + + Assert.True(fixture.IsExecuting.FirstAsync().Wait()); + Assert.Empty(executed); + sub1.Dispose(); + + scheduler.AdvanceByMs(2); + Assert.Equal(1, executed.Count); + Assert.False(fixture.IsExecuting.FirstAsync().Wait()); + }); + + /// + /// Executes the can tick through multiple results. + /// + [Fact] + public void ExecuteCanTickThroughMultipleResults() + { + var fixture = RxCommand.Create( + () => new[] { 1, 2, 3 }.ToObservable(), + outputScheduler: ImmediateScheduler.Instance); + fixture.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var results).Subscribe(); + + fixture.Execute().Subscribe(); + + results + .Should() + .HaveCount(3) + .And.Subject.Should() + .SatisfyRespectively( + first => first.Should() + .Be(1), + second => second.Should() + .Be(2), + third => third.Should() + .Be(3)); + } + + /// + /// Executes the facilitates any number of in flight executions. + /// + [Fact] + public void ExecuteFacilitatesAnyNumberOfInFlightExecutions() => + new TestScheduler().With( + scheduler => + { + var execute = Observable.Return(Unit.Default).Delay(TimeSpan.FromMilliseconds(500), scheduler); + var fixture = RxCommand.Create(() => execute, outputScheduler: scheduler); + fixture.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var executed).Subscribe(); + + var sub1 = fixture.Execute().Subscribe(); + var sub2 = fixture.Execute().Subscribe(); + scheduler.AdvanceByMs(100); + + var sub3 = fixture.Execute().Subscribe(); + scheduler.AdvanceByMs(200); + var sub4 = fixture.Execute().Subscribe(); + scheduler.AdvanceByMs(100); + + Assert.True(fixture.IsExecuting.FirstAsync().Wait()); + Assert.Empty(executed); + + scheduler.AdvanceByMs(101); + Assert.Equal(2, executed.Count); + Assert.True(fixture.IsExecuting.FirstAsync().Wait()); + + scheduler.AdvanceByMs(200); + Assert.Equal(3, executed.Count); + Assert.True(fixture.IsExecuting.FirstAsync().Wait()); + + scheduler.AdvanceByMs(100); + Assert.Equal(4, executed.Count); + Assert.False(fixture.IsExecuting.FirstAsync().Wait()); + }); + + /// + /// Executes the is available via ICommand. + /// + [Fact] + public void ExecuteIsAvailableViaICommand() + { + var executed = false; + ICommand fixture = RxCommand.Create( + () => + { + executed = true; + return Observable.Return(Unit.Default); + }, + outputScheduler: ImmediateScheduler.Instance); + + fixture.Execute(null); + executed.Should().BeTrue(); + } + + /// + /// Executes the passes through parameter. + /// + [Fact] + public void ExecutePassesThroughParameter() + { + var parameters = new List(); + var fixture = RxCommand.Create( + param => + { + parameters.Add(param); + return Observable.Return(Unit.Default); + }, + outputScheduler: ImmediateScheduler.Instance); + + fixture.Execute(1).Subscribe(); + fixture.Execute(42).Subscribe(); + fixture.Execute(348).Subscribe(); + + parameters + .Should() + .HaveCount(3) + .And + .Subject + .Should() + .SatisfyRespectively( + first => first.Should().Be(1), + second => second.Should().Be(42), + third => third.Should().Be(348)); + } + + /// + /// Executes the reenables execution even after failure. + /// + [Fact] + public void ExecuteReenablesExecutionEvenAfterFailure() + { + var fixture = RxCommand.Create(() => Observable.Throw(new InvalidOperationException("oops")), outputScheduler: ImmediateScheduler.Instance); + fixture.CanCommandExecute.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var canExecute).Subscribe(); + fixture.ThrownExceptions.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var thrownExceptions) + .Subscribe(); + + fixture.Execute().Subscribe(_ => { }, _ => { }); + + Assert.Equal(1, thrownExceptions.Count); + Assert.Equal("oops", thrownExceptions[0].Message); + + canExecute + .Should() + .HaveCount(3) + .And + .Subject + .Should() + .SatisfyRespectively( + first => first.Should().BeTrue(), + second => second.Should().BeFalse(), + third => third.Should().BeTrue()); + } + + /// + /// Executes the result is delivered on specified scheduler. + /// + [Fact] + public void ExecuteResultIsDeliveredOnSpecifiedScheduler() => + new TestScheduler().With( + scheduler => + { + var execute = Observable.Return(Unit.Default); + var fixture = RxCommand.Create(() => execute, outputScheduler: scheduler); + var executed = false; + + fixture.Execute().ObserveOn(scheduler).Subscribe(_ => executed = true); + + executed.Should().BeFalse(); + scheduler.AdvanceByMs(1); + executed.Should().BeTrue(); + }); + + /// + /// Executes the ticks any exception. + /// + [Fact] + public void ExecuteTicksAnyException() + { + var fixture = RxCommand.Create(() => Observable.Throw(new InvalidOperationException()), outputScheduler: ImmediateScheduler.Instance); + fixture.ThrownExceptions.Subscribe(); + Exception? exception = null; + fixture.Execute().Subscribe(_ => { }, ex => exception = ex, () => { }); + + exception.Should().BeOfType(); + } + + /// + /// Executes the ticks any lambda exception. + /// + [Fact] + public void ExecuteTicksAnyLambdaException() + { + Unit Execute() => throw new InvalidOperationException(); + var fixture = RxCommand.Create(Execute, outputScheduler: ImmediateScheduler.Instance); + fixture.ThrownExceptions.Subscribe(); + Exception? exception = null; + fixture.Execute().Subscribe(_ => { }, ex => exception = ex, () => { }); + + exception.Should().BeOfType(); + } + + /// + /// Executes the ticks errors through thrown exceptions. + /// + [Fact] + public void ExecuteTicksErrorsThroughThrownExceptions() + { + var fixture = RxCommand.Create(() => Observable.Throw(new InvalidOperationException("oops")), outputScheduler: ImmediateScheduler.Instance); + fixture.ThrownExceptions.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var thrownExceptions) + .Subscribe(); + + fixture.Execute().Subscribe(_ => { }, _ => { }); + + thrownExceptions.Should().ContainSingle(x => x.Message == "oops"); + } + + /// + /// Executes the ticks lambda errors through thrown exceptions. + /// + [Fact] + public void ExecuteTicksLambdaErrorsThroughThrownExceptions() + { + Unit Execute() => throw new InvalidOperationException("oops"); + var fixture = RxCommand.Create(Execute, outputScheduler: ImmediateScheduler.Instance); + fixture.ThrownExceptions.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var thrownExceptions) + .Subscribe(); + + fixture.Execute().Subscribe(_ => { }, _ => { }); + + Assert.Equal(1, thrownExceptions.Count); + Assert.Equal("oops", thrownExceptions[0].Message); + Assert.True(fixture.CanCommandExecute.FirstAsync().Wait()); + } + + /// + /// Executes the ticks through the result. + /// + [Fact] + public void ExecuteTicksThroughTheResult() + { + var num = 0; + var fixture = RxCommand.Create(() => Observable.Return(num), outputScheduler: ImmediateScheduler.Instance); + fixture.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var results).Subscribe(); + + num = 1; + fixture.Execute().Subscribe(); + num = 10; + fixture.Execute().Subscribe(); + num = 30; + fixture.Execute().Subscribe(); + + results + .Should() + .HaveCount(3) + .And + .Subject + .Should() + .SatisfyRespectively( + first => first.Should().Be(1), + second => second.Should().Be(10), + third => third.Should().Be(30)); + } + + /// + /// Executes via ICommand throws if parameter type is incorrect. + /// + [Fact] + public void ExecuteViaICommandThrowsIfParameterTypeIsIncorrect() + { + ICommand fixture = RxCommand.Create(_ => { }, outputScheduler: ImmediateScheduler.Instance); + var ex = Assert.Throws(() => fixture.Execute("foo")); + Assert.Equal("Command requires parameters of type System.Int32, but received parameter of type System.String.", ex.Message); + + fixture = RxCommand.Create(_ => { }); + ex = Assert.Throws(() => fixture.Execute(13)); + + ex.Message.Should() + .Be("Command requires parameters of type System.String, but received parameter of type System.Int32."); + } + + /// + /// Executes via ICommand works with nullable types. + /// + [Fact] + public void ExecuteViaICommandWorksWithNullableTypes() + { + int? value = null; + ICommand fixture = + RxCommand.Create(param => value = param, outputScheduler: ImmediateScheduler.Instance); + + fixture.Execute(42); + value.Should().Be(42); + + fixture.Execute(null); + value.Should().BeNull(); + } + + /// + /// Test that invokes the command against ICommand in target invokes the command. + /// + [Fact] + public void InvokeCommandAgainstICommandInTargetInvokesTheCommand() + { + var executionCount = 0; + var fixture = new ICommandHolder(); + var source = new Subject(); + source.InvokeCommand(fixture, x => x.TheCommand!); + fixture.TheCommand = RxCommand.Create(() => ++executionCount, outputScheduler: ImmediateScheduler.Instance); + + source.OnNext(Unit.Default); + executionCount.Should().Be(1); + Assert.Equal(1, executionCount); + + source.OnNext(Unit.Default); + executionCount.Should().Be(2); + } + + /// + /// Test that invokes the command against ICommand in target passes the specified value to can execute and execute. + /// + [Fact] + public void InvokeCommandAgainstICommandInTargetPassesTheSpecifiedValueToCanExecuteAndExecute() + { + var fixture = new ICommandHolder(); + var source = new Subject(); + source.InvokeCommand(fixture, x => x.TheCommand!); + var command = new FakeCommand(); + fixture.TheCommand = command; + + source.OnNext(42); + command.ExecuteParameter.Should().Be(42); + command.CanExecuteParameter.Should().Be(42); + } + + /// + /// Test that invokes the command against ICommand in target passes the specified value to can execute and execute. + /// + [Fact] + public void InvokeCommandAgainstICommandInNullableTargetPassesTheSpecifiedValueToCanExecuteAndExecute() + { + var fixture = new ICommandHolder(); + var source = new Subject(); + source.InvokeCommand(fixture, x => x.TheCommand); + var command = new FakeCommand(); + fixture.TheCommand = command; + + source.OnNext(42); + command.ExecuteParameter.Should().Be(42); + command.CanExecuteParameter.Should().Be(42); + } + + /// + /// Test that invokes the command against i command in target respects can execute. + /// + [Fact] + public void InvokeCommandAgainstICommandInTargetRespectsCanExecute() + { + var executed = false; + var canExecute = new BehaviorSubject(false); + var fixture = new ICommandHolder(); + var source = new Subject(); + source.InvokeCommand(fixture, x => x.TheCommand!); + fixture.TheCommand = RxCommand.Create(() => executed = true, canExecute, ImmediateScheduler.Instance); + + source.OnNext(Unit.Default); + executed.Should().BeFalse(); + + canExecute.OnNext(true); + source.OnNext(Unit.Default); + executed.Should().BeTrue(); + } + + /// + /// Test that invokes the command against i command in target respects can execute. + /// + [Fact] + public void InvokeCommandAgainstICommandInNullableTargetRespectsCanExecute() + { + var executed = false; + var canExecute = new BehaviorSubject(false); + var fixture = new ICommandHolder(); + var source = new Subject(); + source.InvokeCommand(fixture, x => x.TheCommand); + fixture.TheCommand = RxCommand.Create(() => executed = true, canExecute, ImmediateScheduler.Instance); + + source.OnNext(Unit.Default); + executed.Should().BeFalse(); + + canExecute.OnNext(true); + source.OnNext(Unit.Default); + executed.Should().BeTrue(); + } + + /// + /// Test that invokes the command against ICommand in target respects can execute window. + /// + [Fact] + public void InvokeCommandAgainstICommandInTargetRespectsCanExecuteWindow() + { + var executed = false; + var canExecute = new BehaviorSubject(false); + var fixture = new ICommandHolder(); + var source = new Subject(); + source.InvokeCommand(fixture, x => x.TheCommand!); + fixture.TheCommand = RxCommand.Create(() => executed = true, canExecute, ImmediateScheduler.Instance); + + source.OnNext(Unit.Default); + executed.Should().BeFalse(); + + // The execution window re-opens, but the above execution request should not be instigated because + // it occurred when the window was closed. Execution requests do not queue up when the window is closed. + canExecute.OnNext(true); + executed.Should().BeFalse(); + } + + /// + /// Test that invokes the command against ICommand in target swallows exceptions. + /// + [Fact] + public void InvokeCommandAgainstICommandInTargetSwallowsExceptions() + { + var count = 0; + var fixture = new ICommandHolder(); + var command = RxCommand.Create( + () => + { + ++count; + throw new InvalidOperationException(); + }, + outputScheduler: ImmediateScheduler.Instance); + command.ThrownExceptions.Subscribe(); + fixture.TheCommand = command; + var source = new Subject(); + source.InvokeCommand(fixture, x => x.TheCommand!); + + source.OnNext(Unit.Default); + source.OnNext(Unit.Default); + + count.Should().Be(2); + } + + /// + /// Test that invokes the command against ICommand invokes the command. + /// + [Fact] + public void InvokeCommandAgainstICommandInvokesTheCommand() + { + var executionCount = 0; + ICommand fixture = RxCommand.Create(() => ++executionCount, outputScheduler: ImmediateScheduler.Instance); + var source = new Subject(); + source.InvokeCommand(fixture); + + source.OnNext(Unit.Default); + executionCount.Should().Be(1); + + source.OnNext(Unit.Default); + executionCount.Should().Be(2); + } + + /// + /// Test that invokes the command against ICommand invokes the command. + /// + [Fact] + public void InvokeCommandAgainstNullableICommandInvokesTheCommand() + { + var executionCount = 0; + ICommand fixture = RxCommand.Create(() => ++executionCount, outputScheduler: ImmediateScheduler.Instance); + var source = new Subject(); + source.InvokeCommand(fixture); + + source.OnNext(Unit.Default); + executionCount.Should().Be(1); + + source.OnNext(Unit.Default); + executionCount.Should().Be(2); + } + + /// + /// Test that invokes the command against ICommand passes the specified value to can execute and execute. + /// + [Fact] + public void InvokeCommandAgainstICommandPassesTheSpecifiedValueToCanExecuteAndExecute() + { + var fixture = new FakeCommand(); + var source = new Subject(); + source.InvokeCommand(fixture); + + source.OnNext(42); + + fixture.ExecuteParameter.Should().Be(42); + fixture.CanExecuteParameter.Should().Be(42); + } + + /// + /// Test that invokes the command against ICommand respects can execute. + /// + [Fact] + public void InvokeCommandAgainstICommandRespectsCanExecute() + { + var executed = false; + var canExecute = new BehaviorSubject(false); + ICommand fixture = RxCommand.Create(() => executed = true, canExecute, ImmediateScheduler.Instance); + var source = new Subject(); + source.InvokeCommand(fixture); + + source.OnNext(Unit.Default); + executed.Should().BeFalse(); + + canExecute.OnNext(true); + source.OnNext(Unit.Default); + executed.Should().BeTrue(); + } + + /// + /// Test that invokes the command against ICommand respects can execute window. + /// + [Fact] + public void InvokeCommandAgainstICommandRespectsCanExecuteWindow() + { + var executed = false; + var canExecute = new BehaviorSubject(false); + ICommand fixture = RxCommand.Create(() => executed = true, canExecute, ImmediateScheduler.Instance); + var source = new Subject(); + source.InvokeCommand(fixture); + + source.OnNext(Unit.Default); + executed.Should().BeFalse(); + + // The execution window re-opens, but the above execution request should not be instigated because + // it occurred when the window was closed. Execution requests do not queue up when the window is closed. + canExecute.OnNext(true); + executed.Should().BeFalse(); + } + + /// + /// Test that invokes the command against ICommand swallows exceptions. + /// + [Fact] + public void InvokeCommandAgainstICommandSwallowsExceptions() + { + var count = 0; + var fixture = RxCommand.Create( + () => + { + ++count; + throw new InvalidOperationException(); + }, + outputScheduler: ImmediateScheduler.Instance); + fixture.ThrownExceptions.Subscribe(); + var source = new Subject(); + source.InvokeCommand((ICommand)fixture); + + source.OnNext(Unit.Default); + source.OnNext(Unit.Default); + + count.Should().Be(2); + } + + /// + /// Test that invokes the command against reactive command in target invokes the command. + /// + [Fact] + public void InvokeCommandAgainstRxCommandInTargetInvokesTheCommand() + { + var executionCount = 0; + var fixture = new RxCommandHolder(); + var source = new Subject(); + source.InvokeCommand(fixture, x => x.TheCommand!); + fixture.TheCommand = RxCommand.Create(_ => ++executionCount, outputScheduler: ImmediateScheduler.Instance); + + source.OnNext(0); + executionCount.Should().Be(1); + + source.OnNext(0); + executionCount.Should().Be(2); + } + + /// + /// Test that invokes the command against reactive command in target passes the specified value to execute. + /// + [Fact] + public void InvokeCommandAgainstRxCommandInTargetPassesTheSpecifiedValueToExecute() + { + var executeReceived = 0; + var fixture = new RxCommandHolder(); + var source = new Subject(); + source.InvokeCommand(fixture, x => x.TheCommand!); + fixture.TheCommand = + RxCommand.Create(x => executeReceived = x, outputScheduler: ImmediateScheduler.Instance); + + source.OnNext(42); + executeReceived.Should().Be(42); + } + + /// + /// Test that invokes the command against reactive command in target respects can execute. + /// + [Fact] + public void InvokeCommandAgainstRxCommandInTargetRespectsCanExecute() + { + var executed = false; + var canExecute = new BehaviorSubject(false); + var fixture = new RxCommandHolder(); + var source = new Subject(); + source.InvokeCommand(fixture, x => x.TheCommand!); + fixture.TheCommand = RxCommand.Create(_ => executed = true, canExecute, ImmediateScheduler.Instance); + + source.OnNext(0); + executed.Should().BeFalse(); + + canExecute.OnNext(true); + source.OnNext(0); + executed.Should().BeTrue(); + } + + /// + /// Test that invokes the command against reactive command in target respects can execute window. + /// + [Fact] + public void InvokeCommandAgainstRxCommandInTargetRespectsCanExecuteWindow() + { + var executed = false; + var canExecute = new BehaviorSubject(false); + var fixture = new RxCommandHolder(); + var source = new Subject(); + source.InvokeCommand(fixture, x => x.TheCommand!); + fixture.TheCommand = RxCommand.Create(_ => executed = true, canExecute, ImmediateScheduler.Instance); + + source.OnNext(0); + executed.Should().BeFalse(); + + // The execution window re-opens, but the above execution request should not be instigated because + // it occurred when the window was closed. Execution requests do not queue up when the window is closed. + canExecute.OnNext(true); + executed.Should().BeFalse(); + } + + /// + /// Test that invokes the command against reactive command in target swallows exceptions. + /// + [Fact] + public void InvokeCommandAgainstRxCommandInTargetSwallowsExceptions() + { + var count = 0; + var fixture = new RxCommandHolder() + { + TheCommand = RxCommand.Create( + _ => + { + ++count; + throw new InvalidOperationException(); + }, + outputScheduler: ImmediateScheduler.Instance) + }; + fixture.TheCommand.ThrownExceptions.Subscribe(); + var source = new Subject(); + source.InvokeCommand(fixture, x => x.TheCommand!); + + source.OnNext(0); + source.OnNext(0); + + count.Should().Be(2); + } + + /// + /// Test that invokes the command against reactive command invokes the command. + /// + [Fact] + public void InvokeCommandAgainstRxCommandInvokesTheCommand() + { + var executionCount = 0; + var fixture = RxCommand.Create(() => ++executionCount, outputScheduler: ImmediateScheduler.Instance); + var source = new Subject(); + source.InvokeCommand(fixture); + + source.OnNext(Unit.Default); + executionCount.Should().Be(1); + + source.OnNext(Unit.Default); + executionCount.Should().Be(2); + } + + /// + /// Test that invokes the command against reactive command passes the specified value to execute. + /// + [Fact] + public void InvokeCommandAgainstRxCommandPassesTheSpecifiedValueToExecute() + { + var executeReceived = 0; + var fixture = RxCommand.Create(x => executeReceived = x, outputScheduler: ImmediateScheduler.Instance); + var source = new Subject(); + source.InvokeCommand(fixture); + + source.OnNext(42); + executeReceived.Should().Be(42); + } + + /// + /// Test that invokes the command against reactive command respects can execute. + /// + [Fact] + public void InvokeCommandAgainstRxCommandRespectsCanExecute() + { + var executed = false; + var canExecute = new BehaviorSubject(false); + var fixture = RxCommand.Create(() => executed = true, canExecute, ImmediateScheduler.Instance); + var source = new Subject(); + source.InvokeCommand(fixture); + + source.OnNext(Unit.Default); + executed.Should().BeFalse(); + + canExecute.OnNext(true); + source.OnNext(Unit.Default); + executed.Should().BeTrue(); + } + + /// + /// Test that invokes the command against reactive command respects can execute window. + /// + [Fact] + public void InvokeCommandAgainstRxCommandRespectsCanExecuteWindow() + { + var executed = false; + var canExecute = new BehaviorSubject(false); + var fixture = RxCommand.Create(() => executed = true, canExecute, outputScheduler: ImmediateScheduler.Instance); + var source = new Subject(); + source.InvokeCommand(fixture); + + source.OnNext(Unit.Default); + executed.Should().BeFalse(); + + // The execution window re-opens, but the above execution request should not be instigated because + // it occurred when the window was closed. Execution requests do not queue up when the window is closed. + canExecute.OnNext(true); + executed.Should().BeFalse(); + } + + /// + /// Test that invokes the command against reactive command swallows exceptions. + /// + [Fact] + public void InvokeCommandAgainstRxCommandSwallowsExceptions() + { + var count = 0; + var fixture = RxCommand.Create( + () => + { + ++count; + throw new InvalidOperationException(); + }, + outputScheduler: ImmediateScheduler.Instance); + fixture.ThrownExceptions.Subscribe(); + var source = new Subject(); + source.InvokeCommand(fixture); + + source.OnNext(Unit.Default); + source.OnNext(Unit.Default); + + count.Should().Be(2); + } + + /// + /// Test that invokes the command works even if the source is cold. + /// + [Fact] + public void InvokeCommandWorksEvenIfTheSourceIsCold() + { + var executionCount = 0; + var fixture = RxCommand.Create(() => ++executionCount, outputScheduler: ImmediateScheduler.Instance); + var source = Observable.Return(Unit.Default); + source.InvokeCommand(fixture); + + executionCount.Should().Be(1); + } + + /// + /// Test that determines whether [is executing is behavioral]. + /// + [Fact] + public void IsExecutingIsBehavioral() + { + var fixture = RxCommand.Create( + () => Observable.Return(Unit.Default), + outputScheduler: ImmediateScheduler.Instance); + fixture.IsExecuting.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var isExecuting).Subscribe(); + + isExecuting.Should().ContainSingle(x => !x); + } + + /// + /// Test that determines whether [is executing remains true as long as execution pipeline has not completed]. + /// + [Fact] + public void IsExecutingRemainsTrueAsLongAsExecutionPipelineHasNotCompleted() + { + var execute = new Subject(); + var fixture = RxCommand.Create(() => execute, outputScheduler: ImmediateScheduler.Instance); + + fixture.Execute().Subscribe(); + + Assert.True(fixture.IsExecuting.FirstAsync().Wait()); + + execute.OnNext(Unit.Default); + Assert.True(fixture.IsExecuting.FirstAsync().Wait()); + + execute.OnNext(Unit.Default); + Assert.True(fixture.IsExecuting.FirstAsync().Wait()); + + execute.OnCompleted(); + Assert.False(fixture.IsExecuting.FirstAsync().Wait()); + } + + /// + /// Test that determines whether [is executing ticks as executions progress]. + /// + [Fact] + public void IsExecutingTicksAsExecutionsProgress() => + new TestScheduler().With( + scheduler => + { + var execute = Observable.Return(Unit.Default).Delay(TimeSpan.FromSeconds(1), scheduler); + var fixture = RxCommand.Create(() => execute, outputScheduler: scheduler); + fixture.IsExecuting.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var isExecuting) + .Subscribe(); + + fixture.Execute().Subscribe(); + scheduler.AdvanceByMs(100); + + isExecuting.Should().HaveCount(2); + isExecuting.Should().SatisfyRespectively( + first => first.Should().BeFalse(), + second => second.Should().BeTrue()); + + scheduler.AdvanceByMs(901); + + isExecuting.Should().HaveCount(3); + Assert.False(isExecuting[2]); + }); + + /// + /// Results the is ticked through specified scheduler. + /// + [Fact] + public void ResultIsTickedThroughSpecifiedScheduler() => + new TestScheduler().With( + scheduler => + { + var fixture = RxCommand.Create(() => Observable.Return(Unit.Default), outputScheduler: scheduler); + fixture.ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var results).Subscribe(); + + fixture.Execute().Subscribe(); + results.Should().BeEmpty(); + + scheduler.AdvanceByMs(1); + results.Should().HaveCount(1); + }); + + /// + /// Synchronizes the command execute lazily. + /// + [Fact] + public void SynchronousCommandExecuteLazily() + { + var executionCount = 0; + var fixture1 = RxCommand.Create(() => ++executionCount, outputScheduler: ImmediateScheduler.Instance); + var fixture2 = RxCommand.Create(_ => ++executionCount, outputScheduler: ImmediateScheduler.Instance); + var fixture3 = RxCommand.Create( + () => + { + ++executionCount; + return 42; + }, + outputScheduler: ImmediateScheduler.Instance); + var fixture4 = RxCommand.Create( + _ => + { + ++executionCount; + return 42; + }, + outputScheduler: ImmediateScheduler.Instance); + var execute1 = fixture1.Execute(); + var execute2 = fixture2.Execute(); + var execute3 = fixture3.Execute(); + var execute4 = fixture4.Execute(); + + executionCount.Should().Be(0); + + execute1.Subscribe(); + executionCount.Should().Be(1); + + execute2.Subscribe(); + executionCount.Should().Be(2); + + execute3.Subscribe(); + executionCount.Should().Be(3); + + execute4.Subscribe(); + executionCount.Should().Be(4); + } + + /// + /// Synchronizes the commands fail correctly. + /// + [Fact] + public void SynchronousCommandsFailCorrectly() + { + var fixture1 = RxCommand.Create( + () => throw new InvalidOperationException(), + outputScheduler: ImmediateScheduler.Instance); + var fixture2 = RxCommand.Create( + _ => throw new InvalidOperationException(), + outputScheduler: ImmediateScheduler.Instance); + var fixture3 = RxCommand.Create( + () => throw new InvalidOperationException(), + outputScheduler: ImmediateScheduler.Instance); + Func execute = _ => throw new InvalidOperationException(); + var fixture4 = RxCommand.Create( + execute, + outputScheduler: ImmediateScheduler.Instance); + + var failureCount = 0; + Observable.Merge( + fixture1.ThrownExceptions, + fixture2.ThrownExceptions, + fixture3.ThrownExceptions, + fixture4.ThrownExceptions) + .Subscribe(_ => ++failureCount); + + fixture1.Execute().Subscribe(_ => { }, _ => { }); + + failureCount.Should().Be(1); + + fixture2.Execute().Subscribe(_ => { }, _ => { }); + failureCount.Should().Be(2); + + fixture3.Execute().Subscribe(_ => { }, _ => { }); + failureCount.Should().Be(3); + + fixture4.Execute().Subscribe(_ => { }, _ => { }); + failureCount.Should().Be(4); + } + + /// + /// Tests RxCommand from an invoke command. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task RxCommandCreateHandlesTaskExceptionAsync() + { + var subj = new Subject(); + var isExecuting = false; + Exception? fail = null; + + async Task Execute() + { + await subj.Take(1); + throw new Exception("break execution"); + } + + var fixture = RxCommand.Create(Execute, outputScheduler: ImmediateScheduler.Instance); + + fixture.IsExecuting.Subscribe(x => isExecuting = x); + fixture.ThrownExceptions.Subscribe(ex => fail = ex); + + isExecuting.Should().BeFalse(); + fail.Should().BeNull(); + + fixture.Execute().Subscribe(); + isExecuting.Should().BeTrue(); + fail.Should().BeNull(); + + subj.OnNext(Unit.Default); + + // Wait 10 ms to allow execution to complete + await Task.Delay(10).ConfigureAwait(false); + + isExecuting.Should().BeFalse(); + fail?.Message.Should().NotBeNull().And.Subject.Should().Be("break execution"); + } + + /// + /// Tests RxCommand from an invoke command. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task RxCommandExecutesFromInvokeCommand() + { + var semaphore = new SemaphoreSlim(0); + var command = RxCommand.Create(() => semaphore.Release()); + + Observable.Return(Unit.Default) + .InvokeCommand(command); + + var result = 0; + var task = semaphore.WaitAsync(); + if (await Task.WhenAny(Task.Delay(TimeSpan.FromMilliseconds(100)), task).ConfigureAwait(true) == task) + { + result = 1; + } + else + { + result = -1; + } + + await Task.Delay(200).ConfigureAwait(false); + result.Should().Be(1); + } + + /// + /// Tests RxCommand can dispose self. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task RxCommandCanDisposeSelf() + { + var command = RxCommand.Create(() => new object()); + var waiter = WaitForValueAsync(); + command.Dispose(); + Assert.Null(await waiter.ConfigureAwait(false)); + + async Task WaitForValueAsync() => await command.FirstOrDefaultAsync(); + } + + /// + /// Tests RxCommand can dispose self. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task RxCommandCanDisposeSelfAfterSubscribe() + { + var command = RxCommand.Create(() => "RxCommand", outputScheduler: ImmediateScheduler.Instance); + var disposables = new CompositeDisposable + { + command + }; + var waiter = WaitForValueAsync(); + var valueRecieved = false; + string? executeStringRecieved = null; + string? stringRecieved = null; + disposables.Add(command.Subscribe( + s => + { + stringRecieved = s; + valueRecieved = true; + }, + () => valueRecieved = true)); + disposables.Add(command.Execute().Subscribe(s => executeStringRecieved = s)); + Assert.True(valueRecieved); + Assert.Equal("RxCommand", executeStringRecieved); + Assert.Equal("RxCommand", stringRecieved); + disposables.Dispose(); + Assert.Equal("RxCommand", await waiter.ConfigureAwait(false)); + + async Task WaitForValueAsync() => await command.FirstOrDefaultAsync(); + } +} diff --git a/src/ReactiveMarbles.Command.sln b/src/ReactiveMarbles.Command.sln new file mode 100644 index 0000000..4d76651 --- /dev/null +++ b/src/ReactiveMarbles.Command.sln @@ -0,0 +1,42 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33213.308 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactiveMarbles.Command", "ReactiveMarbles.Command\ReactiveMarbles.Command.csproj", "{19AA5FBF-A095-4459-A523-03E5221AEF0F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactiveMarbles.Command.Tests", "ReactiveMarbles.Command.Tests\ReactiveMarbles.Command.Tests.csproj", "{7D1573F5-9CEF-49EB-B653-87857230495A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionConfig", "SolutionConfig", "{C1E0030B-4B47-4DC4-8BA4-D5FF16EB6BB7}" + ProjectSection(SolutionItems) = preProject + ..\.editorconfig = ..\.editorconfig + directory.build.props = directory.build.props + directory.build.targets = directory.build.targets + directory.packages.props = directory.packages.props + ..\LICENSE = ..\LICENSE + stylecop.json = stylecop.json + ..\version.json = ..\version.json + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {19AA5FBF-A095-4459-A523-03E5221AEF0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19AA5FBF-A095-4459-A523-03E5221AEF0F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19AA5FBF-A095-4459-A523-03E5221AEF0F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19AA5FBF-A095-4459-A523-03E5221AEF0F}.Release|Any CPU.Build.0 = Release|Any CPU + {7D1573F5-9CEF-49EB-B653-87857230495A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7D1573F5-9CEF-49EB-B653-87857230495A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D1573F5-9CEF-49EB-B653-87857230495A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7D1573F5-9CEF-49EB-B653-87857230495A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {FCE43891-1767-4F50-8FCC-E46362D624CF} + EndGlobalSection +EndGlobal diff --git a/src/ReactiveMarbles.Command/IRxCommand.cs b/src/ReactiveMarbles.Command/IRxCommand.cs new file mode 100644 index 0000000..5396c76 --- /dev/null +++ b/src/ReactiveMarbles.Command/IRxCommand.cs @@ -0,0 +1,114 @@ +// Copyright (c) 2019-2022 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Windows.Input; + +namespace ReactiveMarbles.Command; + +/// +/// Encapsulates a user action behind a reactive interface. +/// This is for interop inside for the command binding. +/// Not meant for external use due to the fact it doesn't implement ICommand +/// to force the user to favor the Reactive style command execution. +/// +public interface IRxCommand : ICommand, IDisposable +{ + /// + /// Gets an observable whose value indicates whether the command is currently executing. + /// + /// + /// This observable can be particularly useful for updating UI, such as showing an activity indicator whilst a command + /// is executing. + /// + IObservable IsExecuting { get; } + + /// + /// Gets an observable whose value indicates whether the command can currently execute. + /// + /// + /// The value provided by this observable is governed both by any canExecute observable provided during + /// command creation, as well as the current execution status of the command. A command that is currently executing + /// will always yield false from this observable, even if the canExecute pipeline is currently true. + /// + IObservable CanCommandExecute { get; } + + /// + /// Gets an observable that ticks any exceptions in command execution logic. + /// + /// + /// Any exceptions that are not observed via this observable will propagate out and cause the application to be torn + /// down. Therefore, you will always want to subscribe to this observable if you expect errors could occur (e.g. if + /// your command execution includes network activity). + /// + IObservable ThrownExceptions { get; } +} + +/// +/// Encapsulates a user action behind a reactive interface. +/// This is for interop inside for the command binding. +/// Not meant for external use due to the fact it doesn't implement ICommand +/// to force the user to favor the Reactive style command execution. +/// +/// The parameter type passed to the command. +/// The result type from the command. +public interface IRxCommand : IRxCommand, IObservable +{ + /// + /// Gets an observable that, when subscribed, executes this command. + /// + /// + /// + /// Invoking this method will return a cold (lazy) observable that, when subscribed, will execute the logic + /// encapsulated by the command. It is worth restating that the returned observable is lazy. Nothing will + /// happen if you call Execute and neglect to subscribe (directly or indirectly) to the returned observable. + /// + /// + /// If no parameter value is provided, a default value of type will be passed into + /// the execution logic. + /// + /// + /// Any number of subscribers can subscribe to a given execution observable and the execution logic will only + /// run once. That is, the result is broadcast to those subscribers. + /// + /// + /// In those cases where execution fails, there will be no result value. Instead, the failure will tick through the + /// observable. + /// + /// + /// + /// An observable that will tick the single result value if and when it becomes available. + /// + IObservable Execute(); + + /// + /// Gets an observable that, when subscribed, executes this command. + /// + /// + /// + /// Invoking this method will return a cold (lazy) observable that, when subscribed, will execute the logic + /// encapsulated by the command. It is worth restating that the returned observable is lazy. Nothing will + /// happen if you call Execute and neglect to subscribe (directly or indirectly) to the returned observable. + /// + /// + /// If no parameter value is provided, a default value of type will be passed into + /// the execution logic. + /// + /// + /// Any number of subscribers can subscribe to a given execution observable and the execution logic will only + /// run once. That is, the result is broadcast to those subscribers. + /// + /// + /// In those cases where execution fails, there will be no result value. Instead, the failure will tick through the + /// observable. + /// + /// + /// + /// The parameter to pass into command execution. + /// + /// + /// An observable that will tick the single result value if and when it becomes available. + /// + IObservable Execute(TParam parameter); +} diff --git a/src/ReactiveMarbles.Command/ObservableConstants.cs b/src/ReactiveMarbles.Command/ObservableConstants.cs new file mode 100644 index 0000000..5702305 --- /dev/null +++ b/src/ReactiveMarbles.Command/ObservableConstants.cs @@ -0,0 +1,19 @@ +// Copyright (c) 2019-2022 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Reactive.Linq; + +namespace ReactiveMarbles.Command; + +internal static class ObservableConstants +{ + internal static readonly IObservable True = Observable.Return(true); + internal static readonly IObservable False = Observable.Return(false); +} + +internal static class ObservableConstants +{ + internal static readonly IObservable Empty = Observable.Empty(); +} diff --git a/src/ReactiveMarbles.Command/ReactiveMarbles.Command.csproj b/src/ReactiveMarbles.Command/ReactiveMarbles.Command.csproj new file mode 100644 index 0000000..61b8419 --- /dev/null +++ b/src/ReactiveMarbles.Command/ReactiveMarbles.Command.csproj @@ -0,0 +1,18 @@ + + + + netstandard2.0;net6.0;net7.0 + enable + latest + + + + + + + + + <_Parameter1>$(AssemblyName).Tests + + + diff --git a/src/ReactiveMarbles.Command/RxCommand.cs b/src/ReactiveMarbles.Command/RxCommand.cs new file mode 100644 index 0000000..72b6bef --- /dev/null +++ b/src/ReactiveMarbles.Command/RxCommand.cs @@ -0,0 +1,850 @@ +// Copyright (c) 2019-2022 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Reactive.Threading.Tasks; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; +using ReactiveMarbles.Locator; +using ReactiveMarbles.Mvvm; + +// ReSharper disable StaticMemberInGenericType +namespace ReactiveMarbles.Command; + +/// +/// Encapsulates a user action behind a reactive interface. +/// +/// +/// +/// This non-generic base class defines the creation behavior of the RxCommand's. +/// +/// +/// adds the concept of Input and Output generic types. +/// The Input is often passed in by the View and it's type is captured as TInput, and the Output is +/// the result of executing the command which type is captured as TOutput. +/// +/// +/// is IObservable which can be used like any other IObservable. +/// For example, you can Subscribe() to it like any other observable, and add the output to a List on your view model. +/// The Unit type is a functional programming construct analogous to void and can be used in cases where you don't +/// care about either the input and/or output value. +/// +/// +/// Creating synchronous reactive commands: +/// +/// command = RxCommand.Create(x => Console.WriteLine(x)); +/// +/// // This outputs 42 to console. +/// command.Execute(42).Subscribe(); +/// +/// // A better approach is to invoke a command in response to an Observable. +/// // InvokeCommand operator respects the command's executability. That is, if +/// // the command's CanExecute method returns false, InvokeCommand will not +/// // execute the command when the source observable ticks. +/// Observable.Return(42).InvokeCommand(command); +/// ]]> +/// +/// +/// +/// Creating asynchronous reactive commands: +/// +/// ( +/// _ => Observable.Return(42).Delay(TimeSpan.FromSeconds(2)) +/// ); +/// +/// // Calling the asynchronous reactive command: +/// // Observable.Return(Unit.Default).InvokeCommand(command); +/// +/// // Subscribing to values emitted by the command: +/// command.Subscribe(Console.WriteLine); +/// ]]> +/// +/// +/// +public static class RxCommand +{ + /// + /// Creates a parameterless with synchronous execution logic. + /// + /// + /// The action to execute whenever the command is executed. + /// + /// + /// An optional observable that dictates the availability of the command for execution. + /// + /// + /// An optional scheduler that is used to surface events. Defaults to RxApp.MainThreadScheduler. + /// + /// + /// The RxCommand instance. + /// + public static RxCommand Create( + Action execute, + IObservable? canExecute = null, + IScheduler? outputScheduler = null) => + new(_ => Observable.Create( + observer => + { + execute(); + observer.OnNext(Unit.Default); + observer.OnCompleted(); + return Disposable.Empty; + }), canExecute, outputScheduler); + + /// + /// Creates a with synchronous execution logic that takes a parameter of type + /// and returns a value of type . + /// + /// + /// The function to execute whenever the command is executed. + /// + /// + /// An optional observable that dictates the availability of the command for execution. + /// + /// + /// An optional scheduler that is used to surface events. Defaults to RxApp.MainThreadScheduler. + /// + /// + /// The RxCommand instance. + /// + /// + /// The type of the parameter passed through to command execution. + /// + /// + /// The type of value returned by command executions. + /// + public static RxCommand Create( + Func execute, + IObservable? canExecute = null, + IScheduler? outputScheduler = null) => + new(execute, canExecute, outputScheduler); + + /// + /// Creates a with synchronous execution logic that takes a parameter of type . + /// + /// + /// The action to execute whenever the command is executed. + /// + /// + /// An optional observable that dictates the availability of the command for execution. + /// + /// + /// An optional scheduler that is used to surface events. Defaults to RxApp.MainThreadScheduler. + /// + /// + /// The RxCommand instance. + /// + /// + /// The type of the parameter passed through to command execution. + /// + public static RxCommand Create( + Action execute, + IObservable? canExecute = null, + IScheduler? outputScheduler = null) + { + if (execute == null) + { + throw new ArgumentNullException(nameof(execute)); + } + + return new(param => + { + execute(param); + return Unit.Default; + }, canExecute, outputScheduler); + } + + /// + /// Creates a parameterless with synchronous execution logic that returns a value + /// of type . + /// + /// + /// The function to execute whenever the command is executed. + /// + /// + /// An optional observable that dictates the availability of the command for execution. + /// + /// + /// An optional scheduler that is used to surface events. Defaults to RxApp.MainThreadScheduler. + /// + /// + /// The RxCommand instance. + /// + /// + /// The type of value returned by command executions. + /// + public static RxCommand Create( + Func execute, + IObservable? canExecute = null, + IScheduler? outputScheduler = null) + { + if (execute == null) + { + throw new ArgumentNullException(nameof(execute)); + } + + return new(_ => execute(), canExecute, outputScheduler); + } + + /// + /// Creates a parameterless with asynchronous execution logic. + /// + /// + /// Provides an observable representing the command's asynchronous execution logic. + /// + /// + /// An optional observable that dictates the availability of the command for execution. + /// + /// + /// An optional scheduler that is used to surface events. Defaults to RxApp.MainThreadScheduler. + /// + /// + /// The RxCommand instance. + /// + /// + /// The type of the command's result. + /// + public static RxCommand Create( + Func> execute, + IObservable? canExecute = null, + IScheduler? outputScheduler = null) + { + if (execute is null) + { + throw new ArgumentNullException(nameof(execute)); + } + + return new RxCommand( + _ => execute(), + canExecute ?? ObservableConstants.True, + outputScheduler); + } + + /// + /// Creates a with asynchronous execution logic that takes a parameter of type . + /// + /// + /// Provides an observable representing the command's asynchronous execution logic. + /// + /// + /// An optional observable that dictates the availability of the command for execution. + /// + /// + /// An optional scheduler that is used to surface events. Defaults to RxApp.MainThreadScheduler. + /// + /// + /// The RxCommand instance. + /// + /// + /// The type of the parameter passed through to command execution. + /// + /// + /// The type of the command's result. + /// + public static RxCommand Create( + Func> execute, + IObservable? canExecute = null, + IScheduler? outputScheduler = null) + { + if (execute is null) + { + throw new ArgumentNullException(nameof(execute)); + } + + return new RxCommand( + execute, + canExecute ?? ObservableConstants.True, + outputScheduler); + } + + /// + /// Creates a parameterless with asynchronous execution logic. + /// + /// + /// Provides a representing the command's asynchronous execution logic. + /// + /// + /// An optional observable that dictates the availability of the command for execution. + /// + /// + /// An optional scheduler that is used to surface events. Defaults to RxApp.MainThreadScheduler. + /// + /// + /// The RxCommand instance. + /// + /// + /// The type of the command's result. + /// + public static RxCommand Create( + Func> execute, + IObservable? canExecute = null, + IScheduler? outputScheduler = null) + { + if (execute is null) + { + throw new ArgumentNullException(nameof(execute)); + } + + return new RxCommand(_ => execute(), canExecute, outputScheduler); + } + + /// + /// Creates a parameterless with asynchronous execution logic. + /// + /// + /// Provides a representing the command's asynchronous execution logic. + /// + /// + /// An optional observable that dictates the availability of the command for execution. + /// + /// + /// An optional scheduler that is used to surface events. Defaults to RxApp.MainThreadScheduler. + /// + /// + /// The RxCommand instance. + /// + public static RxCommand Create( + Func execute, + IObservable? canExecute = null, + IScheduler? outputScheduler = null) + { + if (execute is null) + { + throw new ArgumentNullException(nameof(execute)); + } + + return new RxCommand( + async _ => + { + await execute().ConfigureAwait(false); + return Unit.Default; + }, + canExecute, + outputScheduler); + } + + /// + /// Creates a parameterless, cancellable with asynchronous execution logic. + /// + /// + /// Provides a representing the command's asynchronous execution logic. + /// + /// + /// An optional observable that dictates the availability of the command for execution. + /// + /// + /// An optional scheduler that is used to surface events. Defaults to RxApp.MainThreadScheduler. + /// + /// + /// The RxCommand instance. + /// + public static RxCommand Create( + Func execute, + IObservable? canExecute = null, + IScheduler? outputScheduler = null) + { + if (execute is null) + { + throw new ArgumentNullException(nameof(execute)); + } + + return new RxCommand( + async (_, ct) => + { + await execute(ct).ConfigureAwait(false); + return Unit.Default; + }, + canExecute, + outputScheduler); + } + + /// + /// Creates a with asynchronous execution logic that takes a parameter of type . + /// + /// + /// Provides a representing the command's asynchronous execution logic. + /// + /// + /// An optional observable that dictates the availability of the command for execution. + /// + /// + /// An optional scheduler that is used to surface events. Defaults to RxApp.MainThreadScheduler. + /// + /// + /// The RxCommand instance. + /// + /// + /// The type of the parameter passed through to command execution. + /// + /// + /// The type of the command's result. + /// + public static RxCommand Create( + Func> execute, + IObservable? canExecute = null, + IScheduler? outputScheduler = null) + { + if (execute is null) + { + throw new ArgumentNullException(nameof(execute)); + } + + return new RxCommand( + execute, + canExecute, + outputScheduler); + } + + /// + /// Creates a with asynchronous, cancellable execution logic that takes a parameter of type . + /// + /// + /// Provides a representing the command's asynchronous execution logic. + /// + /// + /// An optional observable that dictates the availability of the command for execution. + /// + /// + /// An optional scheduler that is used to surface events. Defaults to RxApp.MainThreadScheduler. + /// + /// + /// The RxCommand instance. + /// + /// + /// The type of the parameter passed through to command execution. + /// + /// + /// The type of the command's result. + /// + public static RxCommand Create( + Func> execute, + IObservable? canExecute = null, + IScheduler? outputScheduler = null) + { + if (execute is null) + { + throw new ArgumentNullException(nameof(execute)); + } + + return new RxCommand( + execute, + canExecute, + outputScheduler); + } + + /// + /// Creates a with asynchronous execution logic that takes a parameter of type . + /// + /// + /// Provides a representing the command's asynchronous execution logic. + /// + /// + /// An optional observable that dictates the availability of the command for execution. + /// + /// + /// An optional scheduler that is used to surface events. Defaults to RxApp.MainThreadScheduler. + /// + /// + /// The RxCommand instance. + /// + /// + /// The type of the parameter passed through to command execution. + /// + public static RxCommand Create( + Func execute, + IObservable? canExecute = null, + IScheduler? outputScheduler = null) + { + if (execute is null) + { + throw new ArgumentNullException(nameof(execute)); + } + + return new RxCommand( + param => execute(param).ToObservable(), + canExecute, + outputScheduler); + } + + /// + /// Creates a with asynchronous, cancellable execution logic that takes a parameter of type . + /// + /// + /// Provides a representing the command's asynchronous execution logic. + /// + /// + /// An optional observable that dictates the availability of the command for execution. + /// + /// + /// An optional scheduler that is used to surface events. Defaults to RxApp.MainThreadScheduler. + /// + /// + /// The RxCommand instance. + /// + /// + /// The type of the parameter passed through to command execution. + /// + public static RxCommand Create( + Func execute, + IObservable? canExecute = null, + IScheduler? outputScheduler = null) + { + if (execute is null) + { + throw new ArgumentNullException(nameof(execute)); + } + + return new RxCommand( + async (param, ct) => + { + await execute(param, ct).ConfigureAwait(false); + return Unit.Default; + }, + canExecute, + outputScheduler); + } +} + +/// +/// Encapsulates a user interaction behind a reactive interface. +/// +/// +/// The type of parameter values passed in during command execution. +/// +/// +/// The type of the values that are the result of command execution. +/// +public class RxCommand : IRxCommand +{ + private readonly IObservable _canExecute; + private readonly IDisposable _canExecuteSubscription; + private readonly ScheduledSubject _exceptions; + private readonly Func> _execute; + private readonly Subject _executionInfo; + private readonly IObservable _isExecuting; + private readonly IObservable _results; + private readonly ISubject _synchronizedExecutionInfo; + private readonly CompositeDisposable _subscriptions = new(); + private EventHandler? _canExecuteChanged; + private bool _canExecuteValue; + + /// + /// Initializes a new instance of the class. + /// + /// The Func to perform when the command is executed. + /// A observable which has a value if the command can execute. + /// The scheduler where to send output after the main execution. + /// Thrown if any dependent parameters are null. + public RxCommand( + Func execute, + IObservable? canExecute, + IScheduler? outputScheduler = null) + : this( + param => Observable.Create(observer => + { + observer.OnNext(execute(param)); + observer.OnCompleted(); + return Disposable.Empty; + }), + canExecute, + outputScheduler) + { + if (execute == null) + { + throw new ArgumentNullException(nameof(execute)); + } + } + + /// + /// Initializes a new instance of the class. + /// + /// The Func to perform when the command is executed. + /// A observable which has a value if the command can execute. + /// The scheduler where to send output after the main execution. + /// Thrown if any dependent parameters are null. + public RxCommand( + Func> execute, + IObservable? canExecute, + IScheduler? outputScheduler = null) + : this(param => Observable.FromAsync(() => execute(param)), canExecute, outputScheduler) + { + if (execute == null) + { + throw new ArgumentNullException(nameof(execute)); + } + } + + /// + /// Initializes a new instance of the class. + /// + /// The Func to perform when the command is executed. + /// A observable which has a value if the command can execute. + /// The scheduler where to send output after the main execution. + /// Thrown if any dependent parameters are null. + public RxCommand( + Func> execute, + IObservable? canExecute, + IScheduler? outputScheduler = null) + : this(param => Observable.FromAsync(ct => execute(param, ct)), canExecute, outputScheduler) + { + if (execute == null) + { + throw new ArgumentNullException(nameof(execute)); + } + } + + /// + /// Initializes a new instance of the class. + /// + /// The Func to perform when the command is executed. + /// A observable which has a value if the command can execute. + /// The scheduler where to send output after the main execution. + /// Thrown if any dependent parameters are null. + public RxCommand( + Func> execute, + IObservable? canExecute, + IScheduler? outputScheduler = null) + { + canExecute ??= ObservableConstants.True; + + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + var coreRegistration = ServiceLocator.Current().GetService(); + var scheduler = outputScheduler ?? coreRegistration.MainThreadScheduler; + _exceptions = new ScheduledSubject(scheduler, coreRegistration.ExceptionHandler); + _executionInfo = new Subject(); + _synchronizedExecutionInfo = Subject.Synchronize(_executionInfo, scheduler); + _isExecuting = _synchronizedExecutionInfo.Scan( + 0, + (acc, next) => + { + return next.Demarcation switch + { + ExecutionDemarcation.Begin => acc + 1, + ExecutionDemarcation.End => acc - 1, + _ => acc + }; + }).Select(inFlightCount => inFlightCount > 0) + .StartWith(false) + .DistinctUntilChanged() + .Replay(1) + .RefCount(); + + _canExecute = canExecute.Catch( + ex => + { + _exceptions.OnNext(ex); + return ObservableConstants.False; + }).StartWith(false) + .CombineLatest(_isExecuting, (canEx, isEx) => canEx && !isEx) + .DistinctUntilChanged() + .Replay(1) + .RefCount(); + + _results = _synchronizedExecutionInfo.Where(x => x.Demarcation == ExecutionDemarcation.Result) + .Select(x => x.Result); + + _canExecuteSubscription = _canExecute + .Subscribe(OnCanExecuteChanged); + } + + /// + event EventHandler? ICommand.CanExecuteChanged + { + add => _canExecuteChanged += value; + remove => _canExecuteChanged -= value; + } + + private enum ExecutionDemarcation + { + Begin, + + Result, + + End + } + + /// + public IObservable CanCommandExecute => _canExecute; + + /// + public IObservable IsExecuting => _isExecuting; + + /// + public IObservable ThrownExceptions => _exceptions.AsObservable(); + + /// + bool ICommand.CanExecute(object? parameter) => ICommandCanExecute(parameter); + + /// + void ICommand.Execute(object? parameter) => ICommandExecute(parameter); + + /// + public IObservable Execute(TParam parameter) + { + try + { + return Observable.Defer( + () => + { + _synchronizedExecutionInfo.OnNext(ExecutionInfo.CreateBegin()); + return ObservableConstants.Empty; + }).Concat(_execute(parameter)) + .Do(result => _synchronizedExecutionInfo.OnNext(ExecutionInfo.CreateResult(result))) + .Catch( + ex => + { + _synchronizedExecutionInfo.OnNext(ExecutionInfo.CreateEnd()); + _exceptions.OnNext(ex); + return Observable.Throw(ex); + }).Finally(() => _synchronizedExecutionInfo.OnNext(ExecutionInfo.CreateEnd())) + .PublishLast() + .RefCount(); + } + catch (Exception ex) + { + _synchronizedExecutionInfo.OnNext(ExecutionInfo.CreateEnd()); + _exceptions.OnNext(ex); + return Observable.Throw(ex); + } + } + + /// + public IObservable Execute() + { + try + { + return Observable.Defer( + () => + { + _synchronizedExecutionInfo.OnNext(ExecutionInfo.CreateBegin()); + return ObservableConstants.Empty; + }).Concat(_execute(default!)) + .Do(result => _synchronizedExecutionInfo.OnNext(ExecutionInfo.CreateResult(result))) + .Catch( + ex => + { + _synchronizedExecutionInfo.OnNext(ExecutionInfo.CreateEnd()); + _exceptions.OnNext(ex); + return Observable.Throw(ex); + }).Finally(() => _synchronizedExecutionInfo.OnNext(ExecutionInfo.CreateEnd())) + .PublishLast() + .RefCount(); + } + catch (Exception ex) + { + _synchronizedExecutionInfo.OnNext(ExecutionInfo.CreateEnd()); + _exceptions.OnNext(ex); + return Observable.Throw(ex); + } + } + + /// + public IDisposable Subscribe(IObserver observer) + { + var disposable = _results.Subscribe(observer); + var ret = Disposable.Create(() => + { + observer?.OnCompleted(); + disposable.Dispose(); + }); + _subscriptions.Add(ret); + return ret; + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Will trigger a event when the CanExecute condition has changed. + /// + /// The new value of the execute. + protected virtual void OnCanExecuteChanged(bool newValue) + { + _canExecuteValue = newValue; + _canExecuteChanged?.Invoke(this, EventArgs.Empty); + } + + /// + /// Dispose of the members that are disposable. + /// + /// A value that indicates whether or not is is being disposed by the dispose method. + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _executionInfo.Dispose(); + _exceptions.Dispose(); + _canExecuteSubscription.Dispose(); + _subscriptions.Dispose(); + } + + /// + /// Will be called by the methods from the ICommand interface. + /// This method is called when the Command should evaluate if it can execute. + /// + /// The parameter being passed to the ICommand. + /// If the command can be executed. + protected virtual bool ICommandCanExecute(object? parameter) => _canExecuteValue; + + /// + /// Will be called by the methods from the ICommand interface. + /// This method is called when the Command should execute. + /// + /// The parameter being passed to the ICommand. + protected virtual void ICommandExecute(object? parameter) + { + // ensure that null is coerced to default(TParam) so that commands taking value types will use a sensible default if no parameter is supplied + parameter ??= default(TParam); + + if (parameter is not null && !(parameter is TParam)) + { + throw new InvalidOperationException( + $"Command requires parameters of type {typeof(TParam).FullName}, but received parameter of type {parameter.GetType().FullName}."); + } + + var result = parameter is null ? Execute() : Execute((TParam)parameter); + + result + .Catch(ObservableConstants.Empty) + .Subscribe(); + } + + private readonly struct ExecutionInfo + { + private ExecutionInfo(ExecutionDemarcation demarcation, TResult result) + { + Demarcation = demarcation; + Result = result; + } + + public ExecutionDemarcation Demarcation { get; } + + public TResult Result { get; } + + public static ExecutionInfo CreateBegin() => new(ExecutionDemarcation.Begin, default!); + + public static ExecutionInfo CreateResult(TResult result) => + new(ExecutionDemarcation.Result, result); + + public static ExecutionInfo CreateEnd() => new(ExecutionDemarcation.End, default!); + } +} diff --git a/src/ReactiveMarbles.Command/RxCommandExtensions.cs b/src/ReactiveMarbles.Command/RxCommandExtensions.cs new file mode 100644 index 0000000..356c101 --- /dev/null +++ b/src/ReactiveMarbles.Command/RxCommandExtensions.cs @@ -0,0 +1,221 @@ +// Copyright (c) 2019-2022 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.ComponentModel; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Linq; +using System.Windows.Input; +using ReactiveMarbles.PropertyChanged; + +namespace ReactiveMarbles.Command; + +/// +/// Extension method for invoking commands. +/// +public static class RxCommandExtensions +{ + /// + /// A utility method that will pipe an Observable to an ICommand (i.e. + /// it will first call its CanExecute with the provided value, then if + /// the command can be executed, Execute() will be called). + /// + /// The type. + /// The source observable to pipe into the command. + /// The command to be executed. + /// An object that when disposes, disconnects the Observable + /// from the command. + public static IDisposable InvokeCommand(this IObservable item, ICommand? command) + { + var canExecuteChanged = Observable.FromEvent( + eventHandler => + { + void Handler(object? sender, EventArgs e) => eventHandler(Unit.Default); + return Handler; + }, + h => command!.CanExecuteChanged += h, + h => command!.CanExecuteChanged -= h) + .StartWith(Unit.Default); + + return WithLatestFromFixed(item, canExecuteChanged, (value, _) => new InvokeCommandInfo(command, command!.CanExecute(value), value)) + .Where(ii => ii.CanExecute) + .Do(ii => command?.Execute(ii.Value)) + .Subscribe(); + } + + /// + /// A utility method that will pipe an Observable to an ICommand (i.e. + /// it will first call its CanExecute with the provided value, then if + /// the command can be executed, Execute() will be called). + /// + /// The type. + /// The result type. + /// The source observable to pipe into the command. + /// The command to be executed. + /// An object that when disposes, disconnects the Observable + /// from the command. + public static IDisposable InvokeCommand(this IObservable item, RxCommand? command) => + command is null + ? throw new ArgumentNullException(nameof(command)) + : WithLatestFromFixed(item, command.CanCommandExecute, (value, canExecute) => new InvokeCommandInfo, T>(command, canExecute, value)) + .Where(ii => ii.CanExecute) + .SelectMany(ii => command.Execute(ii.Value).Catch(ObservableConstants.Empty)) + .Subscribe(); + + /// + /// A utility method that will pipe an Observable to an ICommand (i.e. + /// it will first call its CanExecute with the provided value, then if + /// the command can be executed, Execute() will be called). + /// + /// The type. + /// The target type. + /// The source observable to pipe into the command. + /// The root object which has the Command. + /// The expression to reference the Command. + /// An object that when disposes, disconnects the Observable + /// from the command. + public static IDisposable InvokeCommand(this IObservable item, TTarget? target, Expression> commandProperty) + where TTarget : class, INotifyPropertyChanged + { + if (commandProperty == null) + { + throw new ArgumentNullException(nameof(commandProperty)); + } + + var commandObs = + target.WhenChanged(commandProperty!) + .Where(x => x != null) + .Select(x => x!); + var commandCanExecuteChanged = commandObs + .Select(command => command is null + ? ObservableConstants.Empty + : Observable + .FromEvent( + eventHandler => (_, _) => eventHandler(command), + h => command.CanExecuteChanged += h, + h => command.CanExecuteChanged -= h) + .StartWith(command)) + .Switch(); + + return WithLatestFromFixed( + item, + commandCanExecuteChanged, + (value, cmd) => new InvokeCommandInfo(cmd, cmd.CanExecute(value), value)) + .Where(ii => ii.CanExecute) + .Do(ii => ii.Command.Execute(ii.Value)) + .Subscribe(); + } + + /// + /// A utility method that will pipe an Observable to an ICommand (i.e. + /// it will first call its CanExecute with the provided value, then if + /// the command can be executed, Execute() will be called). + /// + /// The type. + /// The result type. + /// The target type. + /// The source observable to pipe into the command. + /// The root object which has the Command. + /// The expression to reference the Command. + /// An object that when disposes, disconnects the Observable + /// from the command. + public static IDisposable InvokeCommand( + this IObservable item, + TTarget? target, + Expression?>> commandProperty) + where TTarget : class, INotifyPropertyChanged + { + if (commandProperty == null) + { + throw new ArgumentNullException(nameof(commandProperty)); + } + + var command = + target.WhenChanged(commandProperty!) + .Where(x => x != null) + .Select(x => x!); + var invocationInfo = command + .Select(cmd => cmd is null + ? ObservableConstants, T>>.Empty + : cmd + .CanCommandExecute + .Select(canExecute => new InvokeCommandInfo, T>(cmd, canExecute))) + .Switch(); + + return WithLatestFromFixed(item, invocationInfo, (value, ii) => ii.WithValue(value)) + .Where(ii => ii.CanExecute) + .SelectMany(ii => ii.Command.Execute(ii.Value).Catch(ObservableConstants.Empty)) + .Subscribe(); + } + + /// + /// A utility method that will pipe an IRxCommand from an Observable (i.e. + /// it will first call its CanExecute with the provided value, then if + /// the command can be executed, Execute() will be called). + /// + /// The type. + /// The result type. + /// The source observable to pipe into the command. + /// An object that when disposes, disconnects the Observable + /// from the command. + public static IDisposable InvokeCommand(this IObservable source) + where TParam : IRxCommand + { + var command = source.Where(x => x != null).Select(x => x!); + var invocationInfo = command + .Select(cmd => cmd is null + ? ObservableConstants, TParam>>.Empty + : cmd + .CanCommandExecute + .Select(canExecute => new InvokeCommandInfo, TParam>(cmd, canExecute))) + .Switch(); + + return WithLatestFromFixed(source, invocationInfo, (value, ii) => ii.WithValue(value)) + .Where(ii => ii.CanExecute) + .SelectMany(ii => ii.Command.Execute(ii.Value).Catch(ObservableConstants.Empty)) + .Subscribe(); + } + + // See https://github.com/Reactive-Extensions/Rx.NET/issues/444 + private static IObservable WithLatestFromFixed( + IObservable item, + IObservable other, + Func resultSelector) => + item + .Publish( + os => + other + .Select( + a => + os + .Select(b => resultSelector(b, a))) + .Switch()); + + private readonly struct InvokeCommandInfo + { + public InvokeCommandInfo(TCommand command, bool canExecute, TValue value) + { + Command = command; + CanExecute = canExecute; + Value = value!; + } + + public InvokeCommandInfo(TCommand command, bool canExecute) + { + Command = command; + CanExecute = canExecute; + Value = default!; + } + + public TCommand Command { get; } + + public bool CanExecute { get; } + + public TValue Value { get; } + + public InvokeCommandInfo WithValue(TValue value) => + new(Command, CanExecute, value); + } +} diff --git a/src/directory.build.props b/src/directory.build.props new file mode 100644 index 0000000..b2cf7f8 --- /dev/null +++ b/src/directory.build.props @@ -0,0 +1,65 @@ + + + ReactiveUI Association Inc + ReactiveUI Association Inc + Copyright (c) ReactiveUI Association Inc © $([System.DateTime]::Now.ToString('yyyy')) + MIT + https://github.com/reactivemarbles/Mvvm + Common base classes for the MVVM pattern for Reactive Marbles. + logo.png + glennawatson;rlittlesii + system.reactive;propertychanged;inpc;reactive;functional;mvvm + https://github.com/reactivemarbles/Mvvm/releases + https://github.com/reactivemarbles/Mvvm + git + true + enable + true + true + AnyCPU + $(MSBuildProjectName.Contains('Tests')) + embedded + + + true + + true + + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + + + + + + + + $(MSBuildThisFileDirectory) + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers + + + + + + diff --git a/src/directory.build.targets b/src/directory.build.targets new file mode 100644 index 0000000..61eaf0d --- /dev/null +++ b/src/directory.build.targets @@ -0,0 +1,44 @@ + + + + + $(AssemblyName) ($(TargetFramework)) + False + false + + + + $(DefineConstants);NETSTANDARD;PORTABLE + + + $(DefineConstants);NET_461;XAML + + + $(DefineConstants);NETFX_CORE;XAML;WINDOWS;WINDOWS_UWP + + + $(DefineConstants);MONO;UIKIT;COCOA;IOS + + + $(DefineConstants);MONO;COCOA;MAC + + + $(DefineConstants);MONO;UIKIT;COCOA;TVOS + + + $(DefineConstants);MONO;COCOA;WATCHOS + + + $(DefineConstants);MONO;ANDROID + false + + + $(DefineConstants);NETCOREAPP + + + $(DefineConstants);TIZEN + + + + + diff --git a/src/directory.packages.props b/src/directory.packages.props new file mode 100644 index 0000000..76a221b --- /dev/null +++ b/src/directory.packages.props @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/stylecop.json b/src/stylecop.json new file mode 100644 index 0000000..038eadf --- /dev/null +++ b/src/stylecop.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "indentation": { + "useTabs": false, + "indentationSize": 4 + }, + "documentationRules": { + "documentExposedElements": true, + "documentInternalElements": false, + "documentPrivateElements": false, + "documentInterfaces": true, + "documentPrivateFields": false, + "documentationCulture": "en-US", + "companyName": "ReactiveUI Association Incorporated", + "copyrightText": "Copyright (c) 2019-2022 {companyName}. All rights reserved.\n{companyName} licenses this file to you under the {licenseName} license.\nSee the {licenseFile} file in the project root for full license information.", + "variables": { + "licenseName": "MIT", + "licenseFile": "LICENSE" + }, + "xmlHeader": false + }, + "layoutRules": { + "newlineAtEndOfFile": "allow", + "allowConsecutiveUsings": true + }, + "maintainabilityRules": { + "topLevelTypes": [ + "class", + "interface", + "struct", + "enum", + "delegate" + ] + }, + "orderingRules": { + "usingDirectivesPlacement": "outsideNamespace", + "systemUsingDirectivesFirst": true + } + } +} diff --git a/version.json b/version.json new file mode 100644 index 0000000..75b0c44 --- /dev/null +++ b/version.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", + "version": "1.0", + "publicReleaseRefSpec": [ + "^refs/heads/master$", // we release out of master + "^refs/heads/main$", + "^refs/heads/latest$", + "^refs/heads/preview/.*", // we release previews + "^refs/heads/rel/\\d+\\.\\d+\\.\\d+" // we also release branches starting with rel/N.N.N + ], + "nugetPackageVersion":{ + "semVer": 2 + }, + "cloudBuild": { + "setVersionVariables": true, + "buildNumber": { + "enabled": false + } + } +}