From 83c5ff28ac8067d53af6133496b12cb1f77ce35c Mon Sep 17 00:00:00 2001 From: Joanna May Date: Sun, 9 Jun 2024 19:14:00 -0500 Subject: [PATCH 1/4] refactor: rework project, use new introspection generator, combine mixins and share infrastructure --- .github/workflows/auto_release.yaml | 75 --- .github/workflows/publish.yaml | 113 ---- .github/workflows/release.yaml | 138 +++++ .vscode/settings.json | 5 +- .../Chickensoft.AutoInject.Tests.csproj | 21 +- Chickensoft.AutoInject.Tests/coverage.sh | 5 +- Chickensoft.AutoInject.Tests/src/Dependent.cs | 518 ------------------ Chickensoft.AutoInject.Tests/src/Provider.cs | 115 ---- .../src/auto_connect/AutoConnectExtensions.cs | 29 + .../src/auto_connect/AutoConnector.cs | 165 ++++++ .../src/auto_connect/IAutoConnect.cs | 47 ++ .../src/auto_connect/NodeAttribute.cs | 24 + .../src/auto_init/IAutoInit.cs | 63 +++ .../dependent}/DependencyAttribute.cs | 0 .../dependent}/DependencyExceptions.cs | 0 .../dependent/DependencyResolver.cs | 284 ++++++++++ .../dependent/DependentExtensions.cs | 19 + .../auto_inject/dependent/DependentState.cs | 52 ++ .../src/auto_inject/dependent/IDependent.cs | 85 +++ .../auto_inject/dependent/PendingProvider.cs | 16 + .../src/auto_inject/provider/IProvide.cs | 23 + .../src/auto_inject/provider/IProvider.cs | 53 ++ .../provider/ProviderExtensions.cs | 13 + .../src/auto_inject/provider/ProviderState.cs | 55 ++ .../src/auto_node/AutoNode.cs | 29 + .../src/auto_on/IAutoOn.cs | 475 ++++++++++++++++ .../notifications/NotificationExtensions.cs | 46 ++ .../src/notifications/NotificationState.cs | 8 + .../AutoConnectInvalidCastTestScene.cs | 14 + .../AutoConnectInvalidCastTestScene.tscn | 8 + .../fixtures/AutoConnectMissingTestScene.cs | 14 + .../fixtures/AutoConnectMissingTestScene.tscn | 6 + .../test/fixtures/AutoConnectTestScene.cs | 30 + .../test/fixtures/AutoConnectTestScene.tscn | 18 + .../test/fixtures/AutoSetupTestNode.cs | 19 + .../{src/subjects => fixtures}/Dependents.cs | 38 +- .../test/fixtures/MultiProvider.cs | 8 +- .../test/fixtures/MyNode.cs | 24 + .../test/fixtures/OtherAttribute.cs | 6 + .../{src/subjects => fixtures}/Providers.cs | 14 +- .../test/src/AutoConnectInvalidCastTest.cs | 33 ++ .../test/src/AutoConnectMissingTest.cs | 18 + .../test/src/AutoConnectTest.cs | 55 ++ .../test/src/AutoInitTest.cs | 39 ++ .../test/src/AutoNodeTest.cs | 33 ++ .../test/src/AutoOnTest.cs | 168 ++++++ .../test/src/FakeNodeTreeTest.cs | 153 ++++++ .../test/src/MiscTest.cs | 42 +- .../test/src/MultiResolutionTest.cs | 6 +- .../test/src/MyNodeTest.cs | 54 ++ .../test/src/NodeAttributeTest.cs | 12 + .../test/src/NotificationExtensionsTest.cs | 17 + .../test/src/ResolutionTest.cs | 107 ++-- .../test/src/SuperNodeTest.cs | 2 + .../Chickensoft.AutoInject.csproj | 14 +- README.md | 315 +++++++++-- cspell.json | 4 + manual_build.sh | 55 ++ nuget.config | 2 +- nupkg/.gitkeep | 0 60 files changed, 2794 insertions(+), 1010 deletions(-) delete mode 100644 .github/workflows/auto_release.yaml delete mode 100644 .github/workflows/publish.yaml create mode 100644 .github/workflows/release.yaml delete mode 100644 Chickensoft.AutoInject.Tests/src/Dependent.cs delete mode 100644 Chickensoft.AutoInject.Tests/src/Provider.cs create mode 100644 Chickensoft.AutoInject.Tests/src/auto_connect/AutoConnectExtensions.cs create mode 100644 Chickensoft.AutoInject.Tests/src/auto_connect/AutoConnector.cs create mode 100644 Chickensoft.AutoInject.Tests/src/auto_connect/IAutoConnect.cs create mode 100644 Chickensoft.AutoInject.Tests/src/auto_connect/NodeAttribute.cs create mode 100644 Chickensoft.AutoInject.Tests/src/auto_init/IAutoInit.cs rename Chickensoft.AutoInject.Tests/src/{ => auto_inject/dependent}/DependencyAttribute.cs (100%) rename Chickensoft.AutoInject.Tests/src/{ => auto_inject/dependent}/DependencyExceptions.cs (100%) create mode 100644 Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependencyResolver.cs create mode 100644 Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependentExtensions.cs create mode 100644 Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependentState.cs create mode 100644 Chickensoft.AutoInject.Tests/src/auto_inject/dependent/IDependent.cs create mode 100644 Chickensoft.AutoInject.Tests/src/auto_inject/dependent/PendingProvider.cs create mode 100644 Chickensoft.AutoInject.Tests/src/auto_inject/provider/IProvide.cs create mode 100644 Chickensoft.AutoInject.Tests/src/auto_inject/provider/IProvider.cs create mode 100644 Chickensoft.AutoInject.Tests/src/auto_inject/provider/ProviderExtensions.cs create mode 100644 Chickensoft.AutoInject.Tests/src/auto_inject/provider/ProviderState.cs create mode 100644 Chickensoft.AutoInject.Tests/src/auto_node/AutoNode.cs create mode 100644 Chickensoft.AutoInject.Tests/src/auto_on/IAutoOn.cs create mode 100644 Chickensoft.AutoInject.Tests/src/notifications/NotificationExtensions.cs create mode 100644 Chickensoft.AutoInject.Tests/src/notifications/NotificationState.cs create mode 100644 Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectInvalidCastTestScene.cs create mode 100644 Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectInvalidCastTestScene.tscn create mode 100644 Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectMissingTestScene.cs create mode 100644 Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectMissingTestScene.tscn create mode 100644 Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectTestScene.cs create mode 100644 Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectTestScene.tscn create mode 100644 Chickensoft.AutoInject.Tests/test/fixtures/AutoSetupTestNode.cs rename Chickensoft.AutoInject.Tests/test/{src/subjects => fixtures}/Dependents.cs (64%) create mode 100644 Chickensoft.AutoInject.Tests/test/fixtures/MyNode.cs create mode 100644 Chickensoft.AutoInject.Tests/test/fixtures/OtherAttribute.cs rename Chickensoft.AutoInject.Tests/test/{src/subjects => fixtures}/Providers.cs (65%) create mode 100644 Chickensoft.AutoInject.Tests/test/src/AutoConnectInvalidCastTest.cs create mode 100644 Chickensoft.AutoInject.Tests/test/src/AutoConnectMissingTest.cs create mode 100644 Chickensoft.AutoInject.Tests/test/src/AutoConnectTest.cs create mode 100644 Chickensoft.AutoInject.Tests/test/src/AutoInitTest.cs create mode 100644 Chickensoft.AutoInject.Tests/test/src/AutoNodeTest.cs create mode 100644 Chickensoft.AutoInject.Tests/test/src/AutoOnTest.cs create mode 100644 Chickensoft.AutoInject.Tests/test/src/FakeNodeTreeTest.cs create mode 100644 Chickensoft.AutoInject.Tests/test/src/MyNodeTest.cs create mode 100644 Chickensoft.AutoInject.Tests/test/src/NodeAttributeTest.cs create mode 100644 Chickensoft.AutoInject.Tests/test/src/NotificationExtensionsTest.cs create mode 100644 Chickensoft.AutoInject.Tests/test/src/SuperNodeTest.cs create mode 100755 manual_build.sh delete mode 100644 nupkg/.gitkeep diff --git a/.github/workflows/auto_release.yaml b/.github/workflows/auto_release.yaml deleted file mode 100644 index a543b16..0000000 --- a/.github/workflows/auto_release.yaml +++ /dev/null @@ -1,75 +0,0 @@ -# This workflow will run whenever tests finish running. If tests pass, it will -# look at the last commit message to see if it contains the phrase -# "chore(deps): update all dependencies". -# -# If it finds a commit with that phrase, and the testing workflow has passed, -# it will automatically release a new version of the project by running the -# publish workflow. -# -# The commit message phrase above is always used by renovatebot when opening -# PR's to update dependencies. If you have renovatebot enabled and set to -# automatically merge in dependency updates, this can automatically release and -# publish the updated version of the project. -# -# You can disable this action by setting the DISABLE_AUTO_RELEASE repository -# variable to true. - -name: ๐Ÿฆพ Auto-Release -on: - workflow_run: - workflows: ["๐Ÿšฅ Tests"] - branches: - - main - types: - - completed - -jobs: - auto_release: - name: ๐Ÿฆพ Auto-Release - runs-on: ubuntu-latest - outputs: - should_release: ${{ steps.release.outputs.should_release }} - steps: - - name: ๐Ÿงพ Checkout - uses: actions/checkout@v3 - - - name: ๐Ÿง‘โ€๐Ÿ”ฌ Check Test Results - id: tests - run: | - echo "passed=${{ github.event.workflow_run.conclusion == 'success' }}" >> "$GITHUB_OUTPUT" - - - name: ๐Ÿ“„ Check If Dependencies Changed - id: deps - run: | - message=$(git log -1 --pretty=%B) - - if [[ $message == *"chore(deps): update all dependencies"* ]]; then - echo "changed=true" >> "$GITHUB_OUTPUT" - else - echo "changed=false" >> "$GITHUB_OUTPUT" - fi - - - name: ๐Ÿ“ Check Release Status - id: release - run: | - echo "Tests passed: ${{ steps.tests.outputs.passed }}" - echo "Dependencies changed: ${{ steps.deps.outputs.changed }}" - disable_auto_release='${{ vars.DISABLE_AUTO_RELEASE }}' - echo "DISABLE_AUTO_RELEASE=$disable_auto_release" - - if [[ ${{ steps.tests.outputs.passed }} == "true" && ${{ steps.deps.outputs.changed }} == "true" && $disable_auto_release != "true" ]]; then - echo "should_release=true" >> "$GITHUB_OUTPUT" - echo "๐Ÿฆพ Creating a release!" - else - echo "should_release=false" >> "$GITHUB_OUTPUT" - echo "โœ‹ Not creating a release." - fi - - release: - uses: './.github/workflows/publish.yaml' - needs: auto_release - if: needs.auto_release.outputs.should_release == 'true' - secrets: - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} - with: - bump: patch diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml deleted file mode 100644 index 21726c3..0000000 --- a/.github/workflows/publish.yaml +++ /dev/null @@ -1,113 +0,0 @@ -name: '๐Ÿ“ฆ Publish' -on: - workflow_dispatch: - branches: - - main - inputs: - bump: - description: "Version Bump Method" - type: choice - options: - - major - - minor - - patch - required: true - default: minor - workflow_call: - secrets: - NUGET_API_KEY: - description: "NuGet API Key" - required: true - inputs: - bump: - description: "Version Bump Method" - type: string - required: true - -jobs: - publish: - name: ๐Ÿ“ฆ Publish - runs-on: ubuntu-latest - steps: - - name: ๐Ÿงพ Checkout - uses: actions/checkout@v3 - with: - lfs: true - submodules: 'recursive' - - - name: ๐Ÿ”Ž Read Current Project Verson - uses: KageKirin/get-csproj-version@v1.0.0 - id: current-version - with: - file: Chickensoft.AutoInject/Chickensoft.AutoInject.csproj - xpath: /Project/PropertyGroup/Version - - - name: ๐Ÿ–จ Print Current Version - run: | - echo "Current Version: ${{ steps.current-version.outputs.version }}" - - - name: ๐Ÿงฎ Compute Next Version - uses: chickensoft-games/next-godot-csproj-version@v1 - id: next-version - with: - project-version: ${{ steps.current-version.outputs.version }} - godot-version: global.json - bump: ${{ inputs.bump }} - - - name: โœจ Print Next Version - run: | - echo "Next Version: ${{ steps.next-version.outputs.version }}" - - - name: ๐Ÿ“ Change Version - uses: vers-one/dotnet-project-version-updater@v1.3 - with: - file: "Chickensoft.AutoInject/Chickensoft.AutoInject.csproj" - version: ${{ steps.next-version.outputs.version }} - - - name: โœ๏ธ Commit Changes - run: | - git config user.name "action@github.com" - git config user.email "GitHub Action" - git commit -a -m "chore(version): update version to ${{ steps.next-version.outputs.version }}" - git push - - - name: ๐Ÿ–จ Copy Source to Source-Only package - run: | - # Copy source files from Chickensoft.AutoInject.Tests/src/**/*.cs - # to Chickensoft.AutoInject/src/**/*.cs - # - # Because source-only packages are hard to develop and test, we - # actually keep the source that goes in the source-only package inside - # the test project to make it easier to develop and test. - # - # we can always copy it right before publishing the package. - - mkdir -p Chickensoft.AutoInject/src - cp -v -r Chickensoft.AutoInject.Tests/src/* Chickensoft.AutoInject/src/ - - - - name: โœจ Create Release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release create --generate-notes "v${{ steps.next-version.outputs.version }}" - - - name: ๐Ÿ’ฝ Setup .NET SDK - uses: actions/setup-dotnet@v3 - with: - # Use the .NET SDK from global.json in the root of the repository. - global-json-file: global.json - - - name: ๐Ÿ›  Build Source-Only Package - working-directory: Chickensoft.AutoInject - run: | - dotnet build -c Release - - - name: ๐Ÿ“ฆ Publish - run: | - # find the built nuget package - nuget_package=$(find ./nupkg -name "Chickensoft.AutoInject.*.nupkg") - - echo "๐Ÿ“ฆ Publishing package: $nuget_package" - - # publish the nuget package - dotnet nuget push "$nuget_package" --api-key "${{ secrets.NUGET_API_KEY }}" --source "https://api.nuget.org/v3/index.json" --skip-duplicate diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..a07b00e --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,138 @@ +name: "๐Ÿ“ฆ Release" +on: + workflow_dispatch: + inputs: + bump: + description: "version bump method: major, minor, patch" + type: choice + options: + - major + - minor + - patch + required: true + default: patch + +jobs: + publish: + name: ๐Ÿ“ฆ Release + runs-on: ubuntu-latest + steps: + - name: ๐Ÿงพ Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GH_BASIC }} + lfs: true + submodules: "recursive" + fetch-depth: 0 # So we can get all tags. + + - name: ๐Ÿ”Ž Read Current Project Version + id: current-version + uses: WyriHaximus/github-action-get-previous-tag@v1 + with: + fallback: "0.0.0-devbuild" + + - name: ๐Ÿ–จ Print Current Version + run: | + echo "Current Version: ${{ steps.current-version.outputs.tag }}" + + - name: ๐Ÿงฎ Compute Next Version + uses: chickensoft-games/next-godot-csproj-version@v1 + id: next-version + with: + project-version: ${{ steps.current-version.outputs.tag }} + godot-version: global.json + bump: ${{ inputs.bump }} + + - name: โœจ Print Next Version + run: | + echo "Next Version: ${{ steps.next-version.outputs.version }}" + + # Write version to file so .NET will build correct version. + - name: ๐Ÿ“ Write Version to File + uses: jacobtomlinson/gha-find-replace@v3 + with: + find: "0.0.0-devbuild" + replace: ${{ steps.next-version.outputs.version }} + regex: false + include: Chickensoft.AutoInject/Chickensoft.AutoInject.csproj + + - name: ๐Ÿ–จ Copy Source to Source-Only package + run: | + # Copy source files from Chickensoft.AutoInject.Tests/src/**/*.cs + # to Chickensoft.AutoInject/src/**/*.cs + # + # Because source-only packages are hard to develop and test, we + # actually keep the source that goes in the source-only package inside + # the test project to make it easier to develop and test. + # + # we can always copy it right before publishing the package. + + mkdir -p Chickensoft.AutoInject/src + cp -v -r Chickensoft.AutoInject.Tests/src/* Chickensoft.AutoInject/src/ + + - name: ๐Ÿค Suppress Warnings From Files + run: | + # Define the multiline prefix and suffix + PREFIX="#pragma warning disable + #nullable enable + " + SUFFIX=" + #nullable restore + #pragma warning restore" + + # Function to add prefix and suffix to a file + add_prefix_suffix() { + local file="$1" + # Create a temporary file + tmp_file=$(mktemp) + + # Add prefix, content of the file, and suffix to the temporary file + { + echo "$PREFIX" + cat "$file" + echo "$SUFFIX" + } > "$tmp_file" + + # Move the temporary file to the original file + mv "$tmp_file" "$file" + } + + # Export the function and variables so they can be used by find + export -f add_prefix_suffix + export PREFIX + export SUFFIX + + # Find all files and apply the function + find Chickensoft.AutoInject/src -type f -name "*.cs" -exec bash -c 'add_prefix_suffix "$0"' {} \; + + - name: ๐Ÿ’ฝ Setup .NET SDK + uses: actions/setup-dotnet@v3 + with: + # Use the .NET SDK from global.json in the root of the repository. + global-json-file: global.json + + - name: ๐Ÿ›  Build Source-Only Package + working-directory: Chickensoft.AutoInject + run: | + dotnet build -c Release + + - name: ๐Ÿ”Ž Get Package Path + id: package-path + run: | + package=$(find ./Chickensoft.AutoInject/nupkg -name "*.nupkg") + echo "package=$package" >> "$GITHUB_OUTPUT" + echo "๐Ÿ“ฆ Found package: $package" + + - name: โœจ Create Release + env: + GITHUB_TOKEN: ${{ secrets.GH_BASIC }} + run: | + version="${{ steps.next-version.outputs.version }}" + gh release create --title "v$version" --generate-notes "$version" \ + "${{ steps.package-path.outputs.package }}" + + - name: ๐Ÿ›œ Publish to Nuget + run: | + dotnet nuget push "${{ steps.package-path.outputs.package }}" \ + --api-key "${{ secrets.NUGET_API_KEY }}" \ + --source "https://api.nuget.org/v3/index.json" --skip-duplicate diff --git a/.vscode/settings.json b/.vscode/settings.json index b942a33..d544d90 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,9 @@ "editor.formatOnSave": true, "editor.formatOnType": false }, + // Required to keep the C# language server from getting confused about which + // solution to open. + "dotnet.defaultSolution": "Chickensoft.AutoInject.sln", "csharp.semanticHighlighting.enabled": true, "editor.semanticHighlighting.enabled": true, // C# doc comment colorization gets lost with semantic highlighting, but we @@ -157,4 +160,4 @@ } }, "dotnet.completion.showCompletionItemsFromUnimportedNamespaces": true -} \ No newline at end of file +} diff --git a/Chickensoft.AutoInject.Tests/Chickensoft.AutoInject.Tests.csproj b/Chickensoft.AutoInject.Tests/Chickensoft.AutoInject.Tests.csproj index 582a596..3f9efca 100644 --- a/Chickensoft.AutoInject.Tests/Chickensoft.AutoInject.Tests.csproj +++ b/Chickensoft.AutoInject.Tests/Chickensoft.AutoInject.Tests.csproj @@ -2,7 +2,7 @@ net7.0 true - 10.0 + preview enable Chickensoft.AutoInject.Tests @@ -20,18 +20,13 @@ - + + + + - - - - - - - - - - - + + + diff --git a/Chickensoft.AutoInject.Tests/coverage.sh b/Chickensoft.AutoInject.Tests/coverage.sh index 28c0edf..e9fb87e 100755 --- a/Chickensoft.AutoInject.Tests/coverage.sh +++ b/Chickensoft.AutoInject.Tests/coverage.sh @@ -33,7 +33,8 @@ coverlet \ --exclude-by-file "**/test/**/*.cs" \ --exclude-by-file "**/*Microsoft.NET.Test.Sdk.Program.cs" \ --exclude-by-file "**/Godot.SourceGenerators/**/*.cs" \ - --exclude-assemblies-without-sources "missingall" + --exclude-assemblies-without-sources "missingall" \ + --skipautoprops # Projects included via will be collected in code coverage. # If you want to exclude them, replace the string below with the names of @@ -45,7 +46,7 @@ reportgenerator \ -reports:"./coverage/coverage.xml" \ -targetdir:"./coverage/report" \ "-assemblyfilters:$ASSEMBLIES_TO_REMOVE" \ - "-classfilters:-GodotPlugins.Game.Main;-Chickensoft.AutoInject.Tests.*;-Chickensoft.AutoInject.IDependent;-Chickensoft.AutoInject.Dependent;-Chickensoft.AutoInject.IProvider;-Chickensoft.AutoInject.Provider" \ + "-classfilters:-TypeRegistry;-GodotPlugins.Game.Main;-Chickensoft.AutoInject.Tests.*;-Chickensoft.AutoInject.IDependent;-Chickensoft.AutoInject.Dependent;-Chickensoft.AutoInject.IProvider;-Chickensoft.AutoInject.Provider" \ -reporttypes:"Html;Badges" # Copy badges into their own folder. The badges folder should be included in diff --git a/Chickensoft.AutoInject.Tests/src/Dependent.cs b/Chickensoft.AutoInject.Tests/src/Dependent.cs deleted file mode 100644 index ade0fa9..0000000 --- a/Chickensoft.AutoInject.Tests/src/Dependent.cs +++ /dev/null @@ -1,518 +0,0 @@ -#pragma warning disable -namespace Chickensoft.AutoInject; - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using Godot; -using SuperNodes.Types; -#pragma warning disable CS8019 -using Chickensoft.AutoInject; -#pragma warning restore CS8019 - -/// -/// When a SuperNode applies the Dependent PowerUp, it inherits this interface -/// marking it as a dependent node. -/// -public interface IDependent : ISuperNode { - /// Dependent state used to manage dependencies. - DependencyState DependentState { get; } - - /// Event invoked when dependencies have been resolved. - event Action? OnDependenciesResolved; - - /// - /// True if the node is being unit-tested. When unit-tested, setup callbacks - /// will not be invoked. - /// - bool IsTesting { get; set; } - - /// - /// Called after dependencies are resolved, but before - /// is called if (and only if) - /// is false. This allows you to initialize - /// properties that depend on dependencies separate from using those - /// properties to facilitate easier testing. - /// - void Setup() { } - - /// - /// Method that is invoked when all of the dependent node's dependencies are - /// resolved (after _Ready() but before _Process()). - /// - void OnResolved() { } - - /// - /// - /// Method used by the dependency resolution system to tell the dependent - /// node to announce that all of its dependencies have been resolved. - /// - /// Don't call this method. - /// - void _AnnounceDependenciesResolved() { } - - /// - /// Add a fake value to the dependency table. Adding a fake value allows a - /// unit test to override a dependency lookup with a fake value. - /// - /// Dependency value (probably a mock or a fake). - /// Dependency type. - void FakeDependency(T value) where T : notnull; - - /// - /// Returns a dependency that was resolved from an ancestor provider node. - /// - /// The type of the value to resolve. - /// Fallback value to use if a provider for this type - /// wasn't found during dependency resolution. - /// - /// The resolved dependency value, the fallback value, or throws an exception - /// if the provider wasn't found during dependency resolution and a fallback - /// value was not given. - /// - /// Thrown if the provider for - /// the requested value could not be found and when no fallback value is - /// specified. - TValue DependOn(Func? fallback = default) - where TValue : notnull; -} - -/// -/// Dependent PowerUp. Apply this to SuperNodes to automatically resolve -/// dependencies marked with the [Dependency] attribute without using -/// reflection. -/// -[PowerUp] -public abstract partial class Dependent : Node, IDependent { - #region SuperNodesStaticReflectionStubs - // These static stubs don't need to be copied over because we'll be copied - // into a SuperNode that declares these. - - /// - /// True if the node is being unit-tested. When unit-tested, setup callbacks - /// will not be invoked. - /// - public bool IsTesting { get; set; } = false; - - [PowerUpIgnore] - public static ImmutableDictionary - ScriptPropertiesAndFields { get; } = - new Dictionary().ToImmutableDictionary(); - - [PowerUpIgnore] - public static TResult ReceiveScriptPropertyOrFieldType( - string scriptProperty, ITypeReceiver receiver - ) => default!; - - #endregion SuperNodesStaticReflectionStubs - - #region ISuperNode - // These don't need to be copied over since we will be copied into an - // ISuperNode. - - [PowerUpIgnore] - public ImmutableDictionary PropertiesAndFields - => throw new NotImplementedException(); - [PowerUpIgnore] - public TResult GetScriptPropertyOrFieldType( - string scriptProperty, ITypeReceiver receiver - ) => throw new NotImplementedException(); - [PowerUpIgnore] - public dynamic GetScriptPropertyOrField(string scriptProperty) => - throw new NotImplementedException(); - [PowerUpIgnore] - public void SetScriptPropertyOrField(string scriptProperty, dynamic value) => - throw new NotImplementedException(); - - #endregion ISuperNode - - #region AddedInstanceState - - public event Action? OnDependenciesResolved; - - /// - /// Dependent SuperNodes are all given a private dependency state which - /// stores the dependency table and a flag indicating if dependencies are - /// stale. This is the only pointer that is added to each dependent node to - /// avoid increasing the memory footprint of nodes. - /// - public DependencyState DependentState { get; } = new(); - - #endregion AddedInstanceState - - /// - /// Dictionary of script members that were marked with the dependency - /// attribute, keyed by member name. This is computed statically to avoid - /// needing to compute it for each node. - /// - private static readonly Lazy< - ImmutableDictionary - > _allDependencies = new( - () => DependencyResolver.GetDependenciesToResolve( - ScriptPropertiesAndFields - !) - ); - - /// - /// Called by SuperNodes on behalf of your node any time your node receives an - /// event. This is what allows the Dependent PowerUp to automatically manage - /// dependencies on behalf of your node script. - /// - /// Godot notification. - public void OnDependent(int what) => - DependencyResolver.OnDependent( - what, - this, - _allDependencies.Value - ); - - public void _AnnounceDependenciesResolved() => - OnDependenciesResolved?.Invoke(); - - /// - /// Returns a dependency that was resolved from an ancestor provider node. - /// - /// The type of the value to resolve. - /// Fallback value to use if a provider for this type - /// wasn't found during dependency resolution. - /// - /// The resolved dependency value, the fallback value, or throws an exception - /// if the provider wasn't found during dependency resolution and a fallback - /// value was not given - /// - /// Thrown if the provider for - /// the requested value could not be found and when no fallback value is - /// specified. - public TValue DependOn(Func? fallback = default) - where TValue : notnull => DependencyResolver.DependOn(this, fallback); - - - /// - /// Add a fake value to the dependency table. Adding a fake value allows a - /// unit test to override a dependency lookup with a fake value. - /// - /// Dependency value (probably a mock or a fake). - /// Dependency type. - public void FakeDependency(T value) where T : notnull { - DependentState.ProviderFakes[typeof(T)] = - new DependencyResolver.DefaultProvider(value); - } -} - -/// -/// Data added to each Dependent SuperNode. -/// -public class DependencyState { - /// - /// Resolved dependencies are stored in this table. Don't touch! - /// - public readonly DependencyResolver.DependencyTable Dependencies = new(); - - /// - /// Used by the dependency system to determine if dependencies are stale. - /// Dependencies go stale whenever a node is removed from the tree and added - /// back. - /// - public bool ShouldResolveDependencies { get; set; } = true; - - /// - /// Dictionary of providers we are listening to that are still initializing - /// their provided values. We use this in the rare event that we have to - /// clean up subscriptions before providers ever finished initializing. - /// - public Dictionary Pending { get; } - = new(); - - /// - /// Overrides for providers keyed by dependency type. Overriding providers - /// allows nodes being unit-tested to provide fake providers during unit tests - /// that return mock or faked values. - /// - public Dictionary ProviderFakes { - get; - } = new(); -} - -public record PendingProvider( - IProvider Provider, - Action Success -) { - public void Unsubscribe() { - Provider.ProviderState.OnInitialized -= Success; - } -} - -/// -/// Actual implementation of the dependency resolver. Implementation is stored -/// here to prevent copying too much duplicate code into every SuperNode that -/// uses the Dependent PowerUp. -/// -public static class DependencyResolver { - /// - /// A type receiver for use with SuperNode's static reflection. This is given - /// a class at construction time and used to determine if the class can provide - /// a value of a given type. - /// - public class ProviderValidator : ITypeReceiver { - /// Provider to validate. - public IProvider Provider { get; set; } - - public ProviderValidator() { - Provider = default!; - } - -#nullable disable - public bool Receive() => Provider is IProvide; -#nullable restore - } - - /// - /// Essentially a typedef for a Dictionary that maps types to providers. - /// - public class DependencyTable : Dictionary { } - - [ThreadStatic] - private static readonly ProviderValidator _validator = new(); - - /// - /// The provider validator. This receives the generic type of the provider - /// and uses it to determine if the provider can provide the type of value - /// requested by the dependent. Because we only have one validator and its - /// state is mutated to avoid extra allocations, there is one validator per - /// thread to guarantee safety. - /// - public static ProviderValidator Validator => _validator; - - /// - /// Finds and returns the members of a script that are marked with the - /// [Dependency] attribute. - /// - /// Script members. - /// Members that represent dependencies. - public static ImmutableDictionary - GetDependenciesToResolve( - ImmutableDictionary members - ) { - var dependencies = ImmutableDictionary - .CreateBuilder(); - foreach (var member in members.Values) { - if (member.Attributes.ContainsKey( - "global::Chickensoft.AutoInject.DependencyAttribute" - )) { - dependencies.Add(member.Name, member); - } - } - return dependencies.ToImmutable(); - } - - /// - /// Called by the Dependent PowerUp applied to SuperNodes to determine if - /// dependencies are stale and need to be resolved. If so, this will - /// automatically trigger the dependency resolution process. - /// - /// Godot node notification. - /// Dependent node. - /// All dependencies. - public static void OnDependent( - int what, - IDependent dependent, - ImmutableDictionary allDependencies - ) { - if (what == Node.NotificationExitTree) { - dependent.DependentState.ShouldResolveDependencies = true; - foreach (var pending in dependent.DependentState.Pending.Values) { - pending.Unsubscribe(); - } - dependent.DependentState.Pending.Clear(); - } - if ( - what == Node.NotificationReady && - dependent.DependentState.ShouldResolveDependencies - ) { - Resolve(dependent, allDependencies); - } - } - - /// - /// Returns a dependency that was resolved from an ancestor provider node, - /// or the provided fallback value returned from the given lambda. - /// - /// The type of the value to resolve. - /// Dependent node. - /// Function which returns a fallback value to use if - /// a provider for this type wasn't found during dependency resolution. - /// - /// - /// The resolved dependency value, the fallback value, or throws an exception - /// if the provider wasn't found during dependency resolution and a fallback - /// value was not given - /// - /// Thrown if the provider for - /// the requested value could not be found and when no fallback value is - /// specified. - /// Thrown if a dependency - /// is accessed before the provider has called Provide(). - public static TValue DependOn( - IDependent dependent, Func? fallback = default - ) where TValue : notnull { - // First, check dependency fakes. Using a faked value takes priority over - // all the other dependency resolution methods. - if (dependent.DependentState.ProviderFakes.TryGetValue( - typeof(TValue), out var fakeProvider - ) - ) { - return fakeProvider.Value(); - } - - // Lookup dependency, per usual, respecting any fallback values if there - // were no resolved providers for the requested type during dependency - // resolution. - if (dependent.DependentState.Dependencies.TryGetValue( - typeof(TValue), out var providerNode - ) - ) { - if (!providerNode.ProviderState.IsInitialized) { - throw new ProviderNotInitializedException(typeof(TValue)); - } - if (providerNode is IProvide provider) { - return provider.Value(); - } - else if (providerNode is DefaultProvider defaultProvider) { - return defaultProvider.Value(); - } - } - else if (fallback is not null) { - // See if we were given a fallback. - var provider = new DefaultProvider(fallback()); - dependent.DependentState.Dependencies.Add(typeof(TValue), provider); - return (TValue)provider.Value(); - } - - throw new ProviderNotFoundException(typeof(TValue)); - } - - /// - /// Resolve dependencies. Used by the Dependent PowerUp to resolve - /// dependencies for a given SuperNode. - /// - /// SuperNode who wishes to resolve dependencies. - /// - /// Members of the SuperNode which - /// represent dependencies. - private static void Resolve( - IDependent dependent, - ImmutableDictionary dependenciesToResolve - ) { - var state = dependent.DependentState; - // Clear any previously resolved dependencies โ€” if the ancestor tree hasn't - // changed above us, we will just end up re-resolving them as they were. - state.Dependencies.Clear(); - - var shouldResolve = true; - var remainingDependencies = - new HashSet(dependenciesToResolve.Values); - - var node = ((Node)dependent).GetParent(); - var foundDependencies = new HashSet(); - var providersInitializing = 0; - - void resolve() { - if (!dependent.IsTesting) { - dependent.Setup(); - } - dependent.OnResolved(); - dependent._AnnounceDependenciesResolved(); - } - - void onProviderInitialized(IProvider provider) { - providersInitializing--; - - lock (dependent.DependentState.Pending) { - dependent.DependentState.Pending[provider].Unsubscribe(); - dependent.DependentState.Pending.Remove(provider); - } - - if (providersInitializing == 0) { - resolve(); - } - } - - while (node != null && shouldResolve) { - foundDependencies.Clear(); - - if (node is IProvider provider) { - // For each provider node ancestor, check each of our remaining - // dependencies to see if the provider node is the type needed - // to satisfy the dependency. - foreach (var dependency in remainingDependencies) { - Validator.Provider = provider; - - // Use SuperNode's static reflection capabilities to determine if - // we have found the correct provider for the dependency. - var isCorrectProvider = dependent.GetScriptPropertyOrFieldType( - dependency.Name, Validator - ); - - if (isCorrectProvider) { - // Add the provider to our internal dependency table. - dependent.DependentState.Dependencies.Add( - dependency.Type, provider - ); - - // Mark this dependency to be removed from the list of dependencies - // we're searching for. - foundDependencies.Add(dependency); - - // If the provider is not yet initialized, subscribe to its - // initialization event and add it to the list of pending - // subscriptions. - if ( - !provider.ProviderState.IsInitialized && - !state.Pending.ContainsKey(provider) - ) { - state.Pending[provider] = - new PendingProvider(provider, onProviderInitialized); - provider.ProviderState.OnInitialized += onProviderInitialized; - providersInitializing++; - } - } - } - } - - // Remove the dependencies we've resolved. - remainingDependencies.ExceptWith(foundDependencies); - - if (remainingDependencies.Count == 0) { - // Found all dependencies, exit loop. - shouldResolve = false; - } - else { - // Still need to find dependencies โ€” continue up the tree until - // this returns null. - node = node.GetParent(); - } - } - - if (state.Pending.Count == 0) { - // Inform dependent that dependencies have been resolved. - resolve(); - } - - // We *could* check to see if a provider for every dependency was found - // and throw an exception if any were missing, but this would break support - // for fallback values. - } - - public class DefaultProvider : IProvider { - private readonly dynamic _value; - public ProviderState ProviderState { get; } - - public DefaultProvider(dynamic value) { - _value = value; - ProviderState = new() { IsInitialized = true }; - } - - public dynamic Value() => _value; - } -} -#pragma warning restore diff --git a/Chickensoft.AutoInject.Tests/src/Provider.cs b/Chickensoft.AutoInject.Tests/src/Provider.cs deleted file mode 100644 index 59d3070..0000000 --- a/Chickensoft.AutoInject.Tests/src/Provider.cs +++ /dev/null @@ -1,115 +0,0 @@ -#pragma warning disable -namespace Chickensoft.AutoInject; - -using System; -using Godot; -using SuperNodes.Types; -#pragma warning disable CS8019 -using Chickensoft.AutoInject; -#pragma warning restore CS8019 - -/// -/// Represents a node that provides a value to its descendant nodes. -/// -public interface IProvider { - /// - /// Information about the provider โ€” used internally to manage dependencies. - /// - ProviderState ProviderState { get; } - - /// - /// When a provider has initialized all of the values it provides, this method - /// is invoked on the provider itself (immediately after _Ready). When this - /// method is called, the provider is guaranteed that all of its descendant - /// nodes that depend this provider have resolved their dependencies. - /// - void OnProvided() { } -} - -/// -/// A provider of a value of type . -/// -/// The type of value provided. To prevent pain, providers -/// should not provide a value that could ever be null. -public interface IProvide : IProvider where T : notnull { - /// Value that is provided by the provider. - T Value(); -} - -/// -/// Turns an ordinary node into a provider node. -/// -[PowerUp] -public abstract partial class Provider : Node, IProvider { - /// - /// Internal provider state used to manage dependencies. - /// - public ProviderState ProviderState { get; } = new(); - - /// - /// - /// Call this method once all your dependencies have been initialized. This - /// will inform any dependent nodes that are waiting on this provider that - /// the provider has finished initializing. - /// - /// - /// Forgetting to call this method can prevent dependencies from resolving - /// correctly throughout the scene tree. - /// - /// - public void Provide() => ProviderState.Provide(this); - - /// - /// Provider lifecycle method automatically invoked by SuperNodes. - /// - /// Godot node notification. - public void OnProvider(int what) => ProviderState.OnProvider(what, this); -} - -/// -/// Provider state used internally when resolving dependencies. -/// -public class ProviderState { - /// Whether the provider has initialized all of its values. - public bool IsInitialized { get; set; } - - /// - /// Underlying event delegate used to inform dependent nodes that the - /// provider has initialized all of the values it provides. - /// - public event Action? OnInitialized; - - /// - /// Announces to descendent nodes that the values provided by this provider - /// are initialized. - /// - /// Provider node which has finished initializing - /// the values it provides. - public void Announce(IProvider provider) - => OnInitialized?.Invoke(provider); - - /// - /// Internal implementation for the OnProvider lifecycle method. Resets the - /// provider's initialized status when the provider leaves the scene tree. - /// - /// Godot node notification. - /// Provider node. - public static void OnProvider(int what, IProvider provider) { - if (what == Node.NotificationExitTree) { - provider.ProviderState.IsInitialized = false; - } - } - - /// - /// Internal implementation for the Provide method. This marks the Provider - /// as having provided all of its values and then announces to dependent - /// nodes that the provider has finished initializing. - /// - /// - public static void Provide(IProvider provider) { - provider.ProviderState.IsInitialized = true; - provider.ProviderState.Announce(provider); - provider.OnProvided(); - } -} -#pragma warning restore diff --git a/Chickensoft.AutoInject.Tests/src/auto_connect/AutoConnectExtensions.cs b/Chickensoft.AutoInject.Tests/src/auto_connect/AutoConnectExtensions.cs new file mode 100644 index 0000000..99e928c --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_connect/AutoConnectExtensions.cs @@ -0,0 +1,29 @@ +namespace Chickensoft.AutoInject; + +using System; +using Chickensoft.GodotNodeInterfaces; +#pragma warning disable CS8019, IDE0005 +using Chickensoft.AutoInject; +using Godot; +using System.Collections.Generic; + +public static class AutoConnectExtensions { + /// + /// Initialize the fake node tree for unit testing. + /// + /// Godot node. + /// Map of node paths to mock nodes. + /// + public static void FakeNodeTree( + this Node node, Dictionary? nodes + ) { + if (node is not IAutoConnect autoConnect) { + throw new InvalidOperationException( + "Cannot create a fake node tree on a node without the AutoConnect " + + "mixin." + ); + } + + autoConnect.FakeNodes = new(node, nodes); + } +} diff --git a/Chickensoft.AutoInject.Tests/src/auto_connect/AutoConnector.cs b/Chickensoft.AutoInject.Tests/src/auto_connect/AutoConnector.cs new file mode 100644 index 0000000..a00e673 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_connect/AutoConnector.cs @@ -0,0 +1,165 @@ +namespace Chickensoft.AutoInject; + +using System; +using System.Runtime.CompilerServices; +using Chickensoft.GodotNodeInterfaces; +#pragma warning disable CS8019, IDE0005 +using Chickensoft.AutoInject; +using Godot; +using Chickensoft.Introspection; +using System.Collections.Generic; + +public static class AutoConnector { + public class TypeChecker : ITypeReceiver { + public object Value { get; set; } = default!; + + public bool Result { get; private set; } + + public void Receive() => Result = Value is T; + } + + private static readonly TypeChecker _checker = new(); + + public static void ConnectNodes( + IEnumerable properties, + IAutoConnect autoConnect + ) { + var node = (Node)autoConnect; + foreach (var property in properties) { + if ( + !property.Attributes.TryGetValue( + typeof(NodeAttribute), out var nodeAttributes + ) + ) { + continue; + } + var nodeAttribute = (NodeAttribute)nodeAttributes[0]; + + var path = nodeAttribute.Path ?? AsciiToPascalCase(property.Name); + + Exception? e; + + // First, check to see if the node has been faked for testing. + // Faked nodes take precedence over real nodes. + // + // FakeNodes will never be null on an AutoConnect node, actually. + if (autoConnect.FakeNodes!.GetNode(path) is { } fakeNode) { + // We found a faked node for this path. Make sure it's the expected + // type. + _checker.Value = fakeNode; + + property.GenericType.GenericTypeGetter(_checker); + + var satisfiesFakeType = _checker.Result; + + if (!satisfiesFakeType) { + e = new InvalidOperationException( + $"Found a faked node at '{path}' of type " + + $"'{fakeNode.GetType().Name}' that is not the expected type " + + $"'{property.GenericType.ClosedType}' for member " + + $"'{property.Name}' on '{node.Name}'." + ); + GD.PushError(e.Message); + throw e; + } + // Faked node satisfies the expected type :) + if (property.Setter is { } setter) { + setter(node, fakeNode); + } + + continue; + } + + // We're dealing with what should be an actual node in the tree. + var potentialChild = node.GetNodeOrNull(path); + + if (potentialChild is not Node child) { + e = new InvalidOperationException( + $"AutoConnect: Node at '{path}' does not exist in either the real " + + $"or fake subtree for '{node.Name}' member '{property.Name}' of " + + $"type '{property.GenericType.ClosedType}'." + ); + GD.PushError(e.Message); + throw e; + } + + // see if the unchecked node satisfies the expected type of node from the + // property type + _checker.Value = child; + property.GenericType.GenericTypeGetter(_checker); + var originalNodeSatisfiesType = _checker.Result; + + if (originalNodeSatisfiesType) { + // Property expected a vanilla Godot node type and it matched, so we + // set it and leave. + if (property.Setter is { } setter) { + setter(node, child); + } + continue; + } + + // Plain Godot node type wasn't expected, so we need to check if the + // property was expecting a Godot node interface type. + // + // Check to see if the node needs to be adapted to satisfy an + // expected interface type. + var adaptedChild = GodotInterfaces.AdaptNode(child); + _checker.Value = adaptedChild; + + property.GenericType.GenericTypeGetter(_checker); + var adaptedChildSatisfiesType = _checker.Result; + + if (adaptedChildSatisfiesType) { + if (property.Setter is { } setter) { + setter(node, adaptedChild); + } + continue; + } + + // Tell user we can't connect the node to the property. + e = new InvalidOperationException( + $"Node at '{path}' of type '{child.GetType().Name}' does not " + + $"satisfy the expected type '{property.GenericType.ClosedType}' for " + + $"member '{property.Name}' on '{node.Name}'." + ); + GD.PushError(e.Message); + throw e; + } + } + + /// + /// + /// Converts an ASCII string to PascalCase. This looks insane, but it is the + /// fastest out of all the benchmarks I did. + /// + /// + /// Since messing with strings can be slow and looking up nodes is a common + /// operation, this is a good place to optimize. No heap allocations! + /// + /// + /// Removes underscores, always capitalizes the first letter, and capitalizes + /// the first letter after an underscore. + /// + /// + /// Input string. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string AsciiToPascalCase(string input) { + var span = input.AsSpan(); + Span output = stackalloc char[span.Length + 1]; + var outputIndex = 1; + + output[0] = '%'; + + for (var i = 1; i < span.Length + 1; i++) { + var c = span[i - 1]; + + if (c == '_') { continue; } + + output[outputIndex++] = i == 1 || span[i - 2] == '_' + ? (char)(c & 0xDF) + : c; + } + + return new string(output[..outputIndex]); + } +} diff --git a/Chickensoft.AutoInject.Tests/src/auto_connect/IAutoConnect.cs b/Chickensoft.AutoInject.Tests/src/auto_connect/IAutoConnect.cs new file mode 100644 index 0000000..0d86112 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_connect/IAutoConnect.cs @@ -0,0 +1,47 @@ +namespace Chickensoft.AutoInject; + +using System.Runtime.CompilerServices; +using Chickensoft.GodotNodeInterfaces; +#pragma warning disable CS8019, IDE0005 +using Chickensoft.AutoInject; +using Godot; +using Chickensoft.Introspection; +using System.Collections.Generic; + +/// +/// Apply this mixin to your introspective node to automatically connect +/// declared node references to their corresponding instances in the scene tree. +/// +[Mixin] +public interface IAutoConnect : IMixin, IFakeNodeTreeEnabled { + + FakeNodeTree? IFakeNodeTreeEnabled.FakeNodes { + get { + _AddStateIfNeeded(); + return MixinState.Get(); + } + set { + if (value is { } tree) { + MixinState.Overwrite(value); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + void IMixin.Handler() { + var what = MixinState.Get().Notification; + + if (what == Node.NotificationSceneInstantiated) { + AutoConnector.ConnectNodes(Types.Graph.GetProperties(GetType()), this); + } + } + +#pragma warning disable IDE1006 // Naming Styles + public void _AddStateIfNeeded(Dictionary? nodes = null) { + if (this is not Node node) { return; } + if (!MixinState.Has()) { + MixinState.Overwrite(new FakeNodeTree(node, nodes)); + } + } +} + diff --git a/Chickensoft.AutoInject.Tests/src/auto_connect/NodeAttribute.cs b/Chickensoft.AutoInject.Tests/src/auto_connect/NodeAttribute.cs new file mode 100644 index 0000000..4bca75e --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_connect/NodeAttribute.cs @@ -0,0 +1,24 @@ +namespace Chickensoft.AutoInject; + +using System; +#pragma warning disable CS8019, IDE0005 +using Chickensoft.AutoInject; + +/// +/// Node attribute. Apply this to properties or fields that need to be +/// automatically connected to a corresponding node instance in the scene tree. +/// +/// Godot node path. If not provided, the name of the +/// property will be converted to PascalCase (with any leading +/// underscores removed) and used as a unique node identifier +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NodeAttribute(string? path = null) : Attribute { + /// + /// Explicit node path or unique identifier that the tagged property or field + /// should reference. If not provided (or null), the name of the property or + /// field itself will be converted to PascalCase (with any leading + /// underscores removed) and used as a unique node identifier. For example, + /// the reference `Node2D _myNode` would be connected to `%MyNode`. + /// + public string? Path { get; } = path; +} diff --git a/Chickensoft.AutoInject.Tests/src/auto_init/IAutoInit.cs b/Chickensoft.AutoInject.Tests/src/auto_init/IAutoInit.cs new file mode 100644 index 0000000..e4b02b9 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_init/IAutoInit.cs @@ -0,0 +1,63 @@ +namespace Chickensoft.AutoInject; + +using Chickensoft.Introspection; +#pragma warning disable IDE0005 +using Chickensoft.AutoInject; +using Godot; + +/// +/// Mixin which invokes an Initialize method just before Ready is received. +/// The initialize method is provided as a convenient place to initialize +/// non-node related values that may be needed by the node's Ready method. +///
+/// Distinguishing between initialization and _Ready helps make unit testing +/// nodes easier. +///
+[Mixin] +public partial interface IAutoInit : IMixin { + private sealed class AutoInitState { + public bool IsTesting { get; set; } + } + + /// + /// True if the node is being unit-tested. When unit-tested, setup callbacks + /// will not be invoked. + /// + public bool IsTesting { + get { + CreateStateIfNeeded(); + return MixinState.Get().IsTesting; + } + set { + CreateStateIfNeeded(); + MixinState.Get().IsTesting = value; + } + } + + void IMixin.Handler() { + if (this is not Node node) { + return; + } + + node.__SetupNotificationStateIfNeeded(); + + var what = MixinState.Get().Notification; + + if (what == Node.NotificationReady && !IsTesting) { + // Call initialize before _Ready if we're not testing. + Initialize(); + } + } + + private void CreateStateIfNeeded() { + if (MixinState.Has()) { return; } + + MixinState.Overwrite(new AutoInitState()); + } + + /// + /// Initialization method invoked before Ready โ€”ย perform any non-node + /// related setup and initialization here. + /// + void Initialize() { } +} diff --git a/Chickensoft.AutoInject.Tests/src/DependencyAttribute.cs b/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependencyAttribute.cs similarity index 100% rename from Chickensoft.AutoInject.Tests/src/DependencyAttribute.cs rename to Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependencyAttribute.cs diff --git a/Chickensoft.AutoInject.Tests/src/DependencyExceptions.cs b/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependencyExceptions.cs similarity index 100% rename from Chickensoft.AutoInject.Tests/src/DependencyExceptions.cs rename to Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependencyExceptions.cs diff --git a/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependencyResolver.cs b/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependencyResolver.cs new file mode 100644 index 0000000..778403b --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependencyResolver.cs @@ -0,0 +1,284 @@ + +namespace Chickensoft.AutoInject; + +using System; +using System.Collections.Generic; +using Godot; + +#pragma warning disable IDE0005 +using Chickensoft.AutoInject; +using Chickensoft.Introspection; +using System.Globalization; + +/// +/// Actual implementation of the dependency resolver. +/// +public static class DependencyResolver { + /// + /// A type receiver for use with introspective node's reflection metadata. + /// This is given a class at construction time and used to determine if the + /// class can provide a value of a given type. + /// + private sealed class ProviderValidator : ITypeReceiver { + /// Provider to validate. + public IBaseProvider Provider { get; set; } + + /// + /// Result of the validation. True if the node can provide the type. + /// + public bool Result { get; set; } + + public ProviderValidator() { + Provider = default!; + } + +#nullable disable + public void Receive() => Result = Provider is IProvide; +#nullable restore + } + + /// + /// Essentially a typedef for a Dictionary that maps types to providers. + /// + public class DependencyTable : Dictionary { } + + [ThreadStatic] + private static readonly ProviderValidator _validator; + + static DependencyResolver() { + _validator = new(); + } + + /// + /// The provider validator. This receives the generic type of the provider + /// and uses it to determine if the provider can provide the type of value + /// requested by the dependent. Because we only have one validator and its + /// state is mutated to avoid extra allocations, there is one validator per + /// thread to guarantee safety. + /// + private static ProviderValidator Validator => _validator; + + /// + /// Finds and returns the members of a script that are marked with the + /// [Dependency] attribute. + /// + /// Script members. + /// Members that represent dependencies. + private static IEnumerable GetDependenciesToResolve( + IEnumerable properties + ) { + foreach (var property in properties) { + if (property.Attributes.ContainsKey(typeof(DependencyAttribute))) { + yield return property; + } + } + } + + /// + /// Called by the Dependent mixin on an introspective node to determine if + /// dependencies are stale and need to be resolved. If so, this will + /// automatically trigger the dependency resolution process. + /// + /// Godot node notification. + /// Dependent node. + /// All dependencies. + public static void OnDependent( + int what, + IDependent dependent, + IEnumerable properties + ) { + var state = dependent.MixinState.Get(); + if (what == Node.NotificationExitTree) { + dependent.MixinState.Get().ShouldResolveDependencies = true; + foreach (var pending in state.Pending.Values) { + pending.Unsubscribe(); + } + state.Pending.Clear(); + } + if ( + what == Node.NotificationReady && + state.ShouldResolveDependencies + ) { + Resolve(dependent, properties); + } + } + + /// + /// Returns a dependency that was resolved from an ancestor provider node, + /// or the provided fallback value returned from the given lambda. + /// + /// The type of the value to resolve. + /// Dependent node. + /// Function which returns a fallback value to use if + /// a provider for this type wasn't found during dependency resolution. + /// + /// + /// The resolved dependency value, the fallback value, or throws an exception + /// if the provider wasn't found during dependency resolution and a fallback + /// value was not given + /// + /// Thrown if the provider for + /// the requested value could not be found and when no fallback value is + /// specified. + /// Thrown if a dependency + /// is accessed before the provider has called Provide(). + public static TValue DependOn( + IDependent dependent, Func? fallback = default + ) where TValue : notnull { + // First, check dependency fakes. Using a faked value takes priority over + // all the other dependency resolution methods. + var state = dependent.MixinState.Get(); + if (state.ProviderFakes.TryGetValue(typeof(TValue), out var fakeProvider)) { + return fakeProvider.Value(); + } + + // Lookup dependency, per usual, respecting any fallback values if there + // were no resolved providers for the requested type during dependency + // resolution. + if (state.Dependencies.TryGetValue( + typeof(TValue), out var providerNode + ) + ) { + if (!providerNode.ProviderState.IsInitialized) { + throw new ProviderNotInitializedException(typeof(TValue)); + } + if (providerNode is IProvide provider) { + return provider.Value(); + } + else if (providerNode is DefaultProvider defaultProvider) { + return defaultProvider.Value(); + } + } + else if (fallback is not null) { + // See if we were given a fallback. + var provider = new DefaultProvider(fallback()); + state.Dependencies.Add(typeof(TValue), provider); + return (TValue)provider.Value(); + } + + throw new ProviderNotFoundException(typeof(TValue)); + } + + /// + /// Resolve dependencies. Used by the Dependent mixin to resolve + /// dependencies for a given introspective node. + /// + /// Introspective node which wants to resolve + /// dependencies. + /// Properties of the introspective node. + /// + private static void Resolve( + IDependent dependent, + IEnumerable properties + ) { + var state = dependent.MixinState.Get(); + // Clear any previously resolved dependencies โ€” if the ancestor tree hasn't + // changed above us, we will just end up re-resolving them as they were. + state.Dependencies.Clear(); + + var shouldResolve = true; + var remainingDependencies = new HashSet( + GetDependenciesToResolve(properties) + ); + + var node = ((Node)dependent).GetParent(); + var foundDependencies = new HashSet(); + var providersInitializing = 0; + + void resolve() { + if (!dependent.IsTesting) { + dependent.Setup(); + } + dependent.OnResolved(); + } + + void onProviderInitialized(IBaseProvider provider) { + providersInitializing--; + + lock (state.Pending) { + state.Pending[provider].Unsubscribe(); + state.Pending.Remove(provider); + } + + if (providersInitializing == 0) { + resolve(); + } + } + + while (node != null && shouldResolve) { + foundDependencies.Clear(); + + if (node is IBaseProvider provider) { + // For each provider node ancestor, check each of our remaining + // dependencies to see if the provider node is the type needed + // to satisfy the dependency. + foreach (var property in remainingDependencies) { + Validator.Provider = provider; + + // Use the generated introspection metadata to determine if + // we have found the correct provider for the dependency. + property.GenericType.GenericTypeGetter(Validator); + var isCorrectProvider = Validator.Result; + + if (isCorrectProvider) { + // Add the provider to our internal dependency table. + state.Dependencies.Add( + property.GenericType.ClosedType, provider + ); + + // Mark this dependency to be removed from the list of dependencies + // we're searching for. + foundDependencies.Add(property); + + // If the provider is not yet initialized, subscribe to its + // initialization event and add it to the list of pending + // subscriptions. + if ( + !provider.ProviderState.IsInitialized && + !state.Pending.ContainsKey(provider) + ) { + state.Pending[provider] = + new PendingProvider(provider, onProviderInitialized); + provider.ProviderState.OnInitialized += onProviderInitialized; + providersInitializing++; + } + } + } + } + + // Remove the dependencies we've resolved. + remainingDependencies.ExceptWith(foundDependencies); + + if (remainingDependencies.Count == 0) { + // Found all dependencies, exit loop. + shouldResolve = false; + } + else { + // Still need to find dependencies โ€” continue up the tree until + // this returns null. + node = node.GetParent(); + } + } + + if (state.Pending.Count == 0) { + // Inform dependent that dependencies have been resolved. + resolve(); + } + + // We *could* check to see if a provider for every dependency was found + // and throw an exception if any were missing, but this would break support + // for fallback values. + } + + public class DefaultProvider : IBaseProvider { + private readonly dynamic _value; + public ProviderState ProviderState { get; } + + public DefaultProvider(dynamic value) { + _value = value; + ProviderState = new() { IsInitialized = true }; + } + + public dynamic Value() => _value; + } +} diff --git a/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependentExtensions.cs b/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependentExtensions.cs new file mode 100644 index 0000000..b11671e --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependentExtensions.cs @@ -0,0 +1,19 @@ +namespace Chickensoft.AutoInject; + +using System; +#pragma warning disable IDE0005 +using Chickensoft.AutoInject; + +public static class DependentExtensions { + /// + public static TValue DependOn( + this IDependent dependent, + Func? fallback = default + ) where TValue : notnull => DependencyResolver.DependOn(dependent, fallback); + + /// + public static void FakeDependency( + this IDependent dependent, T value + ) where T : notnull => dependent.FakeDependency(value); +} diff --git a/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependentState.cs b/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependentState.cs new file mode 100644 index 0000000..6e286f4 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/DependentState.cs @@ -0,0 +1,52 @@ +namespace Chickensoft.AutoInject; + +using System; +using System.Collections.Generic; + +#pragma warning disable IDE0005 +using Chickensoft.AutoInject; +using Chickensoft.Introspection; +using System.Globalization; + +/// +/// Dependent introspective nodes are all given a private dependency state which +/// stores the dependency table and a flag indicating if dependencies are +/// stale. This is the only pointer that is added to each dependent node to +/// avoid increasing the memory footprint of nodes. +/// +public class DependentState { + /// + /// True if the node is being unit-tested. When unit-tested, setup callbacks + /// will not be invoked. + /// + public bool IsTesting { get; set; } + + /// + /// Resolved dependencies are stored in this table. Don't touch! + /// + public readonly DependencyResolver.DependencyTable Dependencies = []; + + /// + /// Used by the dependency system to determine if dependencies are stale. + /// Dependencies go stale whenever a node is removed from the tree and added + /// back. + /// + public bool ShouldResolveDependencies { get; set; } = true; + + /// + /// Dictionary of providers we are listening to that are still initializing + /// their provided values. We use this in the rare event that we have to + /// clean up subscriptions before providers ever finished initializing. + /// + public Dictionary Pending { get; } + = []; + + /// + /// Overrides for providers keyed by dependency type. Overriding providers + /// allows nodes being unit-tested to provide fake providers during unit tests + /// that return mock or faked values. + /// + public Dictionary ProviderFakes { + get; + } = []; +} diff --git a/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/IDependent.cs b/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/IDependent.cs new file mode 100644 index 0000000..ad9a724 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/IDependent.cs @@ -0,0 +1,85 @@ +namespace Chickensoft.AutoInject; + +using Godot; + +#pragma warning disable IDE0005 +using Chickensoft.AutoInject; +using Chickensoft.Introspection; +using System.Globalization; + + +/// +/// Dependent mixin. Apply this to an introspective node to automatically +/// resolve dependencies marked with the [Dependency] attribute. +/// +[Mixin] +public interface IDependent : IMixin, IAutoInit { + DependentState DependentState { + get { + AddStateIfNeeded(); + return MixinState.Get(); + } + } + + /// + /// Called after dependencies are resolved, but before + /// is called if (and only if) + /// is false. This allows you to initialize + /// properties that depend on dependencies separate from using those + /// properties to facilitate easier testing. + /// + void Setup() { } + + /// + /// Method that is invoked when all of the dependent node's dependencies are + /// resolved (after _Ready() but before _Process()). + /// + void OnResolved() { } + + private void AddStateIfNeeded() { + if (MixinState.Has()) { return; } + + MixinState.Overwrite(new DependentState()); + } + + void IMixin.Handler() { } + + // Specifying "new void" makes this hide the existing handler, which works + // since the introspection generator calls us as ((IDependent)obj).Handler() + // rather than ((IMixin)obj).Handler(). + public new void Handler() { + if (this is not Node node) { + return; + } + + node.__SetupNotificationStateIfNeeded(); + AddStateIfNeeded(); + + if ( + this is IIntrospective introspective && + !introspective.HasMixin(typeof(IAutoInit)) + ) { + // Developer didn't give us the IAutoInit mixin, but all dependents are + // required to also be IAutoInit. So we'll invoke it for them manually. + (this as IAutoInit).Handler(); + } + + DependencyResolver.OnDependent( + MixinState.Get().Notification, + this, + Types.Graph.GetProperties(GetType()) + ); + } + + /// + /// Add a fake value to the dependency table. Adding a fake value allows a + /// unit test to override a dependency lookup with a fake value. + /// + /// Dependency value (probably a mock or a fake). + /// Dependency type. + public void FakeDependency(T value) where T : notnull { + AddStateIfNeeded(); + MixinState.Get().ProviderFakes[typeof(T)] = + new DependencyResolver.DefaultProvider(value); + } +} diff --git a/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/PendingProvider.cs b/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/PendingProvider.cs new file mode 100644 index 0000000..148844a --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_inject/dependent/PendingProvider.cs @@ -0,0 +1,16 @@ +namespace Chickensoft.AutoInject; + +using System; + +#pragma warning disable IDE0005 +using Chickensoft.AutoInject; +using Chickensoft.Introspection; +using System.Globalization; + +public class PendingProvider( + IBaseProvider provider, Action success +) { + public IBaseProvider Provider { get; } = provider; + public Action Success { get; } = success; + public void Unsubscribe() => Provider.ProviderState.OnInitialized -= Success; +} diff --git a/Chickensoft.AutoInject.Tests/src/auto_inject/provider/IProvide.cs b/Chickensoft.AutoInject.Tests/src/auto_inject/provider/IProvide.cs new file mode 100644 index 0000000..64c2567 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_inject/provider/IProvide.cs @@ -0,0 +1,23 @@ +#pragma warning disable +namespace Chickensoft.AutoInject; + +using System; +using Godot; +using Chickensoft.Introspection; +using Chickensoft.AutoInject; + +/// Base provider interface used internally by AutoInject. +public interface IBaseProvider { + /// Provider state. + ProviderState ProviderState { get; } +} + +/// +/// A provider of a value of type . +/// +/// The type of value provided. To prevent pain, providers +/// should not provide a value that could ever be null. +public interface IProvide : IProvider where T : notnull { + /// Value that is provided by the provider. + T Value(); +} diff --git a/Chickensoft.AutoInject.Tests/src/auto_inject/provider/IProvider.cs b/Chickensoft.AutoInject.Tests/src/auto_inject/provider/IProvider.cs new file mode 100644 index 0000000..2d7c639 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_inject/provider/IProvider.cs @@ -0,0 +1,53 @@ +#pragma warning disable +namespace Chickensoft.AutoInject; + +using System; +using Godot; +using Chickensoft.Introspection; +using Chickensoft.AutoInject; + +/// +/// Turns an ordinary node into a provider node. +/// +[Mixin] +public interface IProvider : IMixin, IBaseProvider { + /// + ProviderState IBaseProvider.ProviderState { + get { + AddStateIfNeeded(); + return MixinState.Get(); + } + } + + /// + /// When a provider has initialized all of the values it provides, this method + /// is invoked on the provider itself (immediately after _Ready). When this + /// method is called, the provider is guaranteed that all of its descendant + /// nodes that depend this provider have resolved their dependencies. + /// + void OnProvided() { } + + /// + /// + /// Call this method once all your dependencies have been initialized. This + /// will inform any dependent nodes that are waiting on this provider that + /// the provider has finished initializing. + /// + /// + /// Forgetting to call this method can prevent dependencies from resolving + /// correctly throughout the scene tree. + /// + /// + public void Provide() => ProviderState.Provide(this); + + void IMixin.Handler() { + ProviderState.OnProvider( + MixinState.Get().Notification, this + ); + } + + private void AddStateIfNeeded() { + if (MixinState.Has()) { return; } + MixinState.Set(new ProviderState()); + } +} diff --git a/Chickensoft.AutoInject.Tests/src/auto_inject/provider/ProviderExtensions.cs b/Chickensoft.AutoInject.Tests/src/auto_inject/provider/ProviderExtensions.cs new file mode 100644 index 0000000..282c38e --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_inject/provider/ProviderExtensions.cs @@ -0,0 +1,13 @@ +#pragma warning disable +namespace Chickensoft.AutoInject; + +using System; +using Godot; +using Chickensoft.Introspection; +using Chickensoft.AutoInject; + +public static class ProviderExtensions { + public static void Provide(this IProvider provider) { + provider.Provide(); + } +} diff --git a/Chickensoft.AutoInject.Tests/src/auto_inject/provider/ProviderState.cs b/Chickensoft.AutoInject.Tests/src/auto_inject/provider/ProviderState.cs new file mode 100644 index 0000000..fa5a031 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_inject/provider/ProviderState.cs @@ -0,0 +1,55 @@ +namespace Chickensoft.AutoInject; + +using System; +using Godot; +#pragma warning disable IDE0005 +using Chickensoft.Introspection; +using Chickensoft.AutoInject; + + +/// +/// Provider state used internally when resolving dependencies. +/// +public class ProviderState { + /// Whether the provider has initialized all of its values. + public bool IsInitialized { get; set; } + + /// + /// Underlying event delegate used to inform dependent nodes that the + /// provider has initialized all of the values it provides. + /// + public event Action? OnInitialized; + + /// + /// Announces to descendent nodes that the values provided by this provider + /// are initialized. + /// + /// Provider node which has finished initializing + /// the values it provides. + public void Announce(IBaseProvider provider) + => OnInitialized?.Invoke(provider); + + /// + /// Internal implementation for the OnProvider lifecycle method. Resets the + /// provider's initialized status when the provider leaves the scene tree. + /// + /// Godot node notification. + /// Provider node. + public static void OnProvider(int what, IProvider provider) { + if (what == Node.NotificationExitTree) { + provider.ProviderState.IsInitialized = false; + } + } + + /// + /// Internal implementation for the Provide method. This marks the Provider + /// as having provided all of its values and then announces to dependent + /// nodes that the provider has finished initializing. + /// + /// + public static void Provide(IProvider provider) { + provider.ProviderState.IsInitialized = true; + provider.ProviderState.Announce(provider); + provider.OnProvided(); + } +} diff --git a/Chickensoft.AutoInject.Tests/src/auto_node/AutoNode.cs b/Chickensoft.AutoInject.Tests/src/auto_node/AutoNode.cs new file mode 100644 index 0000000..aff6174 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_node/AutoNode.cs @@ -0,0 +1,29 @@ +namespace Chickensoft.AutoInject; + +using Chickensoft.Introspection; + +/// +/// +/// Add this mixin to your introspective node to automatically connects nodes +/// declared with the [Node] attribute, +/// call an additional initialization lifecycle method, and allow you to +/// provide dependencies to descendant nodes or fetch them from ancestors via +/// the [Dependency] attribute. +/// +/// +/// This enables you to leverage all of the functionality of AutoInject with one +/// easy mixin. +/// +/// +public interface IAutoNode : IMixin, +IAutoOn, IAutoConnect, IAutoInit, IProvider, IDependent { + void IMixin.Handler() { } + + new void Handler() { + // IAutoOn isn't called since its handler does nothing. + (this as IAutoConnect).Handler(); + (this as IAutoInit).Handler(); + (this as IProvider).Handler(); + (this as IDependent).Handler(); + } +} diff --git a/Chickensoft.AutoInject.Tests/src/auto_on/IAutoOn.cs b/Chickensoft.AutoInject.Tests/src/auto_on/IAutoOn.cs new file mode 100644 index 0000000..aa085e1 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/auto_on/IAutoOn.cs @@ -0,0 +1,475 @@ +namespace Chickensoft.AutoInject; + +using Chickensoft.Introspection; +using Godot; + +/// +/// Represents a node which automatically calls nicely named notification +/// methods, such as +/// , , etc. +/// +[Mixin] +public interface IAutoOn : IMixin { + // Handler doesn't do anything, since + // + // automatically calls InvokeNotificationMethods after invoking mixins. + // This ensures callbacks always run after mixins. + void IMixin.Handler() { } + + public static void InvokeNotificationMethods(object? obj, int what) { + if (obj is not IAutoOn autoNode || obj is not Node node) { return; } + + // Invoke Godot callbacks + autoNode.OnNotification(what); + + switch (what) { + case (int)GodotObject.NotificationPostinitialize: + autoNode.OnPostinitialize(); + break; + case (int)GodotObject.NotificationPredelete: + autoNode.OnPredelete(); + break; + case (int)Node.NotificationEnterTree: + autoNode.OnEnterTree(); + break; + case (int)Node.NotificationWMWindowFocusIn: + autoNode.OnWMWindowFocusIn(); + break; + case (int)Node.NotificationWMWindowFocusOut: + autoNode.OnWMWindowFocusOut(); + break; + case (int)Node.NotificationWMCloseRequest: + autoNode.OnWMCloseRequest(); + break; + case (int)Node.NotificationWMSizeChanged: + autoNode.OnWMSizeChanged(); + break; + case (int)Node.NotificationWMDpiChange: + autoNode.OnWMDpiChange(); + break; + case (int)Node.NotificationVpMouseEnter: + autoNode.OnVpMouseEnter(); + break; + case (int)Node.NotificationVpMouseExit: + autoNode.OnVpMouseExit(); + break; + case (int)Node.NotificationOsMemoryWarning: + autoNode.OnOsMemoryWarning(); + break; + case (int)Node.NotificationTranslationChanged: + autoNode.OnTranslationChanged(); + break; + case (int)Node.NotificationWMAbout: + autoNode.OnWMAbout(); + break; + case (int)Node.NotificationCrash: + autoNode.OnCrash(); + break; + case (int)Node.NotificationOsImeUpdate: + autoNode.OnOsImeUpdate(); + break; + case (int)Node.NotificationApplicationResumed: + autoNode.OnApplicationResumed(); + break; + case (int)Node.NotificationApplicationPaused: + autoNode.OnApplicationPaused(); + break; + case (int)Node.NotificationApplicationFocusIn: + autoNode.OnApplicationFocusIn(); + break; + case (int)Node.NotificationApplicationFocusOut: + autoNode.OnApplicationFocusOut(); + break; + case (int)Node.NotificationTextServerChanged: + autoNode.OnTextServerChanged(); + break; + case (int)Node.NotificationWMMouseExit: + autoNode.OnWMMouseExit(); + break; + case (int)Node.NotificationWMMouseEnter: + autoNode.OnWMMouseEnter(); + break; + case (int)Node.NotificationWMGoBackRequest: + autoNode.OnWMGoBackRequest(); + break; + case (int)Node.NotificationEditorPreSave: + autoNode.OnEditorPreSave(); + break; + case (int)Node.NotificationExitTree: + autoNode.OnExitTree(); + break; + case (int)Node.NotificationChildOrderChanged: + autoNode.OnChildOrderChanged(); + break; + case (int)Node.NotificationReady: + autoNode.OnReady(); + break; + case (int)Node.NotificationEditorPostSave: + autoNode.OnEditorPostSave(); + break; + case (int)Node.NotificationUnpaused: + autoNode.OnUnpaused(); + break; + case (int)Node.NotificationPhysicsProcess: + autoNode.OnPhysicsProcess(node.GetPhysicsProcessDeltaTime()); + break; + case (int)Node.NotificationProcess: + autoNode.OnProcess(node.GetProcessDeltaTime()); + break; + case (int)Node.NotificationParented: + autoNode.OnParented(); + break; + case (int)Node.NotificationUnparented: + autoNode.OnUnparented(); + break; + case (int)Node.NotificationPaused: + autoNode.OnPaused(); + break; + case (int)Node.NotificationDragBegin: + autoNode.OnDragBegin(); + break; + case (int)Node.NotificationDragEnd: + autoNode.OnDragEnd(); + break; + case (int)Node.NotificationPathRenamed: + autoNode.OnPathRenamed(); + break; + case (int)Node.NotificationInternalProcess: + autoNode.OnInternalProcess(); + break; + case (int)Node.NotificationInternalPhysicsProcess: + autoNode.OnInternalPhysicsProcess(); + break; + case (int)Node.NotificationPostEnterTree: + autoNode.OnPostEnterTree(); + break; + case (int)Node.NotificationDisabled: + autoNode.OnDisabled(); + break; + case (int)Node.NotificationEnabled: + autoNode.OnEnabled(); + break; + case (int)Node.NotificationSceneInstantiated: + autoNode.OnSceneInstantiated(); + break; + default: + break; + } + } + + /// Notification received during object initialization. + void OnPostinitialize() { } + + /// + /// Notification received before an object is deleted by Godot (a destructor). + /// + void OnPredelete() { } + + /// + /// Method invoked when a Godot notification is received. + /// + /// Notification. + void OnNotification(int what) { } + + /// + /// Notification received when the node enters a SceneTree. + /// + void OnEnterTree() { } + + /// + /// Notification received from the OS when the node's Window ancestor is + /// focused. This may be a change of focus between two windows of the same + /// engine instance, or from the OS desktop or a third-party application to + /// a window of the game (in which case + /// is + /// also received). + /// + void OnWMWindowFocusIn() { } + + /// + /// Notification received from the OS when the node's Window ancestor loses + /// focus. This may be a change of focus between two windows of the same + /// engine instance, or from a window of the game to the OS desktop or a + /// third-party application (in which case + /// is + /// is also received). + /// + void OnWMWindowFocusOut() { } + + /// + /// Notification received from the OS when a close request is sent (e.g. + /// closing the window with a "Close" button or Alt + F4). Implemented on + /// desktop platforms. + /// + void OnWMCloseRequest() { } + + /// + /// Notification received when the window is resized. Note: Only the resized + /// Window node receives this notification, and it's not propagated to the + /// child nodes. + /// + void OnWMSizeChanged() { } + + /// + /// Notification received from the OS when the screen's dots per inch (DPI) + /// scale is changed. Only implemented on macOS. + /// + void OnWMDpiChange() { } + + /// + /// Notification received when the mouse cursor enters the Viewport's + /// visible area, that is not occluded behind other Controls or Windows, + /// provided its is false and + /// regardless if it's currently focused or not. + /// + void OnVpMouseEnter() { } + + /// + /// Notification received when the mouse cursor leaves the Viewport's visible + /// area, that is not occluded behind other Controls or Windows, provided its + /// is false and regardless if + /// it's currently focused or not. + /// + void OnVpMouseExit() { } + + /// + /// Notification received from the OS when the application is exceeding its + /// allocated memory. Implemented only on iOS. + /// + void OnOsMemoryWarning() { } + + /// + /// Notification received when translations may have changed. Can be + /// triggered by the user changing the locale, changing auto_translate_mode + /// or when the node enters the scene tree. Can be used to respond to + /// language changes, for example to change the UI strings on the fly. Useful + /// when working with the built-in translation support, like Object.tr. + ///
+ /// Note: This notification is received alongside + /// , + /// so if you are instantiating a scene, the child nodes will not be + /// initialized yet. You can use it to setup translations for this node, + /// child nodes created from script, or if you want to access child nodes + /// added in the editor, make sure the node is ready using + /// . + ///
+ void OnTranslationChanged() { } + + /// + /// Notification received from the OS when a request for "About" information + /// is sent. Implemented only on macOS. + /// + void OnWMAbout() { } + + /// + /// Notification received from Godot's crash handler when the engine is about + /// to crash. Implemented on desktop platforms, if the crash handler is + /// enabled. + /// + void OnCrash() { } + + /// + /// Notification received from the OS when an update of the Input Method + /// Engine occurs (e.g. change of IME cursor position or composition string). + /// Implemented only on macOS. + /// + void OnOsImeUpdate() { } + + /// + /// Notification received from the OS when the application is resumed. + /// Specific to the Android and iOS platforms. + /// + void OnApplicationResumed() { } + + /// + /// Notification received from the OS when the application is paused. + /// Specific to the Android and iOS platforms. + ///
+ /// Note: On iOS, you only have approximately 5 seconds to finish a task + /// started by this signal. If you go over this allotment, iOS will kill the + /// app instead of pausing it. + ///
+ void OnApplicationPaused() { } + + /// + /// Notification received from the OS when the application is focused, i.e. + /// when changing the focus from the OS desktop or a third-party application + /// to any open window of the Godot instance. Implemented on desktop and + /// mobile platforms. + /// + void OnApplicationFocusIn() { } + + /// + /// Notification received from the OS when the application has lost focus, + /// i.e. when changing the focus from any open window of the Godot instance + /// to the OS desktop or a third-party application. Implemented on desktop + /// and mobile platforms. + /// + void OnApplicationFocusOut() { } + + /// + /// Notification received when the TextServer is changed. + /// + void OnTextServerChanged() { } + + /// + /// Notification received when the mouse leaves the window. Implemented for + /// embedded windows and on desktop and web platforms. + /// + void OnWMMouseExit() { } + + /// + /// Notification received when the mouse enters the window. Implemented for + /// embedded windows and on desktop and web platforms. + /// + void OnWMMouseEnter() { } + + /// + /// Notification received from the OS when a go back request is sent (e.g. + /// pressing the "Back" button on Android). Implemented only on iOS. + /// + void OnWMGoBackRequest() { } + + /// + /// Notification received right before the scene with the node is saved in + /// the editor. This notification is only sent in the Godot editor and will + /// not occur in exported projects. + /// + void OnEditorPreSave() { } + + /// + /// Notification received when the node is about to exit a SceneTree. + /// This notification is received after the related + /// signal. + /// + void OnExitTree() { } + + /// + /// Notification received when the list of children is changed. This happens + /// when child nodes are added, moved or removed. + /// + void OnChildOrderChanged() { } + + /// + /// Notification received when the node is ready. + /// + void OnReady() { } + + /// + /// Notification received right after the scene with the node is saved in + /// the editor. This notification is only sent in the Godot editor and will + /// not occur in exported projects. + /// + void OnEditorPostSave() { } + + /// + /// Notification received when the node is unpaused. See + /// + /// + void OnUnpaused() { } + + /// + /// Notification received from the tree every physics frame when + /// returns true. + /// + /// Time since the last physics update, in seconds. + /// + void OnPhysicsProcess(double delta) { } + + /// + /// Notification received from the tree every rendered frame when + /// returns true. + /// + /// Time since the last process update, in seconds. + /// + void OnProcess(double delta) { } + + /// + /// Notification received when the node is set as a child of another node. + ///
+ /// Note: This does not mean that the node entered the SceneTree. + ///
+ void OnParented() { } + + /// + /// Notification received when the parent node calls + /// on this node. + ///
+ /// Note: This does not mean that the node exited the SceneTree. + ///
+ void OnUnparented() { } + + /// + /// Notification received when the node is paused. See + /// + /// + void OnPaused() { } + + /// + /// Notification received when a drag operation begins. All nodes receive + /// this notification, not only the dragged one. + ///
+ /// Can be triggered either by dragging a Control that provides drag + /// data (see + /// or by using + /// . + ///
+ /// Use see to get the dragged + /// data. + ///
+ void OnDragBegin() { } + + /// + /// Notification received when a drag operation ends. Use + /// to check if the drag + /// succeeded. + /// + void OnDragEnd() { } + + /// + /// Notification received when the node's name or one of its ancestors' + /// name is changed. This notification is not received when the node is + /// removed from the SceneTree. + /// + void OnPathRenamed() { } + + /// + /// Notification received from the tree every rendered frame when + /// returns true. + /// + void OnInternalProcess() { } + + /// + /// Notification received from the tree every physics frame when + /// returns true. + /// + void OnInternalPhysicsProcess() { } + + /// + /// Notification received when the node enters the tree, just before + /// may be received. Unlike the + /// latter, it is sent every time the node enters the tree, not just once. + /// + void OnPostEnterTree() { } + + /// + /// Notification received when the node is disabled. See + /// . + /// + void OnDisabled() { } + + /// + /// Notification received when the node is enabled again after being disabled. + /// See . + /// + void OnEnabled() { } + + /// + /// Notification received only by the newly instantiated scene root node, + /// when + /// + /// is completed. + /// + void OnSceneInstantiated() { } +} diff --git a/Chickensoft.AutoInject.Tests/src/notifications/NotificationExtensions.cs b/Chickensoft.AutoInject.Tests/src/notifications/NotificationExtensions.cs new file mode 100644 index 0000000..49b548e --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/notifications/NotificationExtensions.cs @@ -0,0 +1,46 @@ +namespace Chickensoft.AutoInject; + +using System.Runtime.CompilerServices; +using Chickensoft.Introspection; +using Godot; + +public static class NotificationExtensions { + /// + /// Notify mixins applied to a Godot object that a notification has been + /// received. + /// + /// Godot object. + /// Godot object notification. + public static void Notify(this GodotObject obj, int what) { + obj.__SetupNotificationStateIfNeeded(); + + if (obj is not IIntrospective introspective) { + return; + } + + // Share the notification that just occurred with the mixins we're + // about to invoke. + introspective.MixinState.Get().Notification = what; + + // Invoke each mixin's handler method. + introspective.InvokeMixins(); + + // If we're an IAutoOn, invoke the notification methods like OnReady, + // OnProcess, etc. We specifically do this last. + if (obj is IAutoOn autoOn) { + IAutoOn.InvokeNotificationMethods(introspective, what); + } + } + +#pragma warning disable IDE1006 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void __SetupNotificationStateIfNeeded(this GodotObject obj) { + if (obj is not IIntrospective introspective) { + return; + } + + if (!introspective.MixinState.Has()) { + introspective.MixinState.Overwrite(new NotificationState()); + } + } +} diff --git a/Chickensoft.AutoInject.Tests/src/notifications/NotificationState.cs b/Chickensoft.AutoInject.Tests/src/notifications/NotificationState.cs new file mode 100644 index 0000000..a7cf8fc --- /dev/null +++ b/Chickensoft.AutoInject.Tests/src/notifications/NotificationState.cs @@ -0,0 +1,8 @@ +namespace Chickensoft.AutoInject; + +public class NotificationState { + /// + /// Most recently received Godot object notification. + /// + public int Notification { get; set; } = -1; +} diff --git a/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectInvalidCastTestScene.cs b/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectInvalidCastTestScene.cs new file mode 100644 index 0000000..8139799 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectInvalidCastTestScene.cs @@ -0,0 +1,14 @@ +namespace Chickensoft.AutoInject.Tests.Fixtures; + +using Chickensoft.GodotNodeInterfaces; +using Chickensoft.Introspection; +using Chickensoft.AutoInject; +using Godot; + +[Meta(typeof(IAutoConnect))] +public partial class AutoConnectInvalidCastTestScene : Node2D { + public override void _Notification(int what) => this.Notify(what); + + [Node("Node3D")] + public INode2D Node { get; set; } = default!; +} diff --git a/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectInvalidCastTestScene.tscn b/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectInvalidCastTestScene.tscn new file mode 100644 index 0000000..3d323b8 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectInvalidCastTestScene.tscn @@ -0,0 +1,8 @@ +[gd_scene load_steps=2 format=3 uid="uid://buifevchck4xm"] + +[ext_resource type="Script" path="res://test/fixtures/AutoConnectInvalidCastTestScene.cs" id="1_1ef0r"] + +[node name="AutoConnectInvalidCastTestScene" type="Node2D"] +script = ExtResource("1_1ef0r") + +[node name="Node3D" type="Node3D" parent="."] diff --git a/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectMissingTestScene.cs b/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectMissingTestScene.cs new file mode 100644 index 0000000..e30496a --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectMissingTestScene.cs @@ -0,0 +1,14 @@ +namespace Chickensoft.AutoInject.Tests.Fixtures; + +using Chickensoft.GodotNodeInterfaces; +using Chickensoft.Introspection; +using Chickensoft.AutoInject; +using Godot; + +[Meta(typeof(IAutoConnect))] +public partial class AutoConnectMissingTestScene : Node2D { + public override void _Notification(int what) => this.Notify(what); + + [Node("NonExistentNode")] + public INode2D MyNode { get; set; } = default!; +} diff --git a/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectMissingTestScene.tscn b/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectMissingTestScene.tscn new file mode 100644 index 0000000..1c99a7c --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectMissingTestScene.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://gvbk8s4ox36p"] + +[ext_resource type="Script" path="res://test/fixtures/AutoConnectMissingTestScene.cs" id="1_5ywa5"] + +[node name="AutoConnectMissingTestScene" type="Node2D"] +script = ExtResource("1_5ywa5") diff --git a/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectTestScene.cs b/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectTestScene.cs new file mode 100644 index 0000000..786aca5 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectTestScene.cs @@ -0,0 +1,30 @@ +namespace Chickensoft.AutoInject.Tests.Fixtures; + +using Chickensoft.GodotNodeInterfaces; +using Chickensoft.Introspection; +using Chickensoft.AutoInject; +using Godot; + +[Meta(typeof(IAutoConnect))] +public partial class AutoConnectTestScene : Node2D { + public override void _Notification(int what) => this.Notify(what); + + [Node("Path/To/MyNode")] + public INode2D MyNode { get; set; } = default!; + + [Node("Path/To/MyNode")] + public Node2D MyNodeOriginal { get; set; } = default!; + + [Node] + public INode2D MyUniqueNode { get; set; } = default!; + + [Node("%OtherUniqueName")] + public INode2D DifferentName { get; set; } = default!; + +#pragma warning disable IDE1006 + [Node] + internal INode2D _my_unique_node { get; set; } = default!; + + [Other] + public INode2D SomeOtherNodeReference { get; set; } = default!; +} diff --git a/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectTestScene.tscn b/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectTestScene.tscn new file mode 100644 index 0000000..e58f22d --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/fixtures/AutoConnectTestScene.tscn @@ -0,0 +1,18 @@ +[gd_scene load_steps=2 format=3 uid="uid://bc0rrd0etom5k"] + +[ext_resource type="Script" path="res://test/fixtures/AutoConnectTestScene.cs" id="1_ego6e"] + +[node name="AutoConnectTestScene" type="Node2D"] +script = ExtResource("1_ego6e") + +[node name="Path" type="Node2D" parent="."] + +[node name="To" type="Node2D" parent="Path"] + +[node name="MyNode" type="Node2D" parent="Path/To"] + +[node name="MyUniqueNode" type="Node2D" parent="."] +unique_name_in_owner = true + +[node name="OtherUniqueName" type="Node2D" parent="."] +unique_name_in_owner = true diff --git a/Chickensoft.AutoInject.Tests/test/fixtures/AutoSetupTestNode.cs b/Chickensoft.AutoInject.Tests/test/fixtures/AutoSetupTestNode.cs new file mode 100644 index 0000000..c86911a --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/fixtures/AutoSetupTestNode.cs @@ -0,0 +1,19 @@ +namespace Chickensoft.AutoInject.Tests.Fixtures; + +using Chickensoft.Introspection; +using Chickensoft.AutoInject; +using Godot; + +[Meta(typeof(IAutoInit))] +public partial class AutoInitTestNode : Node2D { + public override void _Notification(int what) => this.Notify(what); + + public bool SetupCalled { get; set; } + + public void Initialize() => SetupCalled = true; +} + +[Meta(typeof(IAutoInit))] +public partial class AutoInitTestNodeNoImplementation : Node2D { + public override void _Notification(int what) => this.Notify(what); +} diff --git a/Chickensoft.AutoInject.Tests/test/src/subjects/Dependents.cs b/Chickensoft.AutoInject.Tests/test/fixtures/Dependents.cs similarity index 64% rename from Chickensoft.AutoInject.Tests/test/src/subjects/Dependents.cs rename to Chickensoft.AutoInject.Tests/test/fixtures/Dependents.cs index 60b79d8..589b7bd 100644 --- a/Chickensoft.AutoInject.Tests/test/src/subjects/Dependents.cs +++ b/Chickensoft.AutoInject.Tests/test/fixtures/Dependents.cs @@ -1,14 +1,14 @@ namespace Chickensoft.AutoInject.Tests.Subjects; +using Chickensoft.Introspection; using Godot; -using SuperNodes.Types; -[SuperNode(typeof(Dependent))] +[Meta(typeof(IAutoOn), typeof(IDependent))] public partial class StringDependent : Node { - public override partial void _Notification(int what); + public override void _Notification(int what) => this.Notify(what); [Dependency] - public string MyDependency => DependOn(); + public string MyDependency => this.DependOn(); public bool OnResolvedCalled { get; private set; } public string ResolvedValue { get; set; } = ""; @@ -21,12 +21,12 @@ public void OnResolved() { } } -[SuperNode(typeof(Dependent))] +[Meta(typeof(IAutoOn), typeof(IDependent))] public partial class FakedDependent : Node { - public override partial void _Notification(int what); + public override void _Notification(int what) => this.Notify(what); [Dependency] - public string MyDependency => DependOn(() => "fallback"); + public string MyDependency => this.DependOn(() => "fallback"); public bool OnResolvedCalled { get; private set; } public string ResolvedValue { get; set; } = ""; @@ -37,12 +37,12 @@ public void OnResolved() { } } -[SuperNode(typeof(Dependent))] +[Meta(typeof(IAutoOn), typeof(IDependent))] public partial class StringDependentFallback : Node { - public override partial void _Notification(int what); + public override void _Notification(int what) => this.Notify(what); [Dependency] - public string MyDependency => DependOn(() => FallbackValue); + public string MyDependency => this.DependOn(() => FallbackValue); public string FallbackValue { get; set; } = ""; public bool OnResolvedCalled { get; private set; } @@ -56,12 +56,12 @@ public void OnResolved() { } } -[SuperNode(typeof(Dependent))] +[Meta(typeof(IAutoOn), typeof(IDependent))] public partial class IntDependent : Node { - public override partial void _Notification(int what); + public override void _Notification(int what) => this.Notify(what); [Dependency] - public int MyDependency => DependOn(); + public int MyDependency => this.DependOn(); public bool OnResolvedCalled { get; private set; } public int ResolvedValue { get; set; } @@ -74,15 +74,15 @@ public void OnResolved() { } } -[SuperNode(typeof(Dependent))] +[Meta(typeof(IAutoOn), typeof(IDependent))] public partial class MultiDependent : Node { - public override partial void _Notification(int what); + public override void _Notification(int what) => this.Notify(what); [Dependency] - public int IntDependency => DependOn(); + public int IntDependency => this.DependOn(); [Dependency] - public string StringDependency => DependOn(); + public string StringDependency => this.DependOn(); public bool OnResolvedCalled { get; private set; } public int IntResolvedValue { get; set; } @@ -97,9 +97,9 @@ public void OnResolved() { } } -[SuperNode(typeof(Dependent))] +[Meta(typeof(IAutoOn), typeof(IDependent))] public partial class NoDependenciesDependent : Node { - public override partial void _Notification(int what); + public override void _Notification(int what) => this.Notify(what); public bool OnResolvedCalled { get; private set; } diff --git a/Chickensoft.AutoInject.Tests/test/fixtures/MultiProvider.cs b/Chickensoft.AutoInject.Tests/test/fixtures/MultiProvider.cs index 68a0515..62e9a23 100644 --- a/Chickensoft.AutoInject.Tests/test/fixtures/MultiProvider.cs +++ b/Chickensoft.AutoInject.Tests/test/fixtures/MultiProvider.cs @@ -2,12 +2,12 @@ namespace Chickensoft.AutoInject.Tests.Fixtures; using Chickensoft.AutoInject; using Chickensoft.AutoInject.Tests.Subjects; +using Chickensoft.Introspection; using Godot; -using SuperNodes.Types; -[SuperNode(typeof(Provider))] +[Meta(typeof(IAutoOn), typeof(IProvider))] public partial class MultiProvider : Node2D, IProvide, IProvide { - public override partial void _Notification(int what); + public override void _Notification(int what) => this.Notify(what); int IProvide.Value() => IntValue; string IProvide.Value() => StringValue; @@ -18,7 +18,7 @@ public override void _Ready() { Child = new MultiDependent(); AddChild(Child); - Provide(); + this.Provide(); } public bool OnProvidedCalled { get; private set; } diff --git a/Chickensoft.AutoInject.Tests/test/fixtures/MyNode.cs b/Chickensoft.AutoInject.Tests/test/fixtures/MyNode.cs new file mode 100644 index 0000000..2359ebb --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/fixtures/MyNode.cs @@ -0,0 +1,24 @@ +namespace Chickensoft.AutoInject.Tests.Fixtures; + +using Chickensoft.GodotNodeInterfaces; +using Chickensoft.Introspection; +using Chickensoft.AutoInject; +using Godot; + +[Meta(typeof(IAutoConnect))] +public partial class MyNode : Node2D { + public override void _Notification(int what) => this.Notify(what); + + [Node("Path/To/SomeNode")] + public INode2D SomeNode { get; set; } = default!; + + [Node] // Connects to "%MyUniqueNode" since no path was specified. + public INode2D MyUniqueNode { get; set; } = default!; + + [Node("%OtherUniqueName")] + public INode2D DifferentName { get; set; } = default!; + +#pragma warning disable IDE1006 + [Node] // Connects to "%MyUniqueNode" since no path was specified. + internal INode2D _my_unique_node { get; set; } = default!; +} diff --git a/Chickensoft.AutoInject.Tests/test/fixtures/OtherAttribute.cs b/Chickensoft.AutoInject.Tests/test/fixtures/OtherAttribute.cs new file mode 100644 index 0000000..a50719f --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/fixtures/OtherAttribute.cs @@ -0,0 +1,6 @@ +namespace Chickensoft.AutoInject.Tests.Fixtures; + +using System; + +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)] +public class OtherAttribute : Attribute { } diff --git a/Chickensoft.AutoInject.Tests/test/src/subjects/Providers.cs b/Chickensoft.AutoInject.Tests/test/fixtures/Providers.cs similarity index 65% rename from Chickensoft.AutoInject.Tests/test/src/subjects/Providers.cs rename to Chickensoft.AutoInject.Tests/test/fixtures/Providers.cs index 755880f..7870786 100644 --- a/Chickensoft.AutoInject.Tests/test/src/subjects/Providers.cs +++ b/Chickensoft.AutoInject.Tests/test/fixtures/Providers.cs @@ -1,32 +1,32 @@ namespace Chickensoft.AutoInject.Tests.Subjects; using Chickensoft.AutoInject; +using Chickensoft.Introspection; using Godot; -using SuperNodes.Types; // Provider nodes created to be used as test subjects. -[SuperNode(typeof(Provider))] +[Meta(typeof(IAutoOn), typeof(IProvider))] public partial class StringProvider : Node, IProvide { - public override partial void _Notification(int what); + public override void _Notification(int what) => this.Notify(what); string IProvide.Value() => Value; public bool OnProvidedCalled { get; private set; } public string Value { get; set; } = ""; - public void OnReady() => Provide(); + public void OnReady() => this.Provide(); public void OnProvided() => OnProvidedCalled = true; } -[SuperNode(typeof(Provider))] +[Meta(typeof(IAutoOn), typeof(IProvider))] public partial class IntProvider : Node, IProvide { - public override partial void _Notification(int what); + public override void _Notification(int what) => this.Notify(what); int IProvide.Value() => Value; - public void OnReady() => Provide(); + public void OnReady() => this.Provide(); public bool OnProvidedCalled { get; private set; } public int Value { get; set; } diff --git a/Chickensoft.AutoInject.Tests/test/src/AutoConnectInvalidCastTest.cs b/Chickensoft.AutoInject.Tests/test/src/AutoConnectInvalidCastTest.cs new file mode 100644 index 0000000..81c4a2b --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/src/AutoConnectInvalidCastTest.cs @@ -0,0 +1,33 @@ +namespace Chickensoft.AutoInject.Tests; + +using System; +using Chickensoft.GodotNodeInterfaces; +using Chickensoft.GoDotTest; +using Chickensoft.AutoInject.Tests.Fixtures; +using Godot; +using Moq; +using Shouldly; + +public class AutoConnectInvalidCastTest(Node testScene) : TestClass(testScene) { + [Test] + public void ThrowsOnIncorrectNodeType() { + var scene = GD.Load( + "res://test/fixtures/AutoConnectInvalidCastTestScene.tscn" + ); + // AutoNode will actually throw an InvalidCastException + // during the scene instantiation, but for whatever reason that doesn't + // happen on our call stack. So we just make sure the node is null after :/ + var node = scene.Instantiate(); + node.Node.ShouldBeNull(); + } + + [Test] + public void ThrowsIfFakedChildNodeIsWrongType() { + var scene = new AutoConnectInvalidCastTestScene(); + scene.FakeNodeTree(new() { ["Node3D"] = new Mock().Object }); + + Should.Throw( + () => scene._Notification((int)Node.NotificationSceneInstantiated) + ); + } +} diff --git a/Chickensoft.AutoInject.Tests/test/src/AutoConnectMissingTest.cs b/Chickensoft.AutoInject.Tests/test/src/AutoConnectMissingTest.cs new file mode 100644 index 0000000..a97a483 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/src/AutoConnectMissingTest.cs @@ -0,0 +1,18 @@ +namespace Chickensoft.AutoInject.Tests; + +using Chickensoft.GoDotTest; +using Chickensoft.AutoInject.Tests.Fixtures; +using Godot; +using Shouldly; + +public class AutoConnectMissingTest(Node testScene) : TestClass(testScene) { + [Test] + public void ThrowsOnMissingNode() { + var scene = GD.Load("res://test/fixtures/AutoConnectMissingTestScene.tscn"); + // AutoNode will actually throw an InvalidOperationException + // during the scene instantiation, but for whatever reason that doesn't + // happen on our call stack. So we just make sure the node is null after :/ + var node = scene.InstantiateOrNull(); + node.MyNode.ShouldBeNull(); + } +} diff --git a/Chickensoft.AutoInject.Tests/test/src/AutoConnectTest.cs b/Chickensoft.AutoInject.Tests/test/src/AutoConnectTest.cs new file mode 100644 index 0000000..5b74361 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/src/AutoConnectTest.cs @@ -0,0 +1,55 @@ +namespace Chickensoft.AutoInject.Tests; + +using System.Threading.Tasks; +using Chickensoft.GoDotTest; +using Chickensoft.AutoInject.Tests.Fixtures; +using Godot; +using GodotTestDriver; +using Shouldly; +using System; +using Chickensoft.Introspection; + +public partial class AutoConnectTest(Node testScene) : TestClass(testScene) { + private Fixture _fixture = default!; + private AutoConnectTestScene _scene = default!; + + [Meta(typeof(IAutoConnect))] + public partial class NotAGodotNode { } + + [Setup] + public async Task Setup() { + _fixture = new Fixture(TestScene.GetTree()); + _scene = await _fixture.LoadAndAddScene(); + } + + [Cleanup] + public async Task Cleanup() => await _fixture.Cleanup(); + + [Test] + public void ConnectsNodesCorrectlyWhenInstantiated() { + _scene.MyNode.ShouldNotBeNull(); + _scene.MyNodeOriginal.ShouldNotBeNull(); + _scene.MyUniqueNode.ShouldNotBeNull(); + _scene.DifferentName.ShouldNotBeNull(); + _scene._my_unique_node.ShouldNotBeNull(); + _scene.SomeOtherNodeReference.ShouldBeNull(); + } + + [Test] + public void NonAutoConnectNodeThrows() { + var node = new Node(); + Should.Throw(() => node.FakeNodeTree(null)); + } + + [Test] + public void FakeNodesDoesNothingIfGivenNull() { + IAutoConnect node = new AutoConnectTestScene(); + Should.NotThrow(() => node.FakeNodes = null); + } + + [Test] + public void AddStateIfNeededDoesNothingIfNotAGodotNode() { + IAutoConnect node = new NotAGodotNode(); + Should.NotThrow(() => node._AddStateIfNeeded()); + } +} diff --git a/Chickensoft.AutoInject.Tests/test/src/AutoInitTest.cs b/Chickensoft.AutoInject.Tests/test/src/AutoInitTest.cs new file mode 100644 index 0000000..f5cf2f2 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/src/AutoInitTest.cs @@ -0,0 +1,39 @@ +namespace Chickensoft.AutoInject.Tests; +using Chickensoft.GoDotTest; +using Chickensoft.AutoInject.Tests.Fixtures; +using Godot; +using Shouldly; +using Chickensoft.Introspection; + +public partial class AutoInitTest(Node testScene) : TestClass(testScene) { + [Meta(typeof(IAutoInit))] + public partial class NotAGodotNode { } + + [Test] + public void SetsUpNode() { + var node = new AutoInitTestNode(); + + node._Notification((int)Node.NotificationReady); + + node.SetupCalled.ShouldBeTrue(); + } + + [Test] + public void DefaultImplementationDoesNothing() { + var node = new AutoInitTestNodeNoImplementation(); + + node._Notification((int)Node.NotificationReady); + } + + [Test] + public void IsTestingCreatesStateIfSetFirst() { + var node = new AutoInitTestNode(); + (node as IAutoInit).IsTesting = true; + } + + [Test] + public void HandlerDoesNotWorkIfNotGodotNode() => Should.NotThrow(() => { + var node = new NotAGodotNode(); + (node as IAutoInit).Handler(); + }); +} diff --git a/Chickensoft.AutoInject.Tests/test/src/AutoNodeTest.cs b/Chickensoft.AutoInject.Tests/test/src/AutoNodeTest.cs new file mode 100644 index 0000000..defb584 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/src/AutoNodeTest.cs @@ -0,0 +1,33 @@ +namespace Chickensoft.AutoInject.Tests; +using Chickensoft.GoDotTest; +using Godot; +using Shouldly; +using Chickensoft.Introspection; + +public partial class AutoNodeTest(Node testScene) : TestClass(testScene) { + [Meta(typeof(IAutoNode))] + public partial class NotAGodotNode : GodotObject { } + + + [Test] + public void MixinHandlerActuallyDoesNothing() { + IMixin node = new NotAGodotNode(); + + Should.NotThrow(node.Handler); + } + + [Test] + public void CallsOtherMixins() => Should.NotThrow(() => { + + var node = new NotAGodotNode(); + + node.__SetupNotificationStateIfNeeded(); + + IIntrospective introspective = node; + + // Some mixins need this data. + node.MixinState.Get().Notification = -1; + + introspective.InvokeMixins(); + }); +} diff --git a/Chickensoft.AutoInject.Tests/test/src/AutoOnTest.cs b/Chickensoft.AutoInject.Tests/test/src/AutoOnTest.cs new file mode 100644 index 0000000..94662fd --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/src/AutoOnTest.cs @@ -0,0 +1,168 @@ +namespace Chickensoft.AutoInject.Tests; + +using Chickensoft.GoDotTest; +using Chickensoft.Introspection; +using Godot; +using Shouldly; + +public partial class AutoOnTest(Node testScene) : TestClass(testScene) { + [Meta(typeof(IAutoOn))] + public partial class AutoOnTestNode : Node { } + + [Meta(typeof(IAutoOn))] + public partial class NotAGodotNode { } + + public class NotAutoOn { } + + [Test] + public void DoesNothingIfNotAGodotNode() { + var node = new NotAGodotNode(); + + Should.NotThrow(() => IAutoOn.InvokeNotificationMethods(node, 1)); + } + + [Test] + public void DOesNothingIfNotAutoOn() { + var node = new NotAutoOn(); + + Should.NotThrow(() => IAutoOn.InvokeNotificationMethods(node, 1)); + } + + [Test] + public void InvokesHandlerForNotification() { + var node = new AutoOnTestNode(); + IAutoOn autoNode = node; + + Should.NotThrow(() => { + IAutoOn.InvokeNotificationMethods( + autoNode, (int)GodotObject.NotificationPostinitialize + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)GodotObject.NotificationPredelete + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationEnterTree + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationWMWindowFocusIn + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationWMWindowFocusOut + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationWMCloseRequest + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationWMSizeChanged + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationWMDpiChange + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationVpMouseEnter + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationVpMouseExit + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationOsMemoryWarning + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationTranslationChanged + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationWMAbout + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationCrash + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationOsImeUpdate + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationApplicationResumed + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationApplicationPaused + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationApplicationFocusIn + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationApplicationFocusOut + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationTextServerChanged + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationWMMouseExit + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationWMMouseEnter + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationWMGoBackRequest + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationEditorPreSave + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationExitTree + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationChildOrderChanged + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationReady + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationEditorPostSave + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationUnpaused + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationPhysicsProcess + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationProcess + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationParented + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationUnparented + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationPaused + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationDragBegin + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationDragEnd + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationPathRenamed + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationInternalProcess + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationInternalPhysicsProcess + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationPostEnterTree + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationDisabled + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationEnabled + ); + IAutoOn.InvokeNotificationMethods( + autoNode, (int)Node.NotificationSceneInstantiated + ); + }); + } +} diff --git a/Chickensoft.AutoInject.Tests/test/src/FakeNodeTreeTest.cs b/Chickensoft.AutoInject.Tests/test/src/FakeNodeTreeTest.cs new file mode 100644 index 0000000..2330300 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/src/FakeNodeTreeTest.cs @@ -0,0 +1,153 @@ +namespace Chickensoft.AutoInject.Tests; + +using System; +using System.Collections.Generic; +using Chickensoft.GodotNodeInterfaces; +using Chickensoft.GoDotTest; +using Godot; +using Moq; +using Shouldly; + +public class FakeNodeTreeTest : TestClass { + public FakeNodeTreeTest(Node testScene) : base(testScene) { + var a = new Mock(); + var b = new Mock(); + var c = new Mock(); + + a.Setup(n => n.Name).Returns("A"); + c.Setup(n => n.Name).Returns("C"); + + A = a.Object; + B = b.Object; + C = c.Object; + } + + public INode A { get; } + public INode B { get; } + public INode C { get; } + + [Test] + public void InitializesAndGetsChildrenAndShowsHasChildren() { + var children = new Dictionary() { ["A"] = A, ["B"] = B }; + var tree = new FakeNodeTree(TestScene, children); + + tree.GetChildren().ShouldBe([A, B]); + tree.HasNode("A").ShouldBeTrue(); + tree.HasNode("B").ShouldBeTrue(); + + tree.GetChildCount().ShouldBe(2); + + tree.GetAllNodes().ShouldBe(new Dictionary() { + ["A"] = A, + ["B"] = B + }); + } + + [Test] + public void InitializesWithNothing() { + var tree = new FakeNodeTree(TestScene); + + tree.GetChildren().ShouldBeEmpty(); + } + + [Test] + public void AddChildWorks() { + var children = new Dictionary() { ["A"] = A, ["B"] = B }; + var tree = new FakeNodeTree(TestScene, children); + + tree.AddChild(C); + tree.GetChildren().ShouldBe([A, B, C]); + tree.HasNode("A").ShouldBeTrue(); + tree.HasNode("B").ShouldBeTrue(); + tree.HasNode("C").ShouldBeTrue(); + + tree.GetChildCount().ShouldBe(3); + } + + [Test] + public void AddChildGeneratesNameForNodeIfNeeded() { + var tree = new FakeNodeTree(TestScene); + tree.AddChild(B); + tree.GetNode(B.GetType().Name + "@0").ShouldBe(B); + } + + [Test] + public void GetNodeReturnsNode() { + var children = new Dictionary() { ["A"] = A, ["B"] = B }; + var tree = new FakeNodeTree(TestScene, children); + + tree.GetNode("A").ShouldBe(A); + tree.GetNode("A").ShouldBe(A); + tree.GetNode("B").ShouldBe(B); + tree.GetNode("nonexistent").ShouldBeNull(); + tree.GetNode("nonexistent").ShouldBeNull(); + } + + [Test] + public void FindChildReturnsMatchingNode() { + var children = new Dictionary() { ["A"] = A, ["B"] = B, ["C"] = C }; + var tree = new FakeNodeTree(TestScene, children); + + var result = tree.FindChild("A"); + result.ShouldBe(A); + } + + [Test] + public void FindChildReturnsNullOnNoMatch() { + var children = new Dictionary() { ["A"] = A, ["B"] = B, ["C"] = C }; + var tree = new FakeNodeTree(TestScene, children); + + var result = tree.FindChild("D"); + result.ShouldBeNull(); + } + + [Test] + public void FindChildrenReturnsMatchingNodes() { + var children = new Dictionary() { ["Apple"] = A, ["Banana"] = B, ["Cherry"] = C }; + var tree = new FakeNodeTree(TestScene, children); + + var results = tree.FindChildren("C*"); + results.ShouldBe([C]); + } + + [Test] + public void GetChildReturnsNodeByIndex() { + var children = new Dictionary() { ["A"] = A, ["B"] = B, ["C"] = C }; + var tree = new FakeNodeTree(TestScene, children); + + var result = tree.GetChild(1); // Get the second child (B). + var result2 = tree.GetChild(1); + result.ShouldBe(B); + result.ShouldBeSameAs(result2); + } + + [Test] + public void GetChildThrowsOnInvalidIndex() { + var tree = new FakeNodeTree(TestScene); + + Should.Throw(() => tree.GetChild(0)); + } + + [Test] + public void GetChildUsesNegativeIndexToGetFromEnd() { + var children = new Dictionary() { ["A"] = A, ["B"] = B, ["C"] = C }; + var tree = new FakeNodeTree(TestScene, children); + + var result = tree.GetChild(-1); + result.ShouldBe(C); + } + + [Test] + public void RemoveChildRemovesNode() { + var children = new Dictionary() { ["A"] = A, ["B"] = B, ["C"] = C }; + var tree = new FakeNodeTree(TestScene, children); + + tree.GetChildCount().ShouldBe(3); + + tree.RemoveChild(B); // Remove the "B" node. + tree.HasNode("B").ShouldBeFalse(); + tree.GetChildren().ShouldBe([A, C]); + + tree.GetChildCount().ShouldBe(2); + } +} diff --git a/Chickensoft.AutoInject.Tests/test/src/MiscTest.cs b/Chickensoft.AutoInject.Tests/test/src/MiscTest.cs index 7ca5889..58223a9 100644 --- a/Chickensoft.AutoInject.Tests/test/src/MiscTest.cs +++ b/Chickensoft.AutoInject.Tests/test/src/MiscTest.cs @@ -3,46 +3,20 @@ namespace Chickensoft.AutoInject.Tests; using System; using Chickensoft.AutoInject.Tests.Subjects; using Chickensoft.GoDotTest; +using Chickensoft.Introspection; using Godot; using Shouldly; -public partial class TestDependent : Dependent { } - -public class MiscTest : TestClass { - public MiscTest(Node testScene) : base(testScene) { } - - [Test] - public void DependentStubs() { - Dependent.ScriptPropertiesAndFields.ShouldNotBeNull(); - Dependent.ReceiveScriptPropertyOrFieldType( - default!, default! - ).ShouldBe(default!); - - var dependent = new TestDependent(); - - // dependent.AllDependencies - - Should.Throw( - () => dependent.PropertiesAndFields - ); - Should.Throw( - () => dependent.GetScriptPropertyOrFieldType("a", default!) - ); - Should.Throw( - () => dependent.GetScriptPropertyOrField("a") - ); - Should.Throw( - () => dependent.SetScriptPropertyOrField("a", default!) - ); - - dependent.QueueFree(); - } +[Meta(typeof(IAutoOn), typeof(IDependent))] +public partial class TestDependent { } +public class MiscTest(Node testScene) : TestClass(testScene) { [Test] public void DependencyPendingCancels() { - var provider = new StringProvider(); + var obj = new StringProvider(); + var provider = obj as IBaseProvider; var initialized = false; - void onInitialized(IProvider provider) => initialized = true; + void onInitialized(IBaseProvider provider) => initialized = true; provider.ProviderState.OnInitialized += onInitialized; @@ -54,7 +28,7 @@ public void DependencyPendingCancels() { initialized.ShouldBeFalse(); - provider.QueueFree(); + obj.QueueFree(); } [Test] diff --git a/Chickensoft.AutoInject.Tests/test/src/MultiResolutionTest.cs b/Chickensoft.AutoInject.Tests/test/src/MultiResolutionTest.cs index c89101e..0abab5a 100644 --- a/Chickensoft.AutoInject.Tests/test/src/MultiResolutionTest.cs +++ b/Chickensoft.AutoInject.Tests/test/src/MultiResolutionTest.cs @@ -8,13 +8,10 @@ namespace Chickensoft.GodotGame; using GodotTestDriver.Util; using Shouldly; -#pragma warning disable CA1001 -public class MultiResolutionTest : TestClass { +public class MultiResolutionTest(Node testScene) : TestClass(testScene) { private Fixture _fixture = default!; private MultiProvider _provider = default!; - public MultiResolutionTest(Node testScene) : base(testScene) { } - [Setup] public void Setup() { _fixture = new Fixture(TestScene.GetTree()); @@ -34,4 +31,3 @@ public async Task MultiDependentSubscribesToMultiProviderCorrectly() { _provider.Child.OnResolvedCalled.ShouldBeTrue(); } } -#pragma warning restore CA1001 diff --git a/Chickensoft.AutoInject.Tests/test/src/MyNodeTest.cs b/Chickensoft.AutoInject.Tests/test/src/MyNodeTest.cs new file mode 100644 index 0000000..90ace9b --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/src/MyNodeTest.cs @@ -0,0 +1,54 @@ +namespace Chickensoft.AutoInject.Tests; + +using System.Threading.Tasks; +using Chickensoft.GodotNodeInterfaces; +using Chickensoft.GoDotTest; +using Chickensoft.AutoInject.Tests.Fixtures; +using Godot; +using GodotTestDriver; +using Moq; +using Shouldly; + +#pragma warning disable CA1001 +public class MyNodeTest(Node testScene) : TestClass(testScene) { + private Fixture _fixture = default!; + private MyNode _scene = default!; + + private Mock _someNode = default!; + private Mock _myUniqueNode = default!; + private Mock _otherUniqueNode = default!; + + [Setup] + public async Task Setup() { + _fixture = new(TestScene.GetTree()); + + _someNode = new(); + _myUniqueNode = new(); + _otherUniqueNode = new(); + + _scene = new MyNode(); + _scene.FakeNodeTree(new() { + ["Path/To/SomeNode"] = _someNode.Object, + ["%MyUniqueNode"] = _myUniqueNode.Object, + ["%OtherUniqueName"] = _otherUniqueNode.Object, + }); + + await _fixture.AddToRoot(_scene); + } + + [Cleanup] + public async Task Cleanup() => await _fixture.Cleanup(); + + [Test] + public void UsesFakeNodeTree() { + // Making a new instance of a node without instantiating a scene doesn't + // trigger NotificationSceneInstantiated, so if we want to make sure our + // AutoNodes get hooked up and use the FakeNodeTree, we need to do it manually. + _scene._Notification((int)Node.NotificationSceneInstantiated); + + _scene.SomeNode.ShouldBe(_someNode.Object); + _scene.MyUniqueNode.ShouldBe(_myUniqueNode.Object); + _scene.DifferentName.ShouldBe(_otherUniqueNode.Object); + _scene._my_unique_node.ShouldBe(_myUniqueNode.Object); + } +} diff --git a/Chickensoft.AutoInject.Tests/test/src/NodeAttributeTest.cs b/Chickensoft.AutoInject.Tests/test/src/NodeAttributeTest.cs new file mode 100644 index 0000000..e187850 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/src/NodeAttributeTest.cs @@ -0,0 +1,12 @@ +namespace Chickensoft.AutoInject.Tests; +using Chickensoft.GoDotTest; +using Godot; +using Shouldly; + +public class NodeAttributeTest(Node testScene) : TestClass(testScene) { + [Test] + public void Initializes() { + var attr = new NodeAttribute("path"); + attr.Path.ShouldBe("path"); + } +} diff --git a/Chickensoft.AutoInject.Tests/test/src/NotificationExtensionsTest.cs b/Chickensoft.AutoInject.Tests/test/src/NotificationExtensionsTest.cs new file mode 100644 index 0000000..61b431c --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/src/NotificationExtensionsTest.cs @@ -0,0 +1,17 @@ +namespace Chickensoft.AutoInject.Tests; +using Chickensoft.GoDotTest; +using Godot; +using Shouldly; + + + +public partial class NotificationExtensionsTest( + Node testScene +) : TestClass(testScene) { + [Test] + public void DoesNothingIfNotIntrospective() { + var node = new Node(); + + Should.NotThrow(() => node.Notify(1)); + } +} diff --git a/Chickensoft.AutoInject.Tests/test/src/ResolutionTest.cs b/Chickensoft.AutoInject.Tests/test/src/ResolutionTest.cs index 4503105..b4e1dcc 100644 --- a/Chickensoft.AutoInject.Tests/test/src/ResolutionTest.cs +++ b/Chickensoft.AutoInject.Tests/test/src/ResolutionTest.cs @@ -5,9 +5,7 @@ namespace Chickensoft.AutoInject.Tests; using Godot; using Shouldly; -public class ResolutionTest : TestClass { - public ResolutionTest(Node testScene) : base(testScene) { } - +public class ResolutionTest(Node testScene) : TestClass(testScene) { [Test] public void Provides() { var value = "Hello, world!"; @@ -23,105 +21,102 @@ public void Provides() { [Test] public void ProviderResetsOnTreeExit() { var value = "Hello, world!"; - var provider = new StringProvider() { Value = value }; + var obj = new StringProvider() { Value = value }; + var provider = obj as IBaseProvider; ((IProvide)provider).Value().ShouldBe(value); - provider._Notification((int)Node.NotificationReady); - + obj._Notification((int)Node.NotificationReady); provider.ProviderState.IsInitialized.ShouldBeTrue(); - provider._Notification((int)Node.NotificationExitTree); - + obj._Notification((int)Node.NotificationExitTree); provider.ProviderState.IsInitialized.ShouldBeFalse(); } [Test] public void ResolvesDependencyWhenProviderIsAlreadyInitialized() { var value = "Hello, world!"; - var provider = new StringProvider() { Value = value }; + var obj = new StringProvider() { Value = value }; + var provider = obj as IBaseProvider; var dependent = new StringDependent(); - provider.AddChild(dependent); + obj.AddChild(dependent); ((IProvide)provider).Value().ShouldBe(value); - provider._Notification((int)Node.NotificationReady); + obj._Notification((int)Node.NotificationReady); provider.ProviderState.IsInitialized.ShouldBeTrue(); - provider.OnProvidedCalled.ShouldBeTrue(); + obj.OnProvidedCalled.ShouldBeTrue(); dependent._Notification((int)Node.NotificationReady); dependent.OnResolvedCalled.ShouldBeTrue(); dependent.ResolvedValue.ShouldBe(value); - provider.RemoveChild(dependent); + obj.RemoveChild(dependent); dependent.QueueFree(); - provider.QueueFree(); + obj.QueueFree(); } [Test] public void ResolvesDependencyAfterProviderIsResolved() { var value = "Hello, world!"; - var provider = new StringProvider() { Value = value }; + var obj = new StringProvider() { Value = value }; + var provider = obj as IBaseProvider; var dependent = new StringDependent(); - provider.AddChild(dependent); + obj.AddChild(dependent); ((IProvide)provider).Value().ShouldBe(value); dependent._Notification((int)Node.NotificationReady); - provider._Notification((int)Node.NotificationReady); + obj._Notification((int)Node.NotificationReady); provider.ProviderState.IsInitialized.ShouldBeTrue(); - provider.OnProvidedCalled.ShouldBeTrue(); + obj.OnProvidedCalled.ShouldBeTrue(); dependent.OnResolvedCalled.ShouldBeTrue(); dependent.ResolvedValue.ShouldBe(value); - dependent.DependentState.Pending.ShouldBeEmpty(); + ((IDependent)dependent).DependentState.Pending.ShouldBeEmpty(); - provider.RemoveChild(dependent); + obj.RemoveChild(dependent); dependent.QueueFree(); - provider.QueueFree(); + obj.QueueFree(); } [Test] public void FindsDependenciesAcrossAncestors() { var value = "Hello, world!"; - var providerA = new StringProvider() { Value = value }; - var providerB = new IntProvider() { Value = 10 }; - var dependent = new StringDependent(); - var onResolvedCalled = false; - void onResolved() => - onResolvedCalled = true; + var objA = new StringProvider() { Value = value }; + var providerA = objA as IBaseProvider; + var objB = new IntProvider() { Value = 10 }; + var providerB = objB as IBaseProvider; + var depObj = new StringDependent(); + var dependent = depObj as IDependent; - dependent.OnDependenciesResolved += onResolved; + objA.AddChild(objB); + objA.AddChild(depObj); - providerA.AddChild(providerB); - providerB.AddChild(dependent); - - dependent._Notification((int)Node.NotificationReady); + depObj._Notification((int)Node.NotificationReady); - providerA._Notification((int)Node.NotificationReady); + objA._Notification((int)Node.NotificationReady); providerA.ProviderState.IsInitialized.ShouldBeTrue(); - providerA.OnProvidedCalled.ShouldBeTrue(); + objA.OnProvidedCalled.ShouldBeTrue(); - onResolvedCalled.ShouldBeTrue(); - - providerB._Notification((int)Node.NotificationReady); + objB._Notification((int)Node.NotificationReady); providerB.ProviderState.IsInitialized.ShouldBeTrue(); - providerB.OnProvidedCalled.ShouldBeTrue(); + objB.OnProvidedCalled.ShouldBeTrue(); - dependent.OnResolvedCalled.ShouldBeTrue(); - dependent.ResolvedValue.ShouldBe(value); + depObj.OnResolvedCalled.ShouldBeTrue(); + depObj.ResolvedValue.ShouldBe(value); dependent.DependentState.Pending.ShouldBeEmpty(); - providerA.RemoveChild(providerB); - providerB.RemoveChild(dependent); - dependent.QueueFree(); - providerB.QueueFree(); - providerA.QueueFree(); + objA.RemoveChild(objB); + objB.RemoveChild(depObj); + depObj.QueueFree(); + objB.QueueFree(); + objA.QueueFree(); } [Test] @@ -148,36 +143,38 @@ public void UsesFallbackValueWhenNoProviderFound() { [Test] public void ThrowsOnDependencyTableThatWasTamperedWith() { var fallback = "Hello, world!"; - var dependent = new StringDependentFallback { + var depObj = new StringDependentFallback { FallbackValue = fallback }; + var dependent = depObj as IDependent; - dependent._Notification((int)Node.NotificationReady); + depObj._Notification((int)Node.NotificationReady); dependent.DependentState.Dependencies[typeof(string)] = new BadProvider(); Should.Throw( - () => dependent.MyDependency.ShouldBe(fallback) + () => depObj.MyDependency.ShouldBe(fallback) ); } [Test] public void DependentCancelsPendingIfRemovedFromTree() { var provider = new StringProvider(); - var dependent = new StringDependent(); + var depObj = new StringDependent(); + var dependent = depObj as IDependent; - provider.AddChild(dependent); + provider.AddChild(depObj); - dependent._Notification((int)Node.NotificationReady); + depObj._Notification((int)Node.NotificationReady); dependent.DependentState.Pending.ShouldNotBeEmpty(); - dependent._Notification((int)Node.NotificationExitTree); + depObj._Notification((int)Node.NotificationExitTree); dependent.DependentState.Pending.ShouldBeEmpty(); - provider.RemoveChild(dependent); - dependent.QueueFree(); + provider.RemoveChild(depObj); + depObj.QueueFree(); provider.QueueFree(); } @@ -224,7 +221,7 @@ public void FakesDependency() { TestScene.RemoveChild(dependent); } - public class BadProvider : IProvider { + public class BadProvider : IBaseProvider { public ProviderState ProviderState { get; } public BadProvider() { diff --git a/Chickensoft.AutoInject.Tests/test/src/SuperNodeTest.cs b/Chickensoft.AutoInject.Tests/test/src/SuperNodeTest.cs new file mode 100644 index 0000000..02d54e5 --- /dev/null +++ b/Chickensoft.AutoInject.Tests/test/src/SuperNodeTest.cs @@ -0,0 +1,2 @@ +namespace Chickensoft.GodotGame; + diff --git a/Chickensoft.AutoInject/Chickensoft.AutoInject.csproj b/Chickensoft.AutoInject/Chickensoft.AutoInject.csproj index c8793eb..262e0e2 100644 --- a/Chickensoft.AutoInject/Chickensoft.AutoInject.csproj +++ b/Chickensoft.AutoInject/Chickensoft.AutoInject.csproj @@ -6,7 +6,7 @@ true preview enable - ../nupkg + ./nupkg true false @@ -20,8 +20,8 @@ true AutoInject - 1.6.0 - Node-based dependency for C# Godot scripts โ€” without reflection! + 0.0.0-devbuild + Node-based dependency injection for C# Godot scripts โ€” without reflection! ยฉ 2023 Chickensoft Chickensoft Chickensoft @@ -29,13 +29,13 @@ Chickensoft.AutoInject Chickensoft.AutoInject release. icon.png - + dependency injection; di; godot; chickensoft; nodes README.md LICENSE - + https://github.com/chickensoft-games/AutoInject git - + https://github.com/chickensoft-games/AutoInject
@@ -45,8 +45,6 @@ - - diff --git a/README.md b/README.md index 02f30b6..6f238d5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Chickensoft Badge][chickensoft-badge]][chickensoft-website] [![Discord][discord-badge]][discord] [![Read the docs][read-the-docs-badge]][docs] ![line coverage][line-coverage] ![branch coverage][branch-coverage] -Node-based dependency injection for C# Godot scripts at build-time. +Node-based dependency injection for C# Godot scripts at build-time, including utilities for automatic node-binding, additional lifecycle hooks, and .net-inspired notification callbacks. --- @@ -31,47 +31,97 @@ Providing nodes "top-down" over sections of the game's scene tree has a few adva - โœ… Dependencies are resolved when the node enters the scene tree, allowing for O(1) access afterwards. Exiting and re-entering the scene tree triggers the dependency resolution process again. - โœ… Scripts can be both dependents and providers. +## ๐Ÿ“ผ About Mixins + +The [Introspection] generator that AutoInject uses allows you to add [mixins] to an existing C# class. Mixins are similar to interfaces, but they allow a node to gain additional instance state, as well as allow the node to know which mixins are applied to it and invoke mixin handler methods โ€”ย all without reflection. + +In addition, AutoInject provides a few extra utilities to make working with node scripts even easier: + +- ๐ŸŽฎ `IAutoOn`: allow node scripts to implement .NET-style handler methods for Godot notifications: i.e., `OnReady`, `OnProcess`, etc. +- ๐Ÿชข `IAutoConnect`: automatically bind properties marked with `[Node]` to a node in the scene tree โ€” also provides access to nodes via their interfaces using [GodotNodeInterfaces]. +- ๐Ÿ›  `IAutoInit`: adds an additional lifecycle method that is called before `_Ready` if (and only if) the node's `IsTesting` property is set to false. The additional lifecycle method for production code enables you to more easily unit test code by separating initialization logic from the engine lifecycle. +- ๐ŸŽ `IProvider`: a node that provides one or more dependencies to its descendants. Providers must implement `IProvide` for each type of value they provide. +- ๐Ÿ”— `IDependent`: a node that depends on one or more dependencies from its ancestors. Dependent nodes must mark their dependencies with the `[Dependency]` attribute and call `this.DependOn()` to retrieve the value. +- ๐Ÿค `IAutoNode`: a mixin that applies all of the above mixins to a node script at once. + +Want all the functionality that AutoInject provides? Simply add this to your Godot node: + +```csharp +using Chickensoft.AutoInject; +using Chickensoft.Introspection; +using Godot; + +// Apply all of the AutoInject mixins at once: +[Meta(typeof(IAutoNode))] +public partial class MyNode : Node { } +``` + +Alternatively, you can use just the mixins you need from this project. + +```csharp +[Meta( + typeof(IAutoOn), + typeof(IAutoConnect), + typeof(IAutoInit), + typeof(IProvider), + typeof(IDependent) +)] +public partial class MyNode : Node { } +``` + +> [!IMPORTANT] +> For the mixins to work, you must override `_Notification` in your node script and call `this.Notify(what)` from it. This is necessary for the mixins to know when to invoke their handler methods. Unfortunately, there is no way around this since Godot must see the `_Notification` method in your script to generate handlers for it. +> +> ```csharp +> public override void _Notification(int what) => this.Notify(what); +> ``` + ## ๐Ÿ“ฆ Installation -AutoInject is a source-only package that uses the [SuperNodes] source generator to generate the necessary dependency injection code at build-time. You'll need to include SuperNodes, the SuperNodes runtime types, and AutoInject in your project. All of the packages are extremely lightweight. +AutoInject is a source-only package that uses the [Introspection] source generator. AutoInject provides two mixins: `IDependent` and `IProvider` that must be applied with the Introspection generator's `[Meta]`. + +You'll need to include `Chickensoft.Introspection`, `Chickensoft.Introspection.Generator`, and `Chickensoft.AutoInject` in your project. All of the packages are extremely lightweight. -Simply add the following to your project's `.csproj` file. Be sure to check the latest versions for each package on [Nuget](https://www.nuget.org/packages?q=Chickensoft). +Simply add the following to your project's `.csproj` file. Be sure to specify the appropriate versions for each package by checking on [Nuget](https://www.nuget.org/packages?q=Chickensoft). ```xml - - - + + + ``` -## ๐Ÿ” Providers +> [!TIP] +> Want to see AutoInject in action? Check out the Chickensoft [Game Demo]. -To provide values to descendant nodes, add the `Provider` [PowerUp] to your node script and implement `IProvide` for each value you'd like to make available. +## ๐ŸŽ Providers -Once providers have initialized the values they provide, they must call the `Provide` method to inform AutoInject that their provided values are now available. +To provide values to descendant nodes, add the `IProvider` mixin to your node script and implement `IProvide` for each value you'd like to make available. -The example below shows a node script that provides a `string` value to its descendants. +Once providers have initialized the values they provide, they must call the `this.Provide()` extension method to inform AutoInject that the provided values are now available. + +The example below shows a node script that provides a `string` value to its descendants. Values are always provided by their type. ```csharp namespace MyGameProject; using Chickensoft.AutoInject; +using Chickensoft.Introspection; using Godot; -using SuperNodes.Types; -[SuperNode(typeof(Provider))] +[Meta(typeof(IAutoNode))] public partial class MyProvider : Node, IProvide { - public override partial void _Notification(int what); + public override void _Notification(int what) => this.Notify(what); string IProvide.Value() => "Value" - // Call the Provide() method once your dependencies have been initialized. - public void OnReady() => Provide(); + // Call the this.Provide() method once your dependencies have been initialized. + public void OnReady() => this.Provide(); public void OnProvided() { // You can optionally implement this method. It gets called once you call - // Provide() to inform AutoInject that the provided values are now + // this.Provide() to inform AutoInject that the provided values are now // available. } } @@ -79,22 +129,22 @@ public partial class MyProvider : Node, IProvide { ## ๐Ÿฃ Dependents -To use a provided value in a descendant node somewhere, add the `Dependent` PowerUp to your descendent node script and mark each dependency with the `[Dependency]` attribute. SuperNodes will automatically tell AutoInject when your node is ready and begin the dependency resolution process. +To use a provided value in a descendant node somewhere, add the `IDependent` mixin to your descendent node script and mark each dependency with the `[Dependency]` attribute. The notification method overrideย is used to automatically tell the mixins when your node is ready and begin the dependency resolution process. -Once all of the dependencies in your dependent node are resolved, the `OnResolved` method of your dependent node will be called (if overridden). +Once all of the dependencies in your dependent node are resolved, the `OnResolved()` method of your dependent node will be called (if overridden). ```csharp namespace MyGameProject; +using Chickensoft.Introspection; using Godot; -using SuperNodes.Types; -[SuperNode(typeof(Dependent))] +[Meta(typeof(IAutoNode))] public partial class StringDependent : Node { - public override partial void _Notification(int what); + public override void _Notification(int what) => this.Notify(what); [Dependency] - public string MyDependency => DependOn(); + public string MyDependency => this.DependOn(); public void OnResolved() { // All of my dependencies are now available! Do whatever you want with @@ -103,7 +153,7 @@ public partial class StringDependent : Node { } ``` -The `OnResolved` method will be called after `_Ready/OnReady`, but before the first frame if (and only if) all the providers it depends on call `Provide()` before the first frame. +The `OnResolved` method will be called after `_Ready/OnReady`, but before the first frame if (and only if) all the providers it depends on call `this.Provide()` before the first frame. Essentially, `OnResolved` is called when the slowest provider has finished providing dependencies. For the best experience, do not wait until processing occurs to call `Provide` from your providers. @@ -123,12 +173,12 @@ For best results, keep dependency trees simple and free from asynchronous initia Instead of subscribing to a parent node's events, consider subscribing to events emitted by the dependency values themselves. ```csharp -[SuperNode(typeof(Dependent))] +[Meta(typeof(IAutoNode))] public partial class MyDependent : Node { - public override partial void _Notification(int what); + public override void _Notification(int what) => this.Notify(what); [Dependency] - public MyValue Value => DependOn(); + public MyValue Value => this.DependOn(); public void OnResolved() { // Setup subscriptions once dependencies are valid. @@ -152,7 +202,7 @@ You can provide fallback values to use when a provider can't be found. This can ```csharp [Dependency] -public string MyDependency => DependOn(() => "fallback_value"); +public string MyDependency => this.DependOn(() => "fallback_value"); ``` ### Faking Dependencies @@ -179,22 +229,21 @@ Sometimes, when testing, you may wish to "fake" the value of a dependency. Faked } ``` -## How AutoInject Works +## โ“ How AutoInject Works AutoInject uses a simple, specific algorithm to resolve dependencies. -- When the Dependent PowerUp is added to a SuperNode, the SuperNodes generator will copy the code from the Dependent PowerUp into the node it was applied to. -- A node script with the Dependent PowerUp observes its lifecycle. When it notices the `Node.NotificationReady` signal, it will begin the dependency resolution process without you having to write any code in your node script. +- When the Dependent mixin is added to an introspective node, the Introspection generator will generate metadata about the type which allows AutoInject to determine what properties the type has, as well as see their attributes. +- A node script with the Dependent mixin observes its lifecycle. When it notices the `Node.NotificationReady` signal, it will begin the dependency resolution process without you having to write any code in your node script. - The dependency process works as follows: - - All properties of the node script are inspected using SuperNode's static reflection table generation. This allows the script to introspect itself without having to resort to C#'s runtime reflection calls. Properties with the `[Dependency]` attribute are collected into the set of required dependencies. + - All properties of the node script are inspected using the metadata generated by the Introspection generator. This allows the script to introspect itself without having to resort to C#'s runtime reflection calls. Properties with the `[Dependency]` attribute are collected into the set of required dependencies. - All required dependencies are added to the remaining dependencies set. - The dependent node begins searching its ancestors, beginning with itself, then its parent, and so on up the tree. - If the current search node implements `IProvide` for any of the remaining dependencies, the individual resolution process begins. - - The dependency stores the provider in a dictionary property on your node script which was copied over from the Dependent PowerUp. + - The dependency stores the provider in a dictionary property in the node script. - The dependency is added to the set of found dependencies. - If the provider search node has not already provided its dependencies, the dependent subscribes to the `OnInitialized` event of the provider. - Pending dependency provider callbacks track a counter for the dependent node that also remove that provider's dependency from the remaining dependencies set and initiate the OnResolved process if nothing is left. - - Subscribing to an event on the provider node and tracking whether or not the provider is initialized is made possible by SuperNodes, which copies the code from the Provider PowerUp into the provider's node script. - After checking all the remaining dependencies, the set of found dependencies are removed from the remaining dependencies set and the found dependencies set is cleared for the next search node. - If all the dependencies are found, the dependent initiates the OnResolved process and finishes the search. - Otherwise, the search node's parent becomes the next parent to search. @@ -202,9 +251,9 @@ AutoInject uses a simple, specific algorithm to resolve dependencies. There are some natural consequences to this algorithm, such as `OnResolved` not being invoked on a dependent until all providers have provided a value. This is intentional โ€”ย providers are expected to synchronously initialize their provided values after `_Ready` has been invoked on them. -AutoInject primarily exists to to locate providers from dependents and subscribe to the providers just long enough for their own `_Ready` method to be invoked โ€” waiting longer than that to call `Provide` from a provider can introduce dependency resolution deadlock or other undesirable circumstances that are indicative of anti-patterns. +AutoInject primarily exists to to locate providers from dependents and subscribe to the providers just long enough for their own `_Ready` method to be invoked โ€” waiting longer than that to call `Provide` from a provider can introduce dependency resolution deadlock or other undesirable circumstances that are indicative of an anti-pattern. -By calling `Provide()` from `_Ready` in provider nodes, you ensure that the order of execution unfolds as follows, synchronously: +By calling `this.Provide()` from `_Ready` in provider nodes, you ensure that the order of execution unfolds as follows, synchronously: 1. Dependent node `_Ready` (descendant of the provider, deepest nodes ready-up first). 2. Provider node `_Ready` (which calls `Provide`). @@ -213,12 +262,195 @@ By calling `Provide()` from `_Ready` in provider nodes, you ensure that the orde 5. Frame 2 `_Process` 6. Etc. -By following the `Provide()` on `_Ready` convention, you guarantee all dependent nodes receive an `OnResolved` callback before the first process invocation occurs, guaranteeing that nodes are setup before frame processing begins โœจ. +By following the `this.Provide()` on `_Ready` convention, you guarantee all dependent nodes receive an `OnResolved` callback before the first process invocation occurs, guaranteeing that nodes are setup before frame processing begins โœจ. -> If your provider is also a dependent, you can call `Provide` from `OnResolved` to allow it to provide dependencies to its subtree, which still guarantees that dependency resolution happens before frame processing begins. Just don't wait until processing has started to call `Provide` from your providers! +> [!TIP] +> If your provider is also a dependent, you can call `this.Provide()` from `OnResolved()` to allow it to provide dependencies to its subtree, which still guarantees that dependency resolution happens before the next frame is processed. > > In general, dependents should have access to their dependencies **before** frame processing callbacks are invoked on them. +## ๐Ÿชข IAutoConnect + +The `IAutoConnect` mixin automatically connects properties in your script to a declared node path or unique node name in the scene tree whenever the scene is instantiated, without reflection. It can also be used to connect nodes as interfaces. + +Simply apply the `[Node]` attribute to any field or property in your script that you want to automatically connect to a node in your scene. + +If you don't specify a node path in the `[Node]` attribute, the name of the field or property will be converted to a [unique node identifier][unique-nodes] name in PascalCase. For example, the field name below `_my_unique_node` is converted to the unique node path name `%MyUniqueNode` by converting the property name to PascalCase and prefixing the percent sign indicator. Likewise, the property name `MyUniqueNode` is converted to `%MyUniqueNode`, which isn't much of a conversion since the property name is already in PascalCase. + +For best results, use PascalCase for your node names in the scene tree (which Godot tends to do by default, anyways). + +In the example below, we're using [GodotNodeInterfaces] to reference nodes as their interfaces instead of their concrete Godot types. This allows us to write a unit test where we fake the nodes in the scene tree by substituting mock nodes, allowing us to test a single node script at a time without polluting our test coverage. + +```csharp +using Chickensoft.GodotNodeInterfaces; +using Chickensoft.AutoInject; +using Chickensoft.Introspection; +using Godot; + +[Meta(typeof(IAutoConnect))] +public partial class MyNode : Node2D { + public override void _Notification(int what) => this.Notify(what); + + [Node("Path/To/SomeNode")] + public INode2D SomeNode { get; set; } = default!; + + [Node] // Connects to "%MyUniqueNode" since no path was specified. + public INode2D MyUniqueNode { get; set; } = default!; + + [Node("%OtherUniqueName")] + public INode2D DifferentName { get; set; } = default!; +} +``` + +> [!TIP] +> `IAutoConnect` can only bind properties to nodes, not fields. + +### ๐Ÿงช Testing + +AutoConnect integrates seamlessly with [GodotNodeInterfaces] to facilitate unit testing Godot node scripts by allowing you to fake the node tree during testing. + +We can easily write a test for the example above by substituting mock nodes: + +```csharp +namespace Chickensoft.AutoInject.Tests; + +using System.Threading.Tasks; +using Chickensoft.GodotNodeInterfaces; +using Chickensoft.GoDotTest; +using Chickensoft.AutoInject.Tests.Fixtures; +using Godot; +using GodotTestDriver; +using Moq; +using Shouldly; + +#pragma warning disable CA1001 +public class MyNodeTest(Node testScene) : TestClass(testScene) { + private Fixture _fixture = default!; + private MyNode _scene = default!; + + private Mock _someNode = default!; + private Mock _myUniqueNode = default!; + private Mock _otherUniqueNode = default!; + + [Setup] + public async Task Setup() { + _fixture = new(TestScene.GetTree()); + + _someNode = new(); + _myUniqueNode = new(); + _otherUniqueNode = new(); + + _scene = new MyNode(); + _scene.FakeNodeTree(new() { + ["Path/To/SomeNode"] = _someNode.Object, + ["%MyUniqueNode"] = _myUniqueNode.Object, + ["%OtherUniqueName"] = _otherUniqueNode.Object, + }); + + await _fixture.AddToRoot(_scene); + } + + [Cleanup] + public async Task Cleanup() => await _fixture.Cleanup(); + + [Test] + public void UsesFakeNodeTree() { + // Making a new instance of a node without instantiating a scene doesn't + // trigger NotificationSceneInstantiated, so if we want to make sure our + // AutoNodes get hooked up and use the FakeNodeTree, we need to do it manually. + _scene._Notification((int)Node.NotificationSceneInstantiated); + + _scene.SomeNode.ShouldBe(_someNode.Object); + _scene.MyUniqueNode.ShouldBe(_myUniqueNode.Object); + _scene.DifferentName.ShouldBe(_otherUniqueNode.Object); + _scene._my_unique_node.ShouldBe(_myUniqueNode.Object); + } +} +``` + +## ๐Ÿ›  IAutoInit + +The `IAutoInit` will conditionally call the `void Initialize()` method your node script has from `_Ready` if (and only if) the `IsTesting` field that it adds to your node is false. Conditionally calling the `Initialize()` method allows you to split your node's late member initialization into two-phases, allowing nodes to be more easily unit tested. + +When writing tests for your node, simply initialize any members that would need to be mocked in a test in your `Initialize()` method. + +```csharp +using Chickensoft.AutoInject; +using Chickensoft.Introspection; +using Godot; + +[Meta(typeof(IAutoInit), typeof(IAutoOn))] +public partial class MyNode : Node2D { + public override void _Notification(int what) => this.Notify(what); + + public IMyObject Obj { get; set; } = default!; + + public void Initialize() { + // Initialize is called from the Ready notification if our IsTesting + // property (added by IAutoInit) is false. + + // Initialize values which would be mocked in a unit testing method. + Obj = new MyObject(); + } + + public void OnReady() { + // Guaranteed to be called after Initialize() + + // Use object we setup in Initialize() method (or, if we're running in a + // unit test, this will use whatever the test supplied) + Obj.DoSomething(); + } +} +``` + +Likewise, when creating a node during a unit test, you can set the `IsTesting` property to `true` to prevent the `Initialize()` method from being called. + +```csharp +var myNode = new MyNode() { + Obj = mock.Object +}; + +(myNode as IAutoInit).IsTesting = true; +``` + +For example tests, please see the [Game Demo] project. + +## ๐Ÿ”‹ IAutoOn + +The `IAutoOn` mixin allows node scripts to implement .NET-style handler methods for Godot notifications, prefixed with `On`. + +```csharp +using Chickensoft.AutoInject; +using Chickensoft.Introspection; +using Godot; + +[Meta(typeof(IAutoOn))] +public partial class MyNode : Node2D { + public override void _Notification(int what) => this.Notify(what); + + public void OnReady() { + // Called when the node enters the scene tree. + } + + public void OnProcess(double delta) { + // Called every frame. + } +} +``` + +## ๐Ÿฆพ IAutoNode + +The `IAutoNode` mixin simply applies all of the mixins provided by AutoInject to a node script at once. + +```csharp +using Chickensoft.AutoInject; +using Chickensoft.Introspection; +using Godot; + +[Meta(typeof(IAutoNode))] +public partial class MyNode : Node { } +``` + --- ๐Ÿฃ Package generated from a ๐Ÿค Chickensoft Template โ€” @@ -228,11 +460,14 @@ By following the `Provide()` on `_Ready` convention, you guarantee all dependent [discord-badge]: https://raw.githubusercontent.com/chickensoft-games/chickensoft_site/main/static/img/badges/discord_badge.svg [discord]: https://discord.gg/gSjaPgMmYW [read-the-docs-badge]: https://raw.githubusercontent.com/chickensoft-games/chickensoft_site/main/static/img/badges/read_the_docs_badge.svg -[docs]: https://chickensoft.games/docsickensoft%20Discord-%237289DA.svg?style=flat&logo=discord&logoColor=white +[docs]: https://chickensoft.games/docs [line-coverage]: Chickensoft.AutoInject.Tests/badges/line_coverage.svg [branch-coverage]: Chickensoft.AutoInject.Tests/badges/branch_coverage.svg [provider]: https://github.com/rrousselGit/provider [tree-order]: https://kidscancode.org/godot_recipes/4.x/basics/tree_ready_order/ -[SuperNodes]: https://github.com/chickensoft-games/SuperNodes -[PowerUp]: https://chickensoft.games/docs/super_nodes/#-powerups +[Introspection]: https://github.com/chickensoft-games/Introspection +[mixins]: https://github.com/chickensoft-games/Introspection?tab=readme-ov-file#%EF%B8%8F-mixins +[GodotNodeInterfaces]: https://github.com/chickensoft-games/GodotNodeInterfaces +[Game Demo]: https://github.com/chickensoft-games/GameDemo +[unique-nodes]: https://docs.godotengine.org/en/stable/tutorials/scripting/scene_unique_nodes.html diff --git a/cspell.json b/cspell.json index d4dde28..62e9d26 100644 --- a/cspell.json +++ b/cspell.json @@ -29,6 +29,7 @@ "issuecomment", "lcov", "linecoverage", + "Metatype", "methodcoverage", "missingall", "msbuild", @@ -42,6 +43,8 @@ "OPTOUT", "paramref", "pascalcase", + "Postinitialize", + "Predelete", "renovatebot", "reportgenerator", "reporttypes", @@ -54,6 +57,7 @@ "typeparam", "typeparamref", "ulong", + "Unparented", "Xunit" ] } diff --git a/manual_build.sh b/manual_build.sh new file mode 100755 index 0000000..c4b1b2b --- /dev/null +++ b/manual_build.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# Copy source files from Chickensoft.AutoInject.Tests/src/**/*.cs +# to Chickensoft.AutoInject/src/**/*.cs +# +# Because source-only packages are hard to develop and test, we +# actually keep the source that goes in the source-only package inside +# the test project to make it easier to develop and test. +# +# we can always copy it right before publishing the package. + +mkdir -p Chickensoft.AutoInject/src +cp -v -r Chickensoft.AutoInject.Tests/src/* Chickensoft.AutoInject/src/ +# Define the multiline prefix and suffix +PREFIX="#pragma warning disable +#nullable enable +" +SUFFIX=" +#nullable restore +#pragma warning restore" + +# Function to add prefix and suffix to a file +add_prefix_suffix() { + local file="$1" + # Create a temporary file + tmp_file=$(mktemp) + + # Add prefix, content of the file, and suffix to the temporary file + { + echo "$PREFIX" + cat "$file" + echo "$SUFFIX" + } > "$tmp_file" + + # Move the temporary file to the original file + mv "$tmp_file" "$file" +} + +# Export the function and variables so they can be used by find +export -f add_prefix_suffix +export PREFIX +export SUFFIX + +# Find all files and apply the function +find Chickensoft.AutoInject/src -type f -name "*.cs" -exec bash -c 'add_prefix_suffix "$0"' {} \; + +cd Chickensoft.AutoInject +dotnet build -c Release + +# Delete everything copied into Chickensoft.AutoInject/src +rm -r src + +# Recreate folder and .gitkeep file +mkdir src +touch src/.gitkeep diff --git a/nuget.config b/nuget.config index e54ff18..06f7b0d 100644 --- a/nuget.config +++ b/nuget.config @@ -6,6 +6,6 @@ - + diff --git a/nupkg/.gitkeep b/nupkg/.gitkeep deleted file mode 100644 index e69de29..0000000 From ea5ccb3248bdc4d159b63701e95d67f1247b915a Mon Sep 17 00:00:00 2001 From: Joanna May Date: Sun, 9 Jun 2024 19:15:54 -0500 Subject: [PATCH 2/4] fix: spelling --- cspell.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cspell.json b/cspell.json index 62e9d26..4f5e4dd 100644 --- a/cspell.json +++ b/cspell.json @@ -13,6 +13,9 @@ "Chickensoft.AutoInject/nupkg/**/*.*" ], "words": [ + "devbuild", + "mktemp", + "skipautoprops", "assemblyfilters", "automerge", "branchcoverage", From 6f4a4a174f8d8781ecab39b451d4705d7eed82c4 Mon Sep 17 00:00:00 2001 From: Joanna May Date: Sun, 9 Jun 2024 19:22:18 -0500 Subject: [PATCH 3/4] fix: try keeping directory --- .gitignore | 6 ++---- Chickensoft.AutoInject/nupkg/.gitkeep | 0 2 files changed, 2 insertions(+), 4 deletions(-) create mode 100644 Chickensoft.AutoInject/nupkg/.gitkeep diff --git a/.gitignore b/.gitignore index 7d4d352..a247cdb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -Chickensoft.AutoInject/nupkg/ - Chickensoft.AutoInject.Tests/coverage/* !Chickensoft.AutoInject.Tests/coverage/.gdignore @@ -9,5 +7,5 @@ obj/ .generated/ .vs/ .DS_Store -nupkg/* -!nupkg/.gitkeep +# Chickensoft.AutoInject/nupkg/ +!Chickensoft.AutoInject/nupkg/.gitkeep diff --git a/Chickensoft.AutoInject/nupkg/.gitkeep b/Chickensoft.AutoInject/nupkg/.gitkeep new file mode 100644 index 0000000..e69de29 From e098d4203d63d7450fc6130c7da6636ffff13d25 Mon Sep 17 00:00:00 2001 From: Joanna May Date: Sun, 9 Jun 2024 19:22:31 -0500 Subject: [PATCH 4/4] fix: update ignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a247cdb..358f325 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,5 @@ obj/ .generated/ .vs/ .DS_Store -# Chickensoft.AutoInject/nupkg/ +Chickensoft.AutoInject/nupkg/ !Chickensoft.AutoInject/nupkg/.gitkeep