Merge branch 'develop' into stable

This commit is contained in:
Jesse Plamondon-Willard 2022-05-01 18:16:09 -04:00
commit c8ad50dad1
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
443 changed files with 9988 additions and 4504 deletions

View File

@ -1,11 +1,15 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<!--set general build properties -->
<Version>3.13.4</Version>
<Version>3.14.0</Version>
<Product>SMAPI</Product>
<LangVersion>latest</LangVersion>
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>
<!--enable nullable annotations, except in .NET Standard 2.0 where they aren't supported-->
<Nullable Condition="'$(TargetFramework)' != 'netstandard2.0'">enable</Nullable>
<NoWarn Condition="'$(TargetFramework)' == 'netstandard2.0'">$(NoWarn);CS8632</NoWarn>
<!--set platform-->
<DefineConstants Condition="$(OS) == 'Windows_NT'">$(DefineConstants);SMAPI_FOR_WINDOWS</DefineConstants>
<CopyToGameFolder>true</CopyToGameFolder>
@ -53,6 +57,7 @@
<Copy SourceFiles="$(TargetDir)\SMAPI.metadata.json" DestinationFiles="$(GamePath)\smapi-internal\metadata.json" />
<Copy SourceFiles="$(TargetDir)\Newtonsoft.Json.dll" DestinationFolder="$(GamePath)\smapi-internal" />
<Copy SourceFiles="$(TargetDir)\TMXTile.dll" DestinationFolder="$(GamePath)\smapi-internal" />
<Copy SourceFiles="$(TargetDir)\Pintail.dll" DestinationFolder="$(GamePath)\smapi-internal" />
<Copy SourceFiles="@(TranslationFiles)" DestinationFolder="$(GamePath)\smapi-internal\i18n" />
<!-- Harmony + dependencies -->

19
build/unix/prepare-install-package.sh Normal file → Executable file
View File

@ -9,7 +9,7 @@
##########
## Constants
## Fetch values
##########
# paths
gamePath="/home/pathoschild/Stardew Valley"
@ -21,6 +21,13 @@ folders=("linux" "macOS" "windows")
declare -A runtimes=(["linux"]="linux-x64" ["macOS"]="osx-x64" ["windows"]="win-x64")
declare -A msBuildPlatformNames=(["linux"]="Unix" ["macOS"]="OSX" ["windows"]="Windows_NT")
# version number
version="$1"
if [ $# -eq 0 ]; then
echo "SMAPI release version (like '4.0.0'):"
read version
fi
##########
## Move to SMAPI root
@ -42,6 +49,7 @@ echo ""
##########
## Compile files
##########
. ${0%/*}/set-smapi-version.sh "$version"
for folder in ${folders[@]}; do
runtime=${runtimes[$folder]}
msbuildPlatformName=${msBuildPlatformNames[$folder]}
@ -126,7 +134,7 @@ for folder in ${folders[@]}; do
cp -r "$smapiBin/i18n" "$bundlePath/smapi-internal"
# bundle smapi-internal
for name in "0Harmony.dll" "0Harmony.xml" "Mono.Cecil.dll" "Mono.Cecil.Mdb.dll" "Mono.Cecil.Pdb.dll" "MonoMod.Common.dll" "Newtonsoft.Json.dll" "TMXTile.dll" "SMAPI.Toolkit.dll" "SMAPI.Toolkit.pdb" "SMAPI.Toolkit.xml" "SMAPI.Toolkit.CoreInterfaces.dll" "SMAPI.Toolkit.CoreInterfaces.pdb" "SMAPI.Toolkit.CoreInterfaces.xml"; do
for name in "0Harmony.dll" "0Harmony.xml" "Mono.Cecil.dll" "Mono.Cecil.Mdb.dll" "Mono.Cecil.Pdb.dll" "MonoMod.Common.dll" "Newtonsoft.Json.dll" "Pintail.dll" "TMXTile.dll" "SMAPI.Toolkit.dll" "SMAPI.Toolkit.pdb" "SMAPI.Toolkit.xml" "SMAPI.Toolkit.CoreInterfaces.dll" "SMAPI.Toolkit.CoreInterfaces.pdb" "SMAPI.Toolkit.CoreInterfaces.xml"; do
cp "$smapiBin/$name" "$bundlePath/smapi-internal"
done
@ -190,13 +198,6 @@ done
##########
## Create release zips
##########
# get version number
version="$1"
if [ $# -eq 0 ]; then
echo "SMAPI release version (like '4.0.0'):"
read version
fi
# rename folders
mv "$packagePath" "bin/SMAPI $version installer"
mv "$packageDevPath" "bin/SMAPI $version installer for developers"

0
build/unix/set-smapi-version.sh Normal file → Executable file
View File

View File

@ -1,19 +1,19 @@
#
#
# This is the PowerShell equivalent of ../unix/prepare-install-package.sh, *except* that it doesn't
# set Linux permissions, create the install.dat files, or create the final zip. Due to limitations
# in PowerShell, the final changes are handled by the windows/finalize-install-package.sh file in
# WSL.
# set Linux permissions, create the install.dat files, or create the final zip (unless you specify
# --windows-only). Due to limitations in PowerShell, the final changes are handled by the
# windows/finalize-install-package.sh file in WSL.
#
# When making changes, make sure to update ../unix/prepare-install-package.ps1 too.
#
#
. "$PSScriptRoot/lib/in-place-regex.ps1"
. "$PSScriptRoot\lib\in-place-regex.ps1"
##########
## Constants
## Fetch values
##########
# paths
$gamePath = "C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley"
@ -25,6 +25,23 @@ $folders = "linux", "macOS", "windows"
$runtimes = @{ linux = "linux-x64"; macOS = "osx-x64"; windows = "win-x64" }
$msBuildPlatformNames = @{ linux = "Unix"; macOS = "OSX"; windows = "Windows_NT" }
# version number
$version = $args[0]
if (!$version) {
$version = Read-Host "SMAPI release version (like '4.0.0')"
}
# Windows-only build
$windowsOnly = $false
foreach ($arg in $args) {
if ($arg -eq "--windows-only") {
$windowsOnly = $true
$folders = "windows"
$runtimes = @{ windows = "win-x64" }
$msBuildPlatformNames = @{ windows = "Windows_NT" }
}
}
##########
## Move to SMAPI root
@ -48,7 +65,8 @@ echo ""
##########
## Compile files
##########
ForEach ($folder in $folders) {
. "$PSScriptRoot/set-smapi-version.ps1" "$version"
foreach ($folder in $folders) {
$runtime = $runtimes[$folder]
$msbuildPlatformName = $msBuildPlatformNames[$folder]
@ -92,6 +110,10 @@ foreach ($folder in $folders) {
# copy base installer files
foreach ($name in @("install on Linux.sh", "install on macOS.command", "install on Windows.bat", "README.txt")) {
if ($windowsOnly -and ($name -eq "install on Linux.sh" -or $name -eq "install on macOS.command")) {
continue;
}
cp "$installAssets/$name" "$packagePath"
}
@ -132,7 +154,7 @@ foreach ($folder in $folders) {
cp -Recurse "$smapiBin/i18n" "$bundlePath/smapi-internal"
# bundle smapi-internal
foreach ($name in @("0Harmony.dll", "0Harmony.xml", "Mono.Cecil.dll", "Mono.Cecil.Mdb.dll", "Mono.Cecil.Pdb.dll", "MonoMod.Common.dll", "Newtonsoft.Json.dll", "TMXTile.dll", "SMAPI.Toolkit.dll", "SMAPI.Toolkit.pdb", "SMAPI.Toolkit.xml", "SMAPI.Toolkit.CoreInterfaces.dll", "SMAPI.Toolkit.CoreInterfaces.pdb", "SMAPI.Toolkit.CoreInterfaces.xml")) {
foreach ($name in @("0Harmony.dll", "0Harmony.xml", "Mono.Cecil.dll", "Mono.Cecil.Mdb.dll", "Mono.Cecil.Pdb.dll", "MonoMod.Common.dll", "Newtonsoft.Json.dll", "Pintail.dll", "TMXTile.dll", "SMAPI.Toolkit.dll", "SMAPI.Toolkit.pdb", "SMAPI.Toolkit.xml", "SMAPI.Toolkit.CoreInterfaces.dll", "SMAPI.Toolkit.CoreInterfaces.pdb", "SMAPI.Toolkit.CoreInterfaces.xml")) {
cp "$smapiBin/$name" "$bundlePath/smapi-internal"
}
@ -184,34 +206,32 @@ foreach ($folder in $folders) {
# disable developer mode in main package
In-Place-Regex -Path "$packagePath/internal/$folder/bundle/smapi-internal/config.json" -Search "`"DeveloperMode`": true" -Replace "`"DeveloperMode`": false"
# DISABLED: will be handled by Linux script
# convert bundle folder into final 'install.dat' files
#foreach ($path in @("$packagePath/internal/$folder", "$packageDevPath/internal/$folder"))
#{
# Compress-Archive -Path "$path/bundle/*" -CompressionLevel Optimal -DestinationPath "$path/install.zip"
# mv "$path/install.zip" "$path/install.dat"
# rm -Recurse -Force "$path/bundle"
#}
if ($windowsOnly)
{
foreach ($path in @("$packagePath/internal/$folder", "$packageDevPath/internal/$folder"))
{
Compress-Archive -Path "$path/bundle/*" -CompressionLevel Optimal -DestinationPath "$path/install.zip"
mv "$path/install.zip" "$path/install.dat"
rm -Recurse -Force "$path/bundle"
}
}
}
###########
### Create release zips
###########
# get version number
$version = $args[0]
if (!$version) {
$version = Read-Host "SMAPI release version (like '4.0.0')"
}
# rename folders
mv "$packagePath" "bin/SMAPI $version installer"
mv "$packageDevPath" "bin/SMAPI $version installer for developers"
# DISABLED: will be handled by Linux script
## package files
#Compress-Archive -Path "bin/SMAPI $version installer" -DestinationPath "bin/SMAPI $version installer.zip" -CompressionLevel Optimal
#Compress-Archive -Path "bin/SMAPI $version installer for developers" -DestinationPath "bin/SMAPI $version installer for developers.zip" -CompressionLevel Optimal
# package files
if ($windowsOnly)
{
Compress-Archive -Path "bin/SMAPI $version installer" -DestinationPath "bin/SMAPI $version installer.zip" -CompressionLevel Optimal
Compress-Archive -Path "bin/SMAPI $version installer for developers" -DestinationPath "bin/SMAPI $version installer for developers.zip" -CompressionLevel Optimal
}
echo ""
echo "Done! See docs/technical/smapi.md to create the release zips."

View File

@ -1,6 +1,75 @@
← [README](README.md)
# Release notes
## 3.14.0
Released 01 May 2022 for Stardew Valley 1.5.6 or later. See [release highlights](https://www.patreon.com/posts/65265507).
### For players
This is a big update, but existing mods should all work fine. If the latest version of a mod breaks in SMAPI 3.14, please report it [on the SMAPI mod page](https://www.nexusmods.com/stardewvalley/mods/2400?tab=posts).
* Improvements:
* SMAPI now ignores dot-prefixed files when searching for mod folders (thanks to Nuztalgia!).
* On Linux, SMAPI now fixes many case-sensitive mod path issues automatically.
* On Linux/macOS, added `--use-current-shell` [command-line argument](technical/smapi.md#command-line-arguments) to avoid opening a separate terminal window.
* Improved performance in some cases.
* Improved translations. Thanks to ChulkyBow (updated Ukrainian)!
* Dropped update checks for the unofficial 64-bit patcher (obsolete since SMAPI 3.12.6).
* Fixes:
* Fixed some movie theater textures not translated when loaded through SMAPI (specifically assets with the `_international` suffix).
* Fixed the warning text when a mod causes an asset load conflict with itself.
* Fixed `--no-terminal` [command-line argument](technical/smapi.md#command-line-arguments) on Linux/macOS still opening a terminal window, even if nothing is logged to it (thanks to Ryhon0!).
* Fixed `player_add` console command not handling journal scraps and secret notes correctly.
* Fixed `set_farm_type` console command not updating warps.
* For the web UI:
* Improved log parser UI (thanks to KhloeLeclair!):
* Added pagination for big logs.
* Added search box to filter the log.
* Added option to show/hide content packs in the mod list.
* Added jump links in the sidebar.
* The filter options now stick to the top of the screen when scrolling.
* Rewrote rendering to improve performance.
### For mod authors
This is a big release that includes the new features planned for SMAPI 4.0.0.
For C# mod authors: your mods should still work fine in SMAPI 3.14.0. However you should review the [migration to SMAPI 4.0](https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_4.0) guide and update your mods when possible. Deprecated code will be removed when SMAPI 4.0.0 releases later this year (no sooner than August 2022), and break any mods which haven't updated by that time. You can update affected mods now, there's no need to wait for 4.0.0.
For content pack authors: SMAPI 3.14.0 and 4.0.0 don't affect content packs. They should work fine as long as
the C# mod that loads them is updated.
* Major changes:
* Added [content events](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Events#Content), which will replace `IAssetEditor` and `IAssetLoader` in SMAPI 4.0.0.
_These include new features not supported by the old API like load conflict resolution, edit priority, and content pack labels. They also support new cases like easily detecting when an asset has changed, and avoid data corruption issues in some edge cases._
* Added [nullable reference type annotations](https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_4.0#Nullable_reference_type_annotations) for all APIs.
* Added [`helper.GameContent` and `helper.ModContent`](https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_4.0#Content_loading_API), which will replace `helper.Content` in SMAPI 4.0.0.
* Improved [mod-provided API](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations#Mod-provided_APIs) proxying (thanks to Shockah!).
_This adds support for custom interfaces in return values or input arguments, custom enums if their values match, generic methods, and more. This is an internal change, you don't need to do anything different in your mod code._
* Mod files loaded through SMAPI APIs (including `helper.Content.Load`) are now case-insensitive, even on Linux.
* Enabled deprecation notices for all deprecated APIs. These will only be shown in `TRACE` logs for at least a month after SMAPI 3.14.0 releases.
* Other improvements:
* Added `IAssetDataForImage.ExtendMap` to resize maps in asset editors.
* Added `IContentPack.ModContent` property to manage content pack assets.
* Added `Constants.ContentPath` to get the full path to the game's `Content` folder.
* Added `IAssetName` fields to the info received by `IAssetEditor`, `IAssetLoader`, and content event methods.
_This adds methods for working with asset names, parsed locales, etc._
* Added `helper.Content.ParseAssetName` to get an `IAssetName` for an arbitrary asset key.
* Added [command-line arguments](technical/smapi.md#command-line-arguments) to toggle developer mode (thanks to Tondorian!).
* If an asset is loaded multiple times in the same tick, `IAssetLoader.CanLoad` and `IAssetEditor.CanEdit` are now cached unless invalidated by `helper.Content.InvalidateCache`.
* The `ISemanticVersion` comparison methods (`CompareTo`, `IsBetween`, `IsNewerThan`, and `IsOlderThan`) now allow null values. A null version is always considered older than any non-null version per [best practices](https://docs.microsoft.com/en-us/dotnet/api/system.icomparable-1.compareto#remarks).
* Deprecation notices now show a shorter stack trace in most cases, so it's clearer where the deprecated code is in the mod.
* Fixes:
* Fixed the `SDate` constructor being case-sensitive.
* Fixed support for using locale codes from custom languages in asset names (e.g. `Data/Achievements.eo-EU`).
* Fixed issue where suppressing `[Left|Right]Thumbstick[Down|Left]` keys would suppress the opposite direction instead.
* Fixed null handling in various edge cases.
* For the web UI:
* Updated the JSON validator/schema for Content Patcher 1.25.0.
* Added `data-*` attributes to the log parser page for external tools.
* Fixed JSON validator showing incorrect error for update keys without a subkey.
### For SMAPI contributors
* You no longer need a Nexus API key to launch the `SMAPI.Web` project locally.
## 3.13.4
Released 16 January 2022 for Stardew Valley 1.5.6 or later.
@ -56,7 +125,7 @@ Released 30 November 2021 for Stardew Valley 1.5.5 or later.
* Fixed installer failing on Windows when run from the game folder.
## 3.13.0
Released 30 November 2021 for Stardew Valley 1.5.5 or later.
Released 30 November 2021 for Stardew Valley 1.5.5 or later. See [release highlights](https://www.patreon.com/posts/59348226).
* For players:
* Updated for Stardew Valley 1.5.5.

View File

@ -412,8 +412,12 @@ The NuGet package is generated automatically in `StardewModdingAPI.ModBuildConfi
when you compile it.
## Release notes
## Upcoming release
## 4.0.1
Released 14 April 2022.
* Added detection for Xbox app game folders.
* Fixed "_conflicts between different versions of Microsoft.Win32.Registry_" warnings in recent SMAPI versions.
* Internal refactoring.
## 4.0.0
Released 30 November 2021.

View File

@ -33,23 +33,27 @@ argument | purpose
`--uninstall` | Preselects the uninstall action, skipping the prompt asking what the user wants to do.
`--game-path "path"` | Specifies the full path to the folder containing the Stardew Valley executable, skipping automatic detection and any prompt to choose a path. If the path is not valid, the installer displays an error.
SMAPI itself recognises two arguments **on Windows only**, but these are intended for internal use
SMAPI itself recognises five arguments **on Windows only**, but these are intended for internal use
or testing and may change without warning. On Linux/macOS, see _environment variables_ below.
argument | purpose
-------- | -------
`--no-terminal` | SMAPI won't write anything to the console window. (Messages will still be written to the log file.)
`--developer-mode`<br />`--developer-mode-off` | Enable or disable features intended for mod developers. Currently this only makes `TRACE`-level messages appear in the console.
`--no-terminal` | The SMAPI launcher won't try to open a terminal window, and SMAPI won't log anything to the console. (Messages will still be written to the log file.)
`--use-current-shell` | The SMAPI launcher won't try to open a terminal window, but SMAPI will still log to the console. (Messages will still be written to the log file.)
`--mods-path` | The path to search for mods, if not the standard `Mods` folder. This can be a path relative to the game folder (like `--mods-path "Mods (test)"`) or an absolute path.
### Environment variables
The above SMAPI arguments don't work on Linux/macOS due to the way the game launcher works. You can
set temporary environment variables instead. For example:
The above SMAPI arguments may not work on Linux/macOS due to the way the game launcher works. You
can set temporary environment variables instead. For example:
> SMAPI_MODS_PATH="Mods (multiplayer)" /path/to/StardewValley
environment variable | purpose
-------------------- | -------
`SMAPI_NO_TERMINAL` | Equivalent to `--no-terminal` above.
`SMAPI_DEVELOPER_MODE` | Equivalent to `--developer-mode` and `--developer-mode-off` above. The value must be `true` or `false`.
`SMAPI_MODS_PATH` | Equivalent to `--mods-path` above.
`SMAPI_NO_TERMINAL` | Equivalent to `--no-terminal` above.
`SMAPI_USE_CURRENT_SHELL` | Equivalent to `--use-current-shell` above.
### Compile flags
SMAPI uses a small number of conditional compilation constants, which you can set by editing the
@ -79,7 +83,7 @@ folder before compiling.
## Prepare a release
### On any platform
**⚠ Ideally we'd have one set of instructions for all platforms. The instructions in this section
will produce a fully functional release for all supported platfrms, _except_ that the application
will produce a fully functional release for all supported platforms, _except_ that the application
icon for SMAPI on Windows will disappear due to [.NET runtime bug
3828](https://github.com/dotnet/runtime/issues/3828). Until that's fixed, see the _[on
Windows](#on-windows)_ section below to create a build that retains the icon.**
@ -116,8 +120,10 @@ Windows](#on-windows)_ section below to create a build that retains the icon.**
2. Launch the game through the Steam UI.
### Prepare the release
1. Run `build/unix/set-smapi-version.sh` to set the SMAPI version. Make sure you use a [semantic
version](https://semver.org). Recommended format:
1. Run `build/unix/prepare-install-package.sh VERSION_HERE` to create the release package in the
root `bin` folder.
Make sure you use a [semantic version](https://semver.org). Recommended format:
build type | format | example
:--------- | :----------------------- | :------
@ -125,9 +131,6 @@ Windows](#on-windows)_ section below to create a build that retains the icon.**
prerelease | `<version>-beta.<date>` | `4.0.0-beta.20251230`
release | `<version>` | `4.0.0`
2. Run `build/unix/prepare-install-package.sh` to create the release package in the root `bin`
folder.
### On Windows
#### First-time setup
1. Set up Windows Subsystem for Linux (WSL):
@ -143,8 +146,10 @@ Windows](#on-windows)_ section below to create a build that retains the icon.**
```
### Prepare the release
1. Run `build/windows/set-smapi-version.ps1` in PowerShell to set the SMAPI version. Make sure you
use a [semantic version](https://semver.org). Recommended format:
1. Run `build/windows/prepare-install-package.ps1 VERSION_HERE` in PowerShell to create the release
package folders in the root `bin` folder.
Make sure you use a [semantic version](https://semver.org). Recommended format:
build type | format | example
:--------- | :----------------------- | :------
@ -152,17 +157,17 @@ Windows](#on-windows)_ section below to create a build that retains the icon.**
prerelease | `<version>-beta.<date>` | `4.0.0-beta.20251230`
release | `<version>` | `4.0.0`
2. Run `build/windows/prepare-install-package.ps1` in PowerShell to create the release package
folders in the root `bin` folder.
3. Launch WSL and run this script:
2. Launch WSL and run this script:
```bash
# edit to match the build created in steps 1-2
# edit to match the build created in steps 1
# In WSL, `/mnt/c/example` accesses `C:\example` on the Windows filesystem.
version="4.0.0"
binFolder="/mnt/e/source/_Stardew/SMAPI/bin"
build/windows/finalize-install-package.sh "$version" "$binFolder"
```
Note: to prepare a test Windows-only build, you can pass `--windows-only` in the first step and
skip the second one.
## Release notes
See [release notes](../release-notes.md).

View File

@ -12,7 +12,7 @@ namespace StardewModdingAPI.Installer.Framework
** Fields
*********/
/// <summary>The underlying toolkit game scanner.</summary>
private readonly GameScanner GameScanner = new GameScanner();
private readonly GameScanner GameScanner = new();
/*********
@ -44,7 +44,7 @@ namespace StardewModdingAPI.Installer.Framework
/// <summary>Get the installer's version number.</summary>
public ISemanticVersion GetInstallerVersion()
{
var raw = this.GetType().Assembly.GetName().Version;
var raw = this.GetType().Assembly.GetName().Version!;
return new SemanticVersion(raw);
}

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
@ -35,6 +36,7 @@ namespace StardewModdingApi.Installer
/// <summary>Get the absolute file or folder paths to remove when uninstalling SMAPI.</summary>
/// <param name="installDir">The folder for Stardew Valley and SMAPI.</param>
/// <param name="modsDir">The folder for SMAPI mods.</param>
[SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These are valid file names.")]
private IEnumerable<string> GetUninstallPaths(DirectoryInfo installDir, DirectoryInfo modsDir)
{
string GetInstallPath(string path) => Path.Combine(installDir.FullName, path);
@ -126,7 +128,7 @@ namespace StardewModdingApi.Installer
/****
** Get basic info & set window title
****/
ModToolkit toolkit = new ModToolkit();
ModToolkit toolkit = new();
var context = new InstallerContext();
Console.Title = $"SMAPI {context.GetInstallerVersion()} installer on {context.Platform} {context.PlatformName}";
Console.WriteLine();
@ -164,7 +166,7 @@ namespace StardewModdingApi.Installer
}
// get game path from CLI
string gamePathArg = null;
string? gamePathArg = null;
{
int pathIndex = Array.LastIndexOf(args, "--game-path") + 1;
if (pathIndex >= 1 && args.Length >= pathIndex)
@ -189,8 +191,8 @@ namespace StardewModdingApi.Installer
** show theme selector
****/
// get theme writers
var lightBackgroundWriter = new ColorfulConsoleWriter(context.Platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.LightBackground));
var darkBackgroundWriter = new ColorfulConsoleWriter(context.Platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.DarkBackground));
ColorfulConsoleWriter lightBackgroundWriter = new(context.Platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.LightBackground));
ColorfulConsoleWriter darkBackgroundWriter = new(context.Platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.DarkBackground));
// print question
this.PrintPlain("Which text looks more readable?");
@ -237,7 +239,7 @@ namespace StardewModdingApi.Installer
** collect details
****/
// get game path
DirectoryInfo installDir = this.InteractivelyGetInstallPath(toolkit, context, gamePathArg);
DirectoryInfo? installDir = this.InteractivelyGetInstallPath(toolkit, context, gamePathArg);
if (installDir == null)
{
this.PrintError("Failed finding your game path.");
@ -246,7 +248,7 @@ namespace StardewModdingApi.Installer
}
// get folders
DirectoryInfo bundleDir = new DirectoryInfo(this.BundlePath);
DirectoryInfo bundleDir = new(this.BundlePath);
paths = new InstallerPaths(bundleDir, installDir);
}
@ -354,8 +356,8 @@ namespace StardewModdingApi.Installer
// move global save data folder (changed in 3.2)
{
string dataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley");
DirectoryInfo oldDir = new DirectoryInfo(Path.Combine(dataPath, "Saves", ".smapi"));
DirectoryInfo newDir = new DirectoryInfo(Path.Combine(dataPath, ".smapi"));
DirectoryInfo oldDir = new(Path.Combine(dataPath, "Saves", ".smapi"));
DirectoryInfo newDir = new(Path.Combine(dataPath, ".smapi"));
if (oldDir.Exists)
{
@ -428,7 +430,7 @@ namespace StardewModdingApi.Installer
}
// add or replace bundled mods
DirectoryInfo bundledModsDir = new DirectoryInfo(Path.Combine(paths.BundlePath, "Mods"));
DirectoryInfo bundledModsDir = new(Path.Combine(paths.BundlePath, "Mods"));
if (bundledModsDir.Exists && bundledModsDir.EnumerateDirectories().Any())
{
this.PrintDebug("Adding bundled mods...");
@ -449,8 +451,8 @@ namespace StardewModdingApi.Installer
}
// find target folder
ModFolder targetMod = targetMods.FirstOrDefault(p => p.Manifest?.UniqueID?.Equals(sourceMod.Manifest.UniqueID, StringComparison.OrdinalIgnoreCase) == true);
DirectoryInfo defaultTargetFolder = new DirectoryInfo(Path.Combine(paths.ModsPath, sourceMod.Directory.Name));
ModFolder? targetMod = targetMods.FirstOrDefault(p => p.Manifest?.UniqueID?.Equals(sourceMod.Manifest.UniqueID, StringComparison.OrdinalIgnoreCase) == true);
DirectoryInfo defaultTargetFolder = new(Path.Combine(paths.ModsPath, sourceMod.Directory.Name));
DirectoryInfo targetFolder = targetMod?.Directory ?? defaultTargetFolder;
this.PrintDebug(targetFolder.FullName == defaultTargetFolder.FullName
? $" adding {sourceMod.Manifest.Name}..."
@ -532,27 +534,45 @@ namespace StardewModdingApi.Installer
/// <summary>Print a message without formatting.</summary>
/// <param name="text">The text to print.</param>
private void PrintPlain(string text) => Console.WriteLine(text);
private void PrintPlain(string text)
{
Console.WriteLine(text);
}
/// <summary>Print a debug message.</summary>
/// <param name="text">The text to print.</param>
private void PrintDebug(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Debug);
private void PrintDebug(string text)
{
this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Debug);
}
/// <summary>Print a debug message.</summary>
/// <param name="text">The text to print.</param>
private void PrintInfo(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Info);
private void PrintInfo(string text)
{
this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Info);
}
/// <summary>Print a warning message.</summary>
/// <param name="text">The text to print.</param>
private void PrintWarning(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Warn);
private void PrintWarning(string text)
{
this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Warn);
}
/// <summary>Print a warning message.</summary>
/// <param name="text">The text to print.</param>
private void PrintError(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Error);
private void PrintError(string text)
{
this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Error);
}
/// <summary>Print a success message.</summary>
/// <param name="text">The text to print.</param>
private void PrintSuccess(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Success);
private void PrintSuccess(string text)
{
this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Success);
}
/// <summary>Interactively delete a file or folder path, and block until deletion completes.</summary>
/// <param name="path">The file or folder path.</param>
@ -562,7 +582,7 @@ namespace StardewModdingApi.Installer
{
try
{
FileUtilities.ForceDelete(Directory.Exists(path) ? new DirectoryInfo(path) : (FileSystemInfo)new FileInfo(path));
FileUtilities.ForceDelete(Directory.Exists(path) ? new DirectoryInfo(path) : new FileInfo(path));
break;
}
catch (Exception ex)
@ -578,7 +598,7 @@ namespace StardewModdingApi.Installer
/// <param name="source">The file or folder to copy.</param>
/// <param name="targetFolder">The folder to copy into.</param>
/// <param name="filter">A filter which matches directories and files to copy, or <c>null</c> to match all.</param>
private void RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool> filter = null)
private void RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool>? filter = null)
{
if (filter != null && !filter(source))
return;
@ -593,8 +613,8 @@ namespace StardewModdingApi.Installer
break;
case DirectoryInfo sourceDir:
DirectoryInfo targetSubfolder = new DirectoryInfo(Path.Combine(targetFolder.FullName, sourceDir.Name));
foreach (var entry in sourceDir.EnumerateFileSystemInfos())
DirectoryInfo targetSubfolder = new(Path.Combine(targetFolder.FullName, sourceDir.Name));
foreach (FileSystemInfo entry in sourceDir.EnumerateFileSystemInfos())
this.RecursiveCopy(entry, targetSubfolder, filter);
break;
@ -608,7 +628,7 @@ namespace StardewModdingApi.Installer
/// <param name="message">The message to print.</param>
/// <param name="options">The allowed options (not case sensitive).</param>
/// <param name="indent">The indentation to prefix to output.</param>
private string InteractivelyChoose(string message, string[] options, string indent = "", Action<string> print = null)
private string InteractivelyChoose(string message, string[] options, string indent = "", Action<string>? print = null)
{
print ??= this.PrintInfo;
@ -616,8 +636,8 @@ namespace StardewModdingApi.Installer
{
print(indent + message);
Console.Write(indent);
string input = Console.ReadLine()?.Trim().ToLowerInvariant();
if (!options.Contains(input))
string? input = Console.ReadLine()?.Trim().ToLowerInvariant();
if (input == null || !options.Contains(input))
{
print($"{indent}That's not a valid option.");
continue;
@ -630,7 +650,7 @@ namespace StardewModdingApi.Installer
/// <param name="toolkit">The mod toolkit.</param>
/// <param name="context">The installer context.</param>
/// <param name="specifiedPath">The path specified as a command-line argument (if any), which should override automatic path detection.</param>
private DirectoryInfo InteractivelyGetInstallPath(ModToolkit toolkit, InstallerContext context, string specifiedPath)
private DirectoryInfo? InteractivelyGetInstallPath(ModToolkit toolkit, InstallerContext context, string? specifiedPath)
{
// use specified path
if (specifiedPath != null)
@ -697,7 +717,7 @@ namespace StardewModdingApi.Installer
// get path from user
Console.WriteLine();
this.PrintInfo($"Type the file path to the game directory (the one containing '{Constants.GameDllName}'), then press enter.");
string path = Console.ReadLine()?.Trim();
string? path = Console.ReadLine()?.Trim();
if (string.IsNullOrWhiteSpace(path))
{
this.PrintWarning("You must specify a directory path to continue.");
@ -710,14 +730,14 @@ namespace StardewModdingApi.Installer
: path.Replace("\\ ", " "); // in Linux/macOS, spaces in paths may be escaped if copied from the command line
if (path.StartsWith("~/"))
{
string home = Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE");
string home = Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE")!;
path = Path.Combine(home, path.Substring(2));
}
// get directory
if (File.Exists(path))
path = Path.GetDirectoryName(path);
DirectoryInfo directory = new DirectoryInfo(path);
path = Path.GetDirectoryName(path)!;
DirectoryInfo directory = new(path);
// validate path
if (!directory.Exists)
@ -763,7 +783,7 @@ namespace StardewModdingApi.Installer
// game folder which contains the installer, if any
{
DirectoryInfo curPath = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory;
DirectoryInfo? curPath = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory;
while (curPath?.Parent != null) // must be in a folder (not at the root)
{
if (context.LooksLikeGameFolder(curPath))
@ -795,7 +815,7 @@ namespace StardewModdingApi.Installer
// get path
string appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley");
DirectoryInfo modDir = new DirectoryInfo(Path.Combine(appDataPath, "Mods"));
DirectoryInfo modDir = new(Path.Combine(appDataPath, "Mods"));
// check if migration needed
if (!modDir.Exists)
@ -808,7 +828,7 @@ namespace StardewModdingApi.Installer
{
// get type
bool isDir = entry is DirectoryInfo;
if (!isDir && !(entry is FileInfo))
if (!isDir && entry is not FileInfo)
continue; // should never happen
// delete packaged mods (newer version bundled into SMAPI)
@ -845,7 +865,7 @@ namespace StardewModdingApi.Installer
/// <summary>Move a filesystem entry to a new parent directory.</summary>
/// <param name="entry">The filesystem entry to move.</param>
/// <param name="newPath">The destination path.</param>
/// <remarks>We can't use <see cref="FileInfo.MoveTo"/> or <see cref="DirectoryInfo.MoveTo"/>, because those don't work across partitions.</remarks>
/// <remarks>We can't use <see cref="FileInfo.MoveTo(string)"/> or <see cref="DirectoryInfo.MoveTo"/>, because those don't work across partitions.</remarks>
private void Move(FileSystemInfo entry, string newPath)
{
// file
@ -872,15 +892,12 @@ namespace StardewModdingApi.Installer
/// <param name="entry">The file or folder info.</param>
private bool ShouldCopy(FileSystemInfo entry)
{
switch (entry.Name)
return entry.Name switch
{
case "mcs":
return false; // ignore macOS symlink
case "Mods":
return false; // Mods folder handled separately
default:
return true;
}
"mcs" => false, // ignore macOS symlink
"Mods" => false, // Mods folder handled separately
_ => true
};
}
}
}

View File

@ -15,7 +15,7 @@ namespace StardewModdingApi.Installer
*********/
/// <summary>The absolute path of the installer folder.</summary>
[SuppressMessage("ReSharper", "AssignNullToNotNullAttribute", Justification = "The assembly location is never null in this context.")]
private static readonly string InstallerPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
private static readonly string InstallerPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
/// <summary>The absolute path of the folder containing the unzipped installer files.</summary>
private static readonly string ExtractedBundlePath = Path.Combine(Path.GetTempPath(), $"SMAPI-installer-{Guid.NewGuid():N}");
@ -31,7 +31,7 @@ namespace StardewModdingApi.Installer
public static void Main(string[] args)
{
// find install bundle
FileInfo zipFile = new FileInfo(Path.Combine(Program.InstallerPath, "install.dat"));
FileInfo zipFile = new(Path.Combine(Program.InstallerPath, "install.dat"));
if (!zipFile.Exists)
{
Console.WriteLine($"Oops! Some of the installer files are missing; try re-downloading the installer. (Missing file: {zipFile.FullName})");
@ -40,7 +40,7 @@ namespace StardewModdingApi.Installer
}
// unzip bundle into temp folder
DirectoryInfo bundleDir = new DirectoryInfo(Program.ExtractedBundlePath);
DirectoryInfo bundleDir = new(Program.ExtractedBundlePath);
Console.WriteLine("Extracting install files...");
ZipFile.ExtractToDirectory(zipFile.FullName, bundleDir.FullName);
@ -66,14 +66,14 @@ namespace StardewModdingApi.Installer
/// <summary>Method called when assembly resolution fails, which may return a manually resolved assembly.</summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The event arguments.</param>
private static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs e)
private static Assembly? CurrentDomain_AssemblyResolve(object? sender, ResolveEventArgs e)
{
try
{
AssemblyName name = new AssemblyName(e.Name);
AssemblyName name = new(e.Name);
foreach (FileInfo dll in new DirectoryInfo(Program.InternalFilesPath).EnumerateFiles("*.dll"))
{
if (name.Name.Equals(AssemblyName.GetAssemblyName(dll.FullName).Name, StringComparison.OrdinalIgnoreCase))
if (name.Name != null && name.Name.Equals(AssemblyName.GetAssemblyName(dll.FullName).Name, StringComparison.OrdinalIgnoreCase))
return Assembly.LoadFrom(dll.FullName);
}
return null;

View File

@ -6,10 +6,41 @@
# move to script's directory
cd "$(dirname "$0")" || exit $?
# change to true to skip opening a terminal
# Whether to avoid opening a separate terminal window, and avoid logging anything to the console.
# This isn't recommended since you won't see errors, warnings, and update alerts.
SKIP_TERMINAL=false
# Whether to avoid opening a separate terminal, but still send the usual log output to the console.
USE_CURRENT_SHELL=false
##########
## Read environment variables
##########
if [ "$SMAPI_NO_TERMINAL" == "true" ]; then
SKIP_TERMINAL=true
fi
if [ "$SMAPI_USE_CURRENT_SHELL" == "true" ]; then
USE_CURRENT_SHELL=true
fi
##########
## Read command-line arguments
##########
while [ "$#" -gt 0 ]; do
case "$1" in
--skip-terminal ) SKIP_TERMINAL=true; shift ;;
--use-current-shell ) USE_CURRENT_SHELL=true; shift ;;
-- ) shift; break ;;
* ) shift ;;
esac
done
if [ "$SKIP_TERMINAL" == "true" ]; then
USE_CURRENT_SHELL=true
fi
##########
## Open terminal if needed
@ -18,21 +49,13 @@ SKIP_TERMINAL=false
# Besides letting the player see errors/warnings/alerts in the console, this is also needed because
# Steam messes with the PATH.
if [ "$(uname)" == "Darwin" ]; then
if [ ! -t 1 ]; then # https://stackoverflow.com/q/911168/262123
# sanity check to make sure we don't have an infinite loop of opening windows
for argument in "$@"; do
if [ "$argument" == "--no-reopen-terminal" ]; then
SKIP_TERMINAL=true
break
fi
done
if [ ! -t 1 ]; then # not open in Terminal (https://stackoverflow.com/q/911168/262123)
# reopen in Terminal if needed
# https://stackoverflow.com/a/29511052/262123
if [ "$SKIP_TERMINAL" == "false" ]; then
if [ "$USE_CURRENT_SHELL" == "false" ]; then
echo "Reopening in the Terminal app..."
echo '#!/bin/sh' > /tmp/open-smapi-terminal.sh
echo "\"$0\" $@ --no-reopen-terminal" >> /tmp/open-smapi-terminal.sh
echo "\"$0\" $@ --use-current-shell" >> /tmp/open-smapi-terminal.sh
chmod +x /tmp/open-smapi-terminal.sh
cat /tmp/open-smapi-terminal.sh
open -W -a Terminal /tmp/open-smapi-terminal.sh
@ -68,7 +91,7 @@ else
export LAUNCH_FILE
# run in terminal
if [ "$SKIP_TERMINAL" == "false" ]; then
if [ "$USE_CURRENT_SHELL" == "false" ]; then
# select terminal (prefer xterm for best compatibility, then known supported terminals)
for terminal in xterm gnome-terminal kitty terminator xfce4-terminal konsole terminal termite alacritty mate-terminal x-terminal-emulator; do
if command -v "$terminal" 2>/dev/null; then
@ -131,7 +154,9 @@ else
fi
# explicitly run without terminal
else
elif [ "$SKIP_TERMINAL" == "true" ]; then
exec $LAUNCH_FILE --no-terminal "$@"
else
exec $LAUNCH_FILE "$@"
fi
fi

View File

@ -30,7 +30,7 @@ namespace StardewModdingAPI.Internal.Patching
/// <param name="name">The method name.</param>
/// <param name="parameters">The method parameter types, or <c>null</c> if it's not overloaded.</param>
/// <param name="generics">The method generic types, or <c>null</c> if it's not generic.</param>
protected MethodInfo RequireMethod<TTarget>(string name, Type[] parameters = null, Type[] generics = null)
protected MethodInfo RequireMethod<TTarget>(string name, Type[]? parameters = null, Type[]? generics = null)
{
return PatchHelper.RequireMethod<TTarget>(name, parameters, generics);
}
@ -40,7 +40,7 @@ namespace StardewModdingAPI.Internal.Patching
/// <param name="priority">The patch priority to apply, usually specified using Harmony's <see cref="Priority"/> enum, or <c>null</c> to keep the default value.</param>
protected HarmonyMethod GetHarmonyMethod(string name, int? priority = null)
{
var method = new HarmonyMethod(
HarmonyMethod method = new(
AccessTools.Method(this.GetType(), name)
?? throw new InvalidOperationException($"Can't find patcher method {PatchHelper.GetMethodString(this.GetType(), name)}.")
);

View File

@ -15,7 +15,7 @@ namespace StardewModdingAPI.Internal.Patching
/// <param name="patchers">The patchers to apply.</param>
public static Harmony Apply(string id, IMonitor monitor, params IPatcher[] patchers)
{
Harmony harmony = new Harmony(id);
Harmony harmony = new(id);
foreach (IPatcher patcher in patchers)
{

View File

@ -16,7 +16,7 @@ namespace StardewModdingAPI.Internal.Patching
/// <typeparam name="TTarget">The type containing the method.</typeparam>
/// <param name="parameters">The method parameter types, or <c>null</c> if it's not overloaded.</param>
/// <exception cref="InvalidOperationException">The type has no matching constructor.</exception>
public static ConstructorInfo RequireConstructor<TTarget>(Type[] parameters = null)
public static ConstructorInfo RequireConstructor<TTarget>(Type[]? parameters = null)
{
return
AccessTools.Constructor(typeof(TTarget), parameters)
@ -29,7 +29,7 @@ namespace StardewModdingAPI.Internal.Patching
/// <param name="parameters">The method parameter types, or <c>null</c> if it's not overloaded.</param>
/// <param name="generics">The method generic types, or <c>null</c> if it's not generic.</param>
/// <exception cref="InvalidOperationException">The type has no matching method.</exception>
public static MethodInfo RequireMethod<TTarget>(string name, Type[] parameters = null, Type[] generics = null)
public static MethodInfo RequireMethod<TTarget>(string name, Type[]? parameters = null, Type[]? generics = null)
{
return
AccessTools.Method(typeof(TTarget), name, parameters, generics)
@ -41,9 +41,9 @@ namespace StardewModdingAPI.Internal.Patching
/// <param name="name">The method name, or <c>null</c> for a constructor.</param>
/// <param name="parameters">The method parameter types, or <c>null</c> if it's not overloaded.</param>
/// <param name="generics">The method generic types, or <c>null</c> if it's not generic.</param>
public static string GetMethodString(Type type, string name, Type[] parameters = null, Type[] generics = null)
public static string GetMethodString(Type type, string? name, Type[]? parameters = null, Type[]? generics = null)
{
StringBuilder str = new StringBuilder();
StringBuilder str = new();
// type
str.Append(type.FullName);

View File

@ -6,10 +6,26 @@ namespace StardewModdingAPI.Internal.ConsoleWriting
/// <summary>The console color scheme options.</summary>
internal class ColorSchemeConfig
{
/*********
** Accessors
*********/
/// <summary>The default color scheme ID to use, or <see cref="MonitorColorScheme.AutoDetect"/> to select one automatically.</summary>
public MonitorColorScheme UseScheme { get; set; }
public MonitorColorScheme UseScheme { get; }
/// <summary>The available console color schemes.</summary>
public IDictionary<MonitorColorScheme, IDictionary<ConsoleLogLevel, ConsoleColor>> Schemes { get; set; }
public IDictionary<MonitorColorScheme, IDictionary<ConsoleLogLevel, ConsoleColor>> Schemes { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="useScheme">The default color scheme ID to use, or <see cref="MonitorColorScheme.AutoDetect"/> to select one automatically.</param>
/// <param name="schemes">The available console color schemes.</param>
public ColorSchemeConfig(MonitorColorScheme useScheme, IDictionary<MonitorColorScheme, IDictionary<ConsoleLogLevel, ConsoleColor>> schemes)
{
this.UseScheme = useScheme;
this.Schemes = schemes;
}
}
}

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.Internal.ConsoleWriting
@ -11,10 +12,11 @@ namespace StardewModdingAPI.Internal.ConsoleWriting
** Fields
*********/
/// <summary>The console text color for each log level.</summary>
private readonly IDictionary<ConsoleLogLevel, ConsoleColor> Colors;
private readonly IDictionary<ConsoleLogLevel, ConsoleColor>? Colors;
/// <summary>Whether the current console supports color formatting.</summary>
private readonly bool SupportsColor;
[MemberNotNullWhen(true, nameof(ColorfulConsoleWriter.Colors))]
private bool SupportsColor { get; }
/*********
@ -72,10 +74,9 @@ namespace StardewModdingAPI.Internal.ConsoleWriting
/// <remarks>The colors here should be kept in sync with the SMAPI config file.</remarks>
public static ColorSchemeConfig GetDefaultColorSchemeConfig(MonitorColorScheme useScheme)
{
return new ColorSchemeConfig
{
UseScheme = useScheme,
Schemes = new Dictionary<MonitorColorScheme, IDictionary<ConsoleLogLevel, ConsoleColor>>
return new ColorSchemeConfig(
useScheme: useScheme,
schemes: new Dictionary<MonitorColorScheme, IDictionary<ConsoleLogLevel, ConsoleColor>>
{
[MonitorColorScheme.DarkBackground] = new Dictionary<ConsoleLogLevel, ConsoleColor>
{
@ -98,7 +99,7 @@ namespace StardewModdingAPI.Internal.ConsoleWriting
[ConsoleLogLevel.Success] = ConsoleColor.DarkGreen
}
}
};
);
}
@ -134,7 +135,7 @@ namespace StardewModdingAPI.Internal.ConsoleWriting
}
// get colors for scheme
return colorConfig.Schemes.TryGetValue(schemeID, out IDictionary<ConsoleLogLevel, ConsoleColor> scheme)
return colorConfig.Schemes.TryGetValue(schemeID, out IDictionary<ConsoleLogLevel, ConsoleColor>? scheme)
? scheme
: throw new NotSupportedException($"Unknown color scheme '{schemeID}'.");
}

View File

@ -12,7 +12,7 @@ namespace StardewModdingAPI.Internal
*********/
/// <summary>Get a string representation of an exception suitable for writing to the error log.</summary>
/// <param name="exception">The error to summarize.</param>
public static string GetLogSummary(this Exception exception)
public static string GetLogSummary(this Exception? exception)
{
try
{
@ -25,7 +25,7 @@ namespace StardewModdingAPI.Internal
case ReflectionTypeLoadException ex:
string summary = ex.ToString();
foreach (Exception childEx in ex.LoaderExceptions ?? new Exception[0])
foreach (Exception? childEx in ex.LoaderExceptions)
summary += $"\n\n{childEx?.GetLogSummary()}";
message = summary;
break;
@ -43,15 +43,6 @@ namespace StardewModdingAPI.Internal
}
}
/// <summary>Get the lowest exception in an exception stack.</summary>
/// <param name="exception">The exception from which to search.</param>
public static Exception GetInnermostException(this Exception exception)
{
while (exception.InnerException != null)
exception = exception.InnerException;
return exception;
}
/// <summary>Simplify common patterns in exception log messages that don't convey useful info.</summary>
/// <param name="message">The log message to simplify.</param>
public static string SimplifyExtensionMessage(string message)

View File

@ -1,6 +1,7 @@
// <generated />
using Microsoft.CodeAnalysis;
// ReSharper disable All -- generated code
using System;
using Microsoft.CodeAnalysis;
namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
{

View File

@ -1,12 +1,14 @@
// <generated />
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;
// ReSharper disable All -- generated code
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;
namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
{
@ -51,17 +53,17 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
protected static Diagnostic[] GetSortedDiagnosticsFromDocuments(DiagnosticAnalyzer analyzer, Document[] documents)
{
var projects = new HashSet<Project>();
foreach (var document in documents)
foreach (Document document in documents)
{
projects.Add(document.Project);
}
var diagnostics = new List<Diagnostic>();
foreach (var project in projects)
foreach (Project project in projects)
{
var compilationWithAnalyzers = project.GetCompilationAsync().Result.WithAnalyzers(ImmutableArray.Create(analyzer));
CompilationWithAnalyzers compilationWithAnalyzers = project.GetCompilationAsync().Result!.WithAnalyzers(ImmutableArray.Create(analyzer));
var diags = compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync().Result;
foreach (var diag in diags)
foreach (Diagnostic diag in diags)
{
if (diag.Location == Location.None || diag.Location.IsInMetadata)
{
@ -71,8 +73,8 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
{
for (int i = 0; i < documents.Length; i++)
{
var document = documents[i];
var tree = document.GetSyntaxTreeAsync().Result;
Document document = documents[i];
SyntaxTree? tree = document.GetSyntaxTreeAsync().Result;
if (tree == diag.Location.SourceTree)
{
diagnostics.Add(diag);
@ -113,7 +115,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
throw new ArgumentException("Unsupported Language");
}
var project = CreateProject(sources, language);
Project project = CreateProject(sources, language);
var documents = project.Documents.ToArray();
if (sources.Length != documents.Length)
@ -124,17 +126,6 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
return documents;
}
/// <summary>
/// Create a Document from a string through creating a project that contains it.
/// </summary>
/// <param name="source">Classes in the form of a string</param>
/// <param name="language">The language the source code is in</param>
/// <returns>A Document created from the source string</returns>
protected static Document CreateDocument(string source, string language = LanguageNames.CSharp)
{
return CreateProject(new[] { source }, language).Documents.First();
}
/// <summary>
/// Create a project using the inputted strings as sources.
/// </summary>
@ -146,9 +137,9 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
string fileNamePrefix = DefaultFilePathPrefix;
string fileExt = language == LanguageNames.CSharp ? CSharpDefaultFileExt : VisualBasicDefaultExt;
var projectId = ProjectId.CreateNewId(debugName: TestProjectName);
ProjectId projectId = ProjectId.CreateNewId(debugName: TestProjectName);
var solution = new AdhocWorkspace()
Solution solution = new AdhocWorkspace()
.CurrentSolution
.AddProject(projectId, TestProjectName, TestProjectName, language)
.AddMetadataReference(projectId, DiagnosticVerifier.SelfReference)
@ -158,14 +149,14 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
.AddMetadataReference(projectId, CodeAnalysisReference);
int count = 0;
foreach (var source in sources)
foreach (string source in sources)
{
var newFileName = fileNamePrefix + count + "." + fileExt;
var documentId = DocumentId.CreateNewId(projectId, debugName: newFileName);
string newFileName = fileNamePrefix + count + "." + fileExt;
DocumentId documentId = DocumentId.CreateNewId(projectId, debugName: newFileName);
solution = solution.AddDocument(documentId, newFileName, SourceText.From(source));
count++;
}
return solution.GetProject(projectId);
return solution.GetProject(projectId)!;
}
#endregion
}

View File

@ -1,4 +1,6 @@
// <generated />
// ReSharper disable All -- generated code
using System.Collections.Generic;
using System.Linq;
using System.Text;
@ -17,18 +19,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
/// <summary>
/// Get the CSharp analyzer being tested - to be implemented in non-abstract class
/// </summary>
protected virtual DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
{
return null;
}
/// <summary>
/// Get the Visual Basic analyzer being tested (C#) - to be implemented in non-abstract class
/// </summary>
protected virtual DiagnosticAnalyzer GetBasicDiagnosticAnalyzer()
{
return null;
}
protected abstract DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer();
#endregion
#region Verifier wrappers
@ -41,18 +32,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
/// <param name="expected"> DiagnosticResults that should appear after the analyzer is run on the source</param>
protected void VerifyCSharpDiagnostic(string source, params DiagnosticResult[] expected)
{
VerifyDiagnostics(new[] { source }, LanguageNames.CSharp, GetCSharpDiagnosticAnalyzer(), expected);
}
/// <summary>
/// Called to test a C# DiagnosticAnalyzer when applied on the inputted strings as a source
/// Note: input a DiagnosticResult for each Diagnostic expected
/// </summary>
/// <param name="sources">An array of strings to create source documents from to run the analyzers on</param>
/// <param name="expected">DiagnosticResults that should appear after the analyzer is run on the sources</param>
protected void VerifyCSharpDiagnostic(string[] sources, params DiagnosticResult[] expected)
{
VerifyDiagnostics(sources, LanguageNames.CSharp, GetCSharpDiagnosticAnalyzer(), expected);
this.VerifyDiagnostics(new[] { source }, LanguageNames.CSharp, this.GetCSharpDiagnosticAnalyzer(), expected);
}
/// <summary>
@ -65,8 +45,8 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
/// <param name="expected">DiagnosticResults that should appear after the analyzer is run on the sources</param>
private void VerifyDiagnostics(string[] sources, string language, DiagnosticAnalyzer analyzer, params DiagnosticResult[] expected)
{
var diagnostics = GetSortedDiagnostics(sources, language, analyzer);
VerifyDiagnosticResults(diagnostics, analyzer, expected);
var diagnostics = DiagnosticVerifier.GetSortedDiagnostics(sources, language, analyzer);
DiagnosticVerifier.VerifyDiagnosticResults(diagnostics, analyzer, expected);
}
#endregion
@ -86,7 +66,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
if (expectedCount != actualCount)
{
string diagnosticsOutput = actualResults.Any() ? FormatDiagnostics(analyzer, actualResults.ToArray()) : " NONE.";
string diagnosticsOutput = actualResults.Any() ? DiagnosticVerifier.FormatDiagnostics(analyzer, actualResults.ToArray()) : " NONE.";
Assert.IsTrue(false,
string.Format("Mismatch between number of diagnostics returned, expected \"{0}\" actual \"{1}\"\r\n\r\nDiagnostics:\r\n{2}\r\n", expectedCount, actualCount, diagnosticsOutput));
@ -103,12 +83,12 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
{
Assert.IsTrue(false,
string.Format("Expected:\nA project diagnostic with No location\nActual:\n{0}",
FormatDiagnostics(analyzer, actual)));
DiagnosticVerifier.FormatDiagnostics(analyzer, actual)));
}
}
else
{
VerifyDiagnosticLocation(analyzer, actual, actual.Location, expected.Locations.First());
DiagnosticVerifier.VerifyDiagnosticLocation(analyzer, actual, actual.Location, expected.Locations.First());
var additionalLocations = actual.AdditionalLocations.ToArray();
if (additionalLocations.Length != expected.Locations.Length - 1)
@ -116,12 +96,12 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
Assert.IsTrue(false,
string.Format("Expected {0} additional locations but got {1} for Diagnostic:\r\n {2}\r\n",
expected.Locations.Length - 1, additionalLocations.Length,
FormatDiagnostics(analyzer, actual)));
DiagnosticVerifier.FormatDiagnostics(analyzer, actual)));
}
for (int j = 0; j < additionalLocations.Length; ++j)
{
VerifyDiagnosticLocation(analyzer, actual, additionalLocations[j], expected.Locations[j + 1]);
DiagnosticVerifier.VerifyDiagnosticLocation(analyzer, actual, additionalLocations[j], expected.Locations[j + 1]);
}
}
@ -129,21 +109,21 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
{
Assert.IsTrue(false,
string.Format("Expected diagnostic id to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
expected.Id, actual.Id, FormatDiagnostics(analyzer, actual)));
expected.Id, actual.Id, DiagnosticVerifier.FormatDiagnostics(analyzer, actual)));
}
if (actual.Severity != expected.Severity)
{
Assert.IsTrue(false,
string.Format("Expected diagnostic severity to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
expected.Severity, actual.Severity, FormatDiagnostics(analyzer, actual)));
expected.Severity, actual.Severity, DiagnosticVerifier.FormatDiagnostics(analyzer, actual)));
}
if (actual.GetMessage() != expected.Message)
{
Assert.IsTrue(false,
string.Format("Expected diagnostic message to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
expected.Message, actual.GetMessage(), FormatDiagnostics(analyzer, actual)));
expected.Message, actual.GetMessage(), DiagnosticVerifier.FormatDiagnostics(analyzer, actual)));
}
}
}
@ -161,7 +141,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
Assert.IsTrue(actualSpan.Path == expected.Path || (actualSpan.Path != null && actualSpan.Path.Contains("Test0.") && expected.Path.Contains("Test.")),
string.Format("Expected diagnostic to be in file \"{0}\" was actually in file \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
expected.Path, actualSpan.Path, FormatDiagnostics(analyzer, diagnostic)));
expected.Path, actualSpan.Path, DiagnosticVerifier.FormatDiagnostics(analyzer, diagnostic)));
var actualLinePosition = actualSpan.StartLinePosition;
@ -172,7 +152,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
{
Assert.IsTrue(false,
string.Format("Expected diagnostic to be on line \"{0}\" was actually on line \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
expected.Line, actualLinePosition.Line + 1, FormatDiagnostics(analyzer, diagnostic)));
expected.Line, actualLinePosition.Line + 1, DiagnosticVerifier.FormatDiagnostics(analyzer, diagnostic)));
}
}
@ -183,7 +163,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
{
Assert.IsTrue(false,
string.Format("Expected diagnostic to start at column \"{0}\" was actually at column \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
expected.Column, actualLinePosition.Character + 1, FormatDiagnostics(analyzer, diagnostic)));
expected.Column, actualLinePosition.Character + 1, DiagnosticVerifier.FormatDiagnostics(analyzer, diagnostic)));
}
}
}
@ -201,7 +181,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
var builder = new StringBuilder();
for (int i = 0; i < diagnostics.Length; ++i)
{
builder.AppendLine("// " + diagnostics[i].ToString());
builder.AppendLine("// " + diagnostics[i]);
var analyzerType = analyzer.GetType();
var rules = analyzer.SupportedDiagnostics;
@ -220,11 +200,10 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
Assert.IsTrue(location.IsInSource,
$"Test base does not currently handle diagnostics in metadata locations. Diagnostic in metadata: {diagnostics[i]}\r\n");
string resultMethodName = diagnostics[i].Location.SourceTree.FilePath.EndsWith(".cs") ? "GetCSharpResultAt" : "GetBasicResultAt";
var linePosition = diagnostics[i].Location.GetLineSpan().StartLinePosition;
builder.AppendFormat("{0}({1}, {2}, {3}.{4})",
resultMethodName,
"GetCSharpResultAt",
linePosition.Line + 1,
linePosition.Character + 1,
analyzerType.Name,

View File

@ -1,10 +1,8 @@
// ReSharper disable CheckNamespace -- matches Stardew Valley's code
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace Netcode
{
/// <summary>A simplified version of Stardew Valley's <c>Netcode.NetCollection</c> for unit testing.</summary>
public class NetCollection<T> : Collection<T>, IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable { }
public class NetCollection<T> : Collection<T> { }
}

View File

@ -7,10 +7,13 @@ namespace Netcode
public class NetFieldBase<T, TSelf> where TSelf : NetFieldBase<T, TSelf>
{
/// <summary>The synchronised value.</summary>
public T Value { get; set; }
public T? Value { get; set; }
/// <summary>Implicitly convert a net field to the its type.</summary>
/// <param name="field">The field to convert.</param>
public static implicit operator T(NetFieldBase<T, TSelf> field) => field.Value;
public static implicit operator T?(NetFieldBase<T, TSelf> field)
{
return field.Value;
}
}
}

View File

@ -1,9 +1,8 @@
// ReSharper disable CheckNamespace -- matches Stardew Valley's code
using System.Collections;
using System.Collections.Generic;
namespace Netcode
{
/// <summary>A simplified version of Stardew Valley's <c>Netcode.NetObjectList</c> for unit testing.</summary>
public class NetList<T> : List<T>, IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable { }
public class NetList<T> : List<T> { }
}

View File

@ -1,5 +1,5 @@
// ReSharper disable CheckNamespace, InconsistentNaming -- matches Stardew Valley's code
#pragma warning disable 649 // (never assigned) -- only used to test type conversions
// ReSharper disable UnusedMember.Global -- used dynamically for unit tests
using System.Collections.Generic;
namespace StardewValley
@ -8,6 +8,6 @@ namespace StardewValley
internal class Farmer
{
/// <summary>A sample field which should be replaced with a different property.</summary>
public readonly IDictionary<string, int[]> friendships;
public readonly IDictionary<string, int[]> friendships = new Dictionary<string, int[]>();
}
}

View File

@ -1,4 +1,5 @@
// ReSharper disable CheckNamespace, InconsistentNaming -- matches Stardew Valley's code
// ReSharper disable UnusedMember.Global -- used dynamically for unit tests
using Netcode;
namespace StardewValley
@ -7,27 +8,27 @@ namespace StardewValley
public class Item
{
/// <summary>A net int field with an equivalent non-net <c>Category</c> property.</summary>
public readonly NetInt category = new NetInt { Value = 42 };
public readonly NetInt category = new() { Value = 42 };
/// <summary>A generic net int field with no equivalent non-net property.</summary>
public readonly NetInt netIntField = new NetInt { Value = 42 };
public readonly NetInt netIntField = new() { Value = 42 };
/// <summary>A generic net ref field with no equivalent non-net property.</summary>
public readonly NetRef<object> netRefField = new NetRef<object>();
public readonly NetRef<object> netRefField = new();
/// <summary>A generic net int property with no equivalent non-net property.</summary>
public NetInt netIntProperty = new NetInt { Value = 42 };
public NetInt netIntProperty = new() { Value = 42 };
/// <summary>A generic net ref property with no equivalent non-net property.</summary>
public NetRef<object> netRefProperty { get; } = new NetRef<object>();
public NetRef<object> netRefProperty { get; } = new();
/// <summary>A sample net list.</summary>
public readonly NetList<int> netList = new NetList<int>();
public readonly NetList<int> netList = new();
/// <summary>A sample net object list.</summary>
public readonly NetObjectList<int> netObjectList = new NetObjectList<int>();
public readonly NetObjectList<int> netObjectList = new();
/// <summary>A sample net collection.</summary>
public readonly NetCollection<int> netCollection = new NetCollection<int>();
public readonly NetCollection<int> netCollection = new();
}
}

View File

@ -7,6 +7,6 @@ namespace StardewValley
public class Object : Item
{
/// <summary>A net int field with an equivalent non-net property.</summary>
public NetInt type = new NetInt { Value = 42 };
public NetInt type = new() { Value = 42 };
}
}

View File

@ -87,13 +87,13 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests
[TestCase("SObject obj = null; if (obj.netRefProperty != null);", 24, "obj.netRefProperty", "NetRef", "object")]
[TestCase("Item item = new Item(); object list = item.netList;", 38, "item.netList", "NetList", "object")] // ↓ NetList field converted to a non-interface type
[TestCase("Item item = new Item(); object list = item.netCollection;", 38, "item.netCollection", "NetCollection", "object")]
[TestCase("Item item = new Item(); int x = (int)item.netIntField;", 32, "item.netIntField", "NetInt", "int")] // ↓ explicit conversion to invalid type
[TestCase("Item item = new Item(); int x = (int)item.netIntField;", 32, "item.netIntField", "NetFieldBase", "int")] // ↓ explicit conversion to invalid type
[TestCase("Item item = new Item(); int x = item.netRefField as object;", 32, "item.netRefField", "NetRef", "object")]
public void AvoidImplicitNetFieldComparisons_RaisesDiagnostic(string codeText, int column, string expression, string fromType, string toType)
{
// arrange
string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText);
DiagnosticResult expected = new DiagnosticResult
DiagnosticResult expected = new()
{
Id = "AvoidImplicitNetFieldCast",
Message = $"This implicitly converts '{expression}' from {fromType} to {toType}, but {fromType} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/package/avoid-implicit-net-field-cast for details.",
@ -135,7 +135,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests
{
// arrange
string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText);
DiagnosticResult expected = new DiagnosticResult
DiagnosticResult expected = new()
{
Id = "AvoidNetField",
Message = $"'{expression}' is a {netType} field; consider using the {suggestedProperty} property instead. See https://smapi.io/package/avoid-net-field for details.",

View File

@ -64,7 +64,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests
{
// arrange
string code = ObsoleteFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText);
DiagnosticResult expected = new DiagnosticResult
DiagnosticResult expected = new()
{
Id = "AvoidObsoleteField",
Message = $"The '{oldName}' field is obsolete and should be replaced with '{newName}'. See https://smapi.io/package/avoid-obsolete-field for details.",

View File

@ -0,0 +1,7 @@
## Release 2.1.0
### New Rules
Rule ID | Category | Severity | Notes
------------------------- | ------------------ | -------- | ------------------------------------------------------------
AvoidImplicitNetFieldCast | SMAPI.CommonErrors | Warning | See [documentation](https://smapi.io/package/code-warnings).
AvoidNetField | SMAPI.CommonErrors | Warning | See [documentation](https://smapi.io/package/code-warnings).
AvoidObsoleteField | SMAPI.CommonErrors | Warning | See [documentation](https://smapi.io/package/code-warnings).

View File

@ -40,8 +40,8 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
// invalid
fromExpression = null;
fromType = default(TypeInfo);
toType = default(TypeInfo);
fromType = default;
toType = default;
return false;
}
@ -64,7 +64,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
}
// conditional access
if (node is ConditionalAccessExpressionSyntax conditionalAccess && conditionalAccess.WhenNotNull is MemberBindingExpressionSyntax conditionalBinding)
if (node is ConditionalAccessExpressionSyntax { WhenNotNull: MemberBindingExpressionSyntax conditionalBinding } conditionalAccess)
{
declaringType = semanticModel.GetTypeInfo(conditionalAccess.Expression).Type;
memberType = semanticModel.GetTypeInfo(node);
@ -74,7 +74,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
// invalid
declaringType = null;
memberType = default(TypeInfo);
memberType = default;
memberName = null;
return false;
}

View File

@ -132,7 +132,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
};
/// <summary>The diagnostic info for an implicit net field cast.</summary>
private readonly DiagnosticDescriptor AvoidImplicitNetFieldCastRule = new DiagnosticDescriptor(
private readonly DiagnosticDescriptor AvoidImplicitNetFieldCastRule = new(
id: "AvoidImplicitNetFieldCast",
title: "Netcode types shouldn't be implicitly converted",
messageFormat: "This implicitly converts '{0}' from {1} to {2}, but {1} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/package/avoid-implicit-net-field-cast for details.",
@ -143,7 +143,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
);
/// <summary>The diagnostic info for an avoidable net field access.</summary>
private readonly DiagnosticDescriptor AvoidNetFieldRule = new DiagnosticDescriptor(
private readonly DiagnosticDescriptor AvoidNetFieldRule = new(
id: "AvoidNetField",
title: "Avoid Netcode types when possible",
messageFormat: "'{0}' is a {1} field; consider using the {2} property instead. See https://smapi.io/package/avoid-net-field for details.",
@ -227,10 +227,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
// warn: implicit conversion
if (this.IsInvalidConversion(memberType.Type, memberType.ConvertedType))
{
context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), context.Node, memberType.Type.Name, memberType.ConvertedType));
return;
}
});
}

View File

@ -24,7 +24,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
/// <summary>Describes the diagnostic rule covered by the analyzer.</summary>
private readonly IDictionary<string, DiagnosticDescriptor> Rules = new Dictionary<string, DiagnosticDescriptor>
{
["AvoidObsoleteField"] = new DiagnosticDescriptor(
["AvoidObsoleteField"] = new(
id: "AvoidObsoleteField",
title: "Reference to obsolete field",
messageFormat: "The '{0}' field is obsolete and should be replaced with '{1}'. See https://smapi.io/package/avoid-obsolete-field for details.",
@ -77,7 +77,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
try
{
// get reference info
if (!AnalyzerUtilities.TryGetMemberInfo(context.Node, context.SemanticModel, out ITypeSymbol declaringType, out TypeInfo memberType, out string memberName))
if (!AnalyzerUtilities.TryGetMemberInfo(context.Node, context.SemanticModel, out ITypeSymbol declaringType, out _, out string memberName))
return;
// suggest replacement

View File

@ -12,6 +12,10 @@
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.10.0" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="AnalyzerReleases.Shipped.md" />
</ItemGroup>
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>

View File

@ -88,7 +88,7 @@ namespace StardewModdingAPI.ModBuildConfig
Regex[] ignoreFilePatterns = this.GetCustomIgnorePatterns().ToArray();
// get mod info
ModFileManager package = new ModFileManager(this.ProjectDir, this.TargetDir, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, this.ModDllName, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip);
ModFileManager package = new(this.ProjectDir, this.TargetDir, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, this.ModDllName, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip);
// deploy mod files
if (this.EnableModDeploy)
@ -246,7 +246,7 @@ namespace StardewModdingAPI.ModBuildConfig
// create zip file
Directory.CreateDirectory(Path.GetDirectoryName(zipPath)!);
using Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write);
using ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create);
using ZipArchive archive = new(zipStream, ZipArchiveMode.Create);
foreach (var fileEntry in files)
{

View File

@ -136,7 +136,7 @@ namespace StardewModdingAPI.ModBuildConfig.Framework
// project manifest
bool hasProjectManifest = false;
{
FileInfo manifest = new FileInfo(Path.Combine(projectDir, this.ManifestFileName));
FileInfo manifest = new(Path.Combine(projectDir, this.ManifestFileName));
if (manifest.Exists)
{
yield return Tuple.Create(this.ManifestFileName, manifest);
@ -146,7 +146,7 @@ namespace StardewModdingAPI.ModBuildConfig.Framework
// project i18n files
bool hasProjectTranslations = false;
DirectoryInfo translationsFolder = new DirectoryInfo(Path.Combine(projectDir, "i18n"));
DirectoryInfo translationsFolder = new(Path.Combine(projectDir, "i18n"));
if (translationsFolder.Exists)
{
foreach (FileInfo file in translationsFolder.EnumerateFiles())
@ -156,7 +156,7 @@ namespace StardewModdingAPI.ModBuildConfig.Framework
// project assets folder
bool hasAssetsFolder = false;
DirectoryInfo assetsFolder = new DirectoryInfo(Path.Combine(projectDir, "assets"));
DirectoryInfo assetsFolder = new(Path.Combine(projectDir, "assets"));
if (assetsFolder.Exists)
{
foreach (FileInfo file in assetsFolder.EnumerateFiles("*", SearchOption.AllDirectories))
@ -168,7 +168,7 @@ namespace StardewModdingAPI.ModBuildConfig.Framework
}
// build output
DirectoryInfo buildFolder = new DirectoryInfo(targetDir);
DirectoryInfo buildFolder = new(targetDir);
foreach (FileInfo file in buildFolder.EnumerateFiles("*", SearchOption.AllDirectories))
{
// get path info

View File

@ -5,11 +5,12 @@
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<!--NuGet package-->
<PackageId>Pathoschild.Stardew.ModBuildConfig</PackageId>
<Title>Build package for SMAPI mods</Title>
<Version>4.0.0</Version>
<Version>4.0.1</Version>
<Authors>Pathoschild</Authors>
<Description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods. For SMAPI 3.13.0 or later.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
@ -24,6 +25,12 @@
<ItemGroup>
<PackageReference Include="Microsoft.Build.Utilities.Core" Version="16.10" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<!--
This is imported through Microsoft.Build.Utilities.Core. When installed by a mod, NuGet
otherwise imports version 4.3.0 instead of 5.0.0, which conflicts with SMAPI's version.
-->
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
</ItemGroup>
<ItemGroup>

View File

@ -1,6 +1,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
@ -52,7 +53,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
/// <param name="value">The parsed value.</param>
/// <param name="required">Whether to show an error if the argument is missing.</param>
/// <param name="oneOf">Require that the argument match one of the given values (case-insensitive).</param>
public bool TryGet(int index, string name, out string value, bool required = true, string[] oneOf = null)
public bool TryGet(int index, string name, [NotNullWhen(true)] out string? value, bool required = true, string[]? oneOf = null)
{
value = null;
@ -86,7 +87,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
value = 0;
// get argument
if (!this.TryGet(index, name, out string raw, required))
if (!this.TryGet(index, name, out string? raw, required))
return false;
// parse

View File

@ -100,7 +100,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
List<string[]> lines = new List<string[]>(rows.Length + 2)
{
header,
header.Select((value, i) => "".PadRight(widths[i], '-')).ToArray()
header.Select((_, i) => "".PadRight(widths[i], '-')).ToArray()
};
lines.AddRange(rows);

View File

@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other
{
/// <summary>A command which runs one of the game's save migrations.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class ApplySaveFixCommand : ConsoleCommand
{
/*********
@ -21,7 +23,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other
public override void Handle(IMonitor monitor, string command, ArgumentParser args)
{
// get fix ID
if (!args.TryGet(0, "fix_id", out string rawFixId, required: false))
if (!args.TryGet(0, "fix_id", out string? rawFixId, required: false))
{
monitor.Log("Invalid usage. Type 'help apply_save_fix' for details.", LogLevel.Error);
return;

View File

@ -1,8 +1,10 @@
using StardewValley;
using System.Diagnostics.CodeAnalysis;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other
{
/// <summary>A command which sends a debug command to the game.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class DebugCommand : ConsoleCommand
{
/*********

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using Netcode;
@ -9,6 +10,7 @@ using StardewValley.Network;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other
{
/// <summary>A command which regenerates the game's bundles.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class RegenerateBundlesCommand : ConsoleCommand
{
/*********

View File

@ -1,8 +1,10 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other
{
/// <summary>A command which shows the data files.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class ShowDataFilesCommand : ConsoleCommand
{
/*********

View File

@ -1,8 +1,10 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other
{
/// <summary>A command which shows the game files.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class ShowGameFilesCommand : ConsoleCommand
{
/*********
@ -18,8 +20,8 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other
/// <param name="args">The command arguments.</param>
public override void Handle(IMonitor monitor, string command, ArgumentParser args)
{
Process.Start(Constants.ExecutionPath);
monitor.Log($"OK, opening {Constants.ExecutionPath}.", LogLevel.Info);
Process.Start(Constants.GamePath);
monitor.Log($"OK, opening {Constants.GamePath}.", LogLevel.Info);
}
}
}

View File

@ -1,8 +1,10 @@
using System;
using System.Diagnostics.CodeAnalysis;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other
{
/// <summary>A command which logs the keys being pressed for 30 seconds once enabled.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class TestInputCommand : ConsoleCommand
{
/*********
@ -37,9 +39,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other
public override void OnUpdated(IMonitor monitor)
{
// handle expiry
if (this.ExpiryTicks == null)
return;
if (this.ExpiryTicks <= DateTime.UtcNow.Ticks)
if (this.ExpiryTicks != null && this.ExpiryTicks <= DateTime.UtcNow.Ticks)
{
monitor.Log("No longer logging input.", LogLevel.Info);
this.ExpiryTicks = null;

View File

@ -13,7 +13,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
** Fields
*********/
/// <summary>Provides methods for searching and constructing items.</summary>
private readonly ItemRepository Items = new ItemRepository();
private readonly ItemRepository Items = new();
/// <summary>The type names recognized by this command.</summary>
private readonly string[] ValidTypes = Enum.GetNames(typeof(ItemType)).Concat(new[] { "Name" }).ToArray();
@ -40,7 +40,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
}
// read arguments
if (!args.TryGet(0, "item type", out string type, oneOf: this.ValidTypes))
if (!args.TryGet(0, "item type", out string? type, oneOf: this.ValidTypes))
return;
if (!args.TryGetInt(2, "count", out int count, min: 1, required: false))
count = 1;
@ -48,7 +48,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
quality = Object.lowQuality;
// find matching item
SearchableItem match = Enum.TryParse(type, true, out ItemType itemType)
SearchableItem? match = Enum.TryParse(type, true, out ItemType itemType)
? this.FindItemByID(monitor, args, itemType)
: this.FindItemByName(monitor, args);
if (match == null)
@ -76,14 +76,14 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/// <param name="monitor">Writes messages to the console and log file.</param>
/// <param name="args">The command arguments.</param>
/// <param name="type">The item type.</param>
private SearchableItem FindItemByID(IMonitor monitor, ArgumentParser args, ItemType type)
private SearchableItem? FindItemByID(IMonitor monitor, ArgumentParser args, ItemType type)
{
// read arguments
if (!args.TryGetInt(1, "item ID", out int id, min: 0))
return null;
// find matching item
SearchableItem item = this.Items.GetAll().FirstOrDefault(p => p.Type == type && p.ID == id);
SearchableItem? item = this.Items.GetAll().FirstOrDefault(p => p.Type == type && p.ID == id);
if (item == null)
monitor.Log($"There's no {type} item with ID {id}.", LogLevel.Error);
return item;
@ -92,10 +92,10 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/// <summary>Get a matching item by its name.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
/// <param name="args">The command arguments.</param>
private SearchableItem FindItemByName(IMonitor monitor, ArgumentParser args)
private SearchableItem? FindItemByName(IMonitor monitor, ArgumentParser args)
{
// read arguments
if (!args.TryGet(1, "item name", out string name))
if (!args.TryGet(1, "item name", out string? name))
return null;
// find matching items

View File

@ -1,16 +1,18 @@
using System.Linq;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
/// <summary>A command which list item types.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class ListItemTypesCommand : ConsoleCommand
{
/*********
** Fields
*********/
/// <summary>Provides methods for searching and constructing items.</summary>
private readonly ItemRepository Items = new ItemRepository();
private readonly ItemRepository Items = new();
/*********

View File

@ -1,18 +1,20 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
/// <summary>A command which list items available to spawn.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class ListItemsCommand : ConsoleCommand
{
/*********
** Fields
*********/
/// <summary>Provides methods for searching and constructing items.</summary>
private readonly ItemRepository Items = new ItemRepository();
private readonly ItemRepository Items = new();
/*********
@ -59,15 +61,14 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
private IEnumerable<SearchableItem> GetItems(string[] searchWords)
{
// normalize search term
searchWords = searchWords?.Where(word => !string.IsNullOrWhiteSpace(word)).ToArray();
if (searchWords?.Any() == false)
searchWords = null;
searchWords = searchWords.Where(word => !string.IsNullOrWhiteSpace(word)).ToArray();
bool getAll = !searchWords.Any();
// find matches
return (
from item in this.Items.GetAll()
let term = $"{item.ID}|{item.Type}|{item.Name}|{item.DisplayName}"
where searchWords == null || searchWords.All(word => term.IndexOf(word, StringComparison.CurrentCultureIgnoreCase) != -1)
where getAll || searchWords.All(word => term.IndexOf(word, StringComparison.CurrentCultureIgnoreCase) != -1)
select item
);
}

View File

@ -1,9 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Xna.Framework;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
/// <summary>A command which edits the color of a player feature.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class SetColorCommand : ConsoleCommand
{
/*********
@ -20,9 +22,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
public override void Handle(IMonitor monitor, string command, ArgumentParser args)
{
// parse arguments
if (!args.TryGet(0, "target", out string target, oneOf: new[] { "hair", "eyes", "pants" }))
if (!args.TryGet(0, "target", out string? target, oneOf: new[] { "hair", "eyes", "pants" }))
return;
if (!args.TryGet(1, "color", out string rawColor))
if (!args.TryGet(1, "color", out string? rawColor))
return;
// parse color

View File

@ -1,6 +1,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Text;
@ -10,6 +11,7 @@ using StardewValley.GameData;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
/// <summary>A command which changes the player's farm type.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class SetFarmTypeCommand : ConsoleCommand
{
/*********
@ -33,7 +35,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
}
// parse arguments
if (!args.TryGet(0, "farm type", out string farmType))
if (!args.TryGet(0, "farm type", out string? farmType))
return;
bool isVanillaId = int.TryParse(farmType, out int vanillaId) && vanillaId is (>= 0 and < Farm.layout_max);
@ -108,7 +110,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
return;
}
if (!this.GetCustomFarmTypes().TryGetValue(id, out ModFarmType customFarmType))
if (!this.GetCustomFarmTypes().TryGetValue(id, out ModFarmType? customFarmType))
{
monitor.Log($"Invalid farm type '{id}'. Enter `help set_farm_type` for more info.", LogLevel.Error);
return;
@ -121,7 +123,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/// <summary>Change the farm type.</summary>
/// <param name="type">The farm type ID.</param>
/// <param name="customFarmData">The custom farm type data, if applicable.</param>
private void SetFarmType(int type, ModFarmType customFarmData)
private void SetFarmType(int type, ModFarmType? customFarmData)
{
// set flags
Game1.whichFarm = type;
@ -131,9 +133,10 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
Farm farm = Game1.getFarm();
farm.mapPath.Value = $@"Maps\{Farm.getMapNameFromTypeInt(Game1.whichFarm)}";
farm.reloadMap();
farm.updateWarps();
// clear spouse area cache to avoid errors
FieldInfo cacheField = farm.GetType().GetField("_baseSpouseAreaTiles", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
FieldInfo? cacheField = farm.GetType().GetField("_baseSpouseAreaTiles", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (cacheField == null)
throw new InvalidOperationException("Failed to access '_baseSpouseAreaTiles' field to clear spouse area cache.");
if (cacheField.GetValue(farm) is not IDictionary cache)
@ -161,7 +164,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/// <param name="type">The farm type.</param>
private string GetVanillaName(int type)
{
string translationKey = type switch
string? translationKey = type switch
{
Farm.default_layout => "Character_FarmStandard",
Farm.riverlands_layout => "Character_FarmFishing",
@ -194,7 +197,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
****/
/// <summary>Get the display name for a custom farm type.</summary>
/// <param name="farmType">The custom farm type.</param>
private string GetCustomName(ModFarmType farmType)
private string? GetCustomName(ModFarmType? farmType)
{
if (string.IsNullOrWhiteSpace(farmType?.TooltipStringPath))
return farmType?.ID;

View File

@ -1,9 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
/// <summary>A command which edits the player's current health.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class SetHealthCommand : ConsoleCommand
{
/*********

View File

@ -1,9 +1,11 @@
using System.Linq;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
/// <summary>A command which edits the player's current immunity.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class SetImmunityCommand : ConsoleCommand
{
/*********

View File

@ -1,9 +1,11 @@
using System.Linq;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
/// <summary>A command which edits the player's maximum health.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class SetMaxHealthCommand : ConsoleCommand
{
/*********

View File

@ -1,9 +1,11 @@
using System.Linq;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
/// <summary>A command which edits the player's maximum stamina.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class SetMaxStaminaCommand : ConsoleCommand
{
/*********

View File

@ -1,9 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
/// <summary>A command which edits the player's current money.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class SetMoneyCommand : ConsoleCommand
{
/*********

View File

@ -1,8 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
/// <summary>A command which edits the player's name.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class SetNameCommand : ConsoleCommand
{
/*********
@ -19,9 +21,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
public override void Handle(IMonitor monitor, string command, ArgumentParser args)
{
// parse arguments
if (!args.TryGet(0, "target", out string target, oneOf: new[] { "player", "farm" }))
if (!args.TryGet(0, "target", out string? target, oneOf: new[] { "player", "farm" }))
return;
args.TryGet(1, "name", out string name, required: false);
args.TryGet(1, "name", out string? name, required: false);
// handle
switch (target)

View File

@ -1,9 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
/// <summary>A command which edits the player's current stamina.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class SetStaminaCommand : ConsoleCommand
{
/*********

View File

@ -1,8 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
/// <summary>A command which edits a player style.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class SetStyleCommand : ConsoleCommand
{
/*********
@ -19,7 +21,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
public override void Handle(IMonitor monitor, string command, ArgumentParser args)
{
// parse arguments
if (!args.TryGet(0, "target", out string target, oneOf: new[] { "hair", "shirt", "acc", "skin", "shoe", "swim", "gender" }))
if (!args.TryGet(0, "target", out string? target, oneOf: new[] { "hair", "shirt", "acc", "skin", "shoe", "swim", "gender" }))
return;
if (!args.TryGetInt(1, "style ID", out int styleID))
return;

View File

@ -1,5 +1,7 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Xna.Framework;
using StardewValley;
using StardewValley.Locations;
using StardewValley.Objects;
@ -9,6 +11,7 @@ using SObject = StardewValley.Object;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
/// <summary>A command which clears in-game objects.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class ClearCommand : ConsoleCommand
{
/*********
@ -49,13 +52,13 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
}
// parse arguments
if (!args.TryGet(0, "location", out string locationName, required: true))
if (!args.TryGet(0, "location", out string? locationName, required: true))
return;
if (!args.TryGet(1, "object type", out string type, required: true, oneOf: this.ValidTypes))
if (!args.TryGet(1, "object type", out string? type, required: true, oneOf: this.ValidTypes))
return;
// get target location
GameLocation location = Game1.locations.FirstOrDefault(p => p.Name != null && p.Name.Equals(locationName, StringComparison.OrdinalIgnoreCase));
GameLocation? location = Game1.locations.FirstOrDefault(p => p.Name != null && p.Name.Equals(locationName, StringComparison.OrdinalIgnoreCase));
if (location == null && locationName == "current")
location = Game1.currentLocation;
if (location == null)
@ -92,11 +95,10 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
removed +=
this.RemoveObjects(location, obj =>
!(obj is Chest)
obj is not Chest
&& (
obj.Name == "Weeds"
|| obj.Name == "Stone"
|| (obj.ParentSheetIndex == 294 || obj.ParentSheetIndex == 295)
obj.Name is "Weeds" or "Stone"
|| obj.ParentSheetIndex is 294 or 295
)
)
+ this.RemoveResourceClumps(location, clump => this.DebrisClumps.Contains(clump.parentSheetIndex.Value));
@ -114,7 +116,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
case "furniture":
{
int removed = this.RemoveFurniture(location, furniture => true);
int removed = this.RemoveFurniture(location, _ => true);
monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info);
break;
}
@ -138,11 +140,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
bool everything = type == "everything";
int removed =
this.RemoveFurniture(location, p => true)
+ this.RemoveObjects(location, p => true)
+ this.RemoveTerrainFeatures(location, p => true)
+ this.RemoveLargeTerrainFeatures(location, p => everything || !(p is Bush bush) || bush.isDestroyable(location, p.currentTileLocation))
+ this.RemoveResourceClumps(location, p => true);
this.RemoveFurniture(location, _ => true)
+ this.RemoveObjects(location, _ => true)
+ this.RemoveTerrainFeatures(location, _ => true)
+ this.RemoveLargeTerrainFeatures(location, p => everything || p is not Bush bush || bush.isDestroyable(location, p.currentTileLocation))
+ this.RemoveResourceClumps(location, _ => true);
monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info);
break;
}
@ -165,11 +167,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
int removed = 0;
foreach (var pair in location.Objects.Pairs.ToArray())
foreach ((Vector2 tile, SObject? obj) in location.Objects.Pairs.ToArray())
{
if (shouldRemove(pair.Value))
if (shouldRemove(obj))
{
location.Objects.Remove(pair.Key);
location.Objects.Remove(tile);
removed++;
}
}
@ -185,11 +187,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
int removed = 0;
foreach (var pair in location.terrainFeatures.Pairs.ToArray())
foreach ((Vector2 tile, TerrainFeature? feature) in location.terrainFeatures.Pairs.ToArray())
{
if (shouldRemove(pair.Value))
if (shouldRemove(feature))
{
location.terrainFeatures.Remove(pair.Key);
location.terrainFeatures.Remove(tile);
removed++;
}
}
@ -225,7 +227,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
int removed = 0;
foreach (var clump in location.resourceClumps.Where(shouldRemove).ToArray())
foreach (ResourceClump clump in location.resourceClumps.Where(shouldRemove).ToArray())
{
location.resourceClumps.Remove(clump);
removed++;

View File

@ -1,9 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using StardewValley;
using StardewValley.Locations;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
/// <summary>A command which moves the player to the next mine level.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class DownMineLevelCommand : ConsoleCommand
{
/*********

View File

@ -1,9 +1,11 @@
using System;
using System.Diagnostics.CodeAnalysis;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
/// <summary>A command which immediately warps all NPCs to their scheduled positions. To hurry a single NPC, see <c>debug hurry npc-name</c> instead.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class HurryAllCommand : ConsoleCommand
{
/*********

View File

@ -1,10 +1,12 @@
using System.Linq;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using StardewModdingAPI.Utilities;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
/// <summary>A command which sets the current day.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class SetDayCommand : ConsoleCommand
{
/*********

View File

@ -1,9 +1,11 @@
using System;
using System.Diagnostics.CodeAnalysis;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
/// <summary>A command which moves the player to the given mine level.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class SetMineLevelCommand : ConsoleCommand
{
/*********

View File

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using StardewModdingAPI.Utilities;
using StardewValley;
@ -5,6 +6,7 @@ using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
/// <summary>A command which sets the current season.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class SetSeasonCommand : ConsoleCommand
{
/*********
@ -35,7 +37,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
}
// parse arguments
if (!args.TryGet(0, "season", out string season, oneOf: this.ValidSeasons))
if (!args.TryGet(0, "season", out string? season, oneOf: this.ValidSeasons))
return;
// handle

View File

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Xna.Framework;
using StardewValley;
@ -5,6 +6,7 @@ using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
/// <summary>A command which sets the current time.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class SetTimeCommand : ConsoleCommand
{
/*********

View File

@ -1,10 +1,12 @@
using System.Linq;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using StardewModdingAPI.Utilities;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
/// <summary>A command which sets the current year.</summary>
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")]
internal class SetYearCommand : ConsoleCommand
{
/*********

View File

@ -43,16 +43,6 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData
this.Item = createItem(this);
}
/// <summary>Construct an instance.</summary>
/// <param name="item">The item metadata to copy.</param>
public SearchableItem(SearchableItem item)
{
this.Type = item.Type;
this.ID = item.ID;
this.CreateItem = item.CreateItem;
this.Item = item.Item;
}
/// <summary>Get whether the item name contains a case-insensitive substring.</summary>
/// <param name="substring">The substring to find.</param>
public bool NameContains(string substring)

View File

@ -31,7 +31,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
/// <param name="itemTypes">The item types to fetch (or null for any type).</param>
/// <param name="includeVariants">Whether to include flavored variants like "Sunflower Honey".</param>
[SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "TryCreate invokes the lambda immediately.")]
public IEnumerable<SearchableItem> GetAll(ItemType[] itemTypes = null, bool includeVariants = true)
public IEnumerable<SearchableItem> GetAll(ItemType[]? itemTypes = null, bool includeVariants = true)
{
//
//
@ -43,9 +43,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
//
//
IEnumerable<SearchableItem> GetAllRaw()
IEnumerable<SearchableItem?> GetAllRaw()
{
HashSet<ItemType> types = itemTypes?.Any() == true ? new HashSet<ItemType>(itemTypes) : null;
HashSet<ItemType>? types = itemTypes?.Any() == true ? new HashSet<ItemType>(itemTypes) : null;
bool ShouldGet(ItemType type) => types == null || types.Contains(type);
// get tools
@ -106,8 +106,8 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
{
foreach (int id in this.TryLoad<int, string>("Data\\weapons").Keys)
{
yield return this.TryCreate(ItemType.Weapon, id, p => (p.ID >= 32 && p.ID <= 34)
? (Item)new Slingshot(p.ID)
yield return this.TryCreate(ItemType.Weapon, id, p => p.ID is >= 32 and <= 34
? new Slingshot(p.ID)
: new MeleeWeapon(p.ID)
);
}
@ -132,37 +132,40 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
{
foreach (int id in Game1.objectInformation.Keys)
{
string[] fields = Game1.objectInformation[id]?.Split('/');
// secret notes
if (id == 79)
{
if (ShouldGet(ItemType.Object))
{
foreach (int secretNoteId in this.TryLoad<int, string>("Data\\SecretNotes").Keys)
{
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + secretNoteId, _ =>
{
SObject note = new SObject(79, 1);
note.name = $"{note.name} #{secretNoteId}";
return note;
});
}
}
}
string[]? fields = Game1.objectInformation[id]?.Split('/');
// ring
else if (id != 801 && fields?.Length >= 4 && fields[3] == "Ring") // 801 = wedding ring, which isn't an equippable ring
if (id != 801 && fields?.Length >= 4 && fields[3] == "Ring") // 801 = wedding ring, which isn't an equippable ring
{
if (ShouldGet(ItemType.Ring))
yield return this.TryCreate(ItemType.Ring, id, p => new Ring(p.ID));
}
// item
// journal scrap
else if (id == 842)
{
if (ShouldGet(ItemType.Object))
{
foreach (SearchableItem? journalScrap in this.GetSecretNotes(isJournalScrap: true))
yield return journalScrap;
}
}
// secret notes
else if (id == 79)
{
if (ShouldGet(ItemType.Object))
{
foreach (SearchableItem? secretNote in this.GetSecretNotes(isJournalScrap: false))
yield return secretNote;
}
}
// object
else if (ShouldGet(ItemType.Object))
{
// spawn main item
SObject item = null;
SObject? item = null;
yield return this.TryCreate(ItemType.Object, id, p =>
{
return item = (p.ID == 812 // roe
@ -176,58 +179,120 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
// flavored items
if (includeVariants)
{
foreach (SearchableItem? variant in this.GetFlavoredObjectVariants(item))
yield return variant;
}
}
}
}
}
return (
from item in GetAllRaw()
where item != null
select item
);
}
/*********
** Private methods
*********/
/// <summary>Get the individual secret note or journal scrap items.</summary>
/// <param name="isJournalScrap">Whether to get journal scraps.</param>
/// <remarks>Derived from <see cref="GameLocation.tryToCreateUnseenSecretNote"/>.</remarks>
private IEnumerable<SearchableItem?> GetSecretNotes(bool isJournalScrap)
{
// get base item ID
int baseId = isJournalScrap ? 842 : 79;
// get secret note IDs
var ids = this
.TryLoad<int, string>("Data\\SecretNotes")
.Keys
.Where(isJournalScrap
? id => (id >= GameLocation.JOURNAL_INDEX)
: id => (id < GameLocation.JOURNAL_INDEX)
)
.Select<int, int>(isJournalScrap
? id => (id - GameLocation.JOURNAL_INDEX)
: id => id
);
// build items
foreach (int id in ids)
{
int fakeId = this.CustomIDOffset * 8 + id;
if (isJournalScrap)
fakeId += GameLocation.JOURNAL_INDEX;
yield return this.TryCreate(ItemType.Object, fakeId, _ =>
{
SObject note = new(baseId, 1);
note.Name = $"{note.Name} #{id}";
return note;
});
}
}
/// <summary>Get flavored variants of a base item (like Blueberry Wine for Blueberry), if any.</summary>
/// <param name="item">A sample of the base item.</param>
private IEnumerable<SearchableItem?> GetFlavoredObjectVariants(SObject item)
{
int id = item.ParentSheetIndex;
switch (item.Category)
{
// fruit products
case SObject.FruitsCategory:
// wine
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + item.ParentSheetIndex, _ => new SObject(348, 1)
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + id, _ => new SObject(348, 1)
{
Name = $"{item.Name} Wine",
Price = item.Price * 3,
preserve = { SObject.PreserveType.Wine },
preservedParentSheetIndex = { item.ParentSheetIndex }
preservedParentSheetIndex = { id }
});
// jelly
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + item.ParentSheetIndex, _ => new SObject(344, 1)
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + id, _ => new SObject(344, 1)
{
Name = $"{item.Name} Jelly",
Price = 50 + item.Price * 2,
preserve = { SObject.PreserveType.Jelly },
preservedParentSheetIndex = { item.ParentSheetIndex }
preservedParentSheetIndex = { id }
});
break;
// vegetable products
case SObject.VegetableCategory:
// juice
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + item.ParentSheetIndex, _ => new SObject(350, 1)
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + id, _ => new SObject(350, 1)
{
Name = $"{item.Name} Juice",
Price = (int)(item.Price * 2.25d),
preserve = { SObject.PreserveType.Juice },
preservedParentSheetIndex = { item.ParentSheetIndex }
preservedParentSheetIndex = { id }
});
// pickled
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + item.ParentSheetIndex, _ => new SObject(342, 1)
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, _ => new SObject(342, 1)
{
Name = $"Pickled {item.Name}",
Price = 50 + item.Price * 2,
preserve = { SObject.PreserveType.Pickle },
preservedParentSheetIndex = { item.ParentSheetIndex }
preservedParentSheetIndex = { id }
});
break;
// flower honey
case SObject.flowersCategory:
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + item.ParentSheetIndex, _ =>
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, _ =>
{
SObject honey = new SObject(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false)
SObject honey = new(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false)
{
Name = $"{item.Name} Honey",
preservedParentSheetIndex = { item.ParentSheetIndex }
preservedParentSheetIndex = { id }
};
honey.Price += item.Price * 2;
return honey;
@ -235,16 +300,19 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
break;
// roe and aged roe (derived from FishPond.GetFishProduce)
case SObject.sellAtFishShopCategory when item.ParentSheetIndex == 812:
case SObject.sellAtFishShopCategory when id == 812:
{
this.GetRoeContextTagLookups(out HashSet<string> simpleTags, out List<List<string>> complexTags);
foreach (var pair in Game1.objectInformation)
{
// get input
SObject input = this.TryCreate(ItemType.Object, pair.Key, p => new SObject(p.ID, 1))?.Item as SObject;
var inputTags = input?.GetContextTags();
if (inputTags?.Any() != true)
SObject? input = this.TryCreate(ItemType.Object, pair.Key, p => new SObject(p.ID, 1))?.Item as SObject;
if (input == null)
continue;
HashSet<string> inputTags = input.GetContextTags();
if (!inputTags.Any())
continue;
// check if roe-producing fish
@ -252,9 +320,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
continue;
// yield roe
SObject roe = null;
SObject? roe = null;
Color color = this.GetRoeColor(input);
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + item.ParentSheetIndex, _ =>
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + id, _ =>
{
roe = new ColoredObject(812, 1, color)
{
@ -269,7 +337,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
// aged roe
if (roe != null && pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item
{
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + item.ParentSheetIndex, _ => new ColoredObject(447, 1, color)
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + id, _ => new ColoredObject(447, 1, color)
{
name = $"Aged {input.Name} Roe",
Category = -27,
@ -283,18 +351,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
break;
}
}
}
}
}
}
return GetAllRaw().Where(p => p != null);
}
/*********
** Private methods
*********/
/// <summary>Get optimized lookups to match items which produce roe in a fish pond.</summary>
/// <param name="simpleTags">A lookup of simple singular tags which match a roe-producing fish.</param>
/// <param name="complexTags">A list of tag sets which match roe-producing fish.</param>
@ -320,6 +377,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
/// <typeparam name="TValue">The asset value type.</typeparam>
/// <param name="assetName">The data asset name.</param>
private Dictionary<TKey, TValue> TryLoad<TKey, TValue>(string assetName)
where TKey : notnull
{
try
{
@ -336,7 +394,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
/// <param name="type">The item type.</param>
/// <param name="id">The unique ID (if different from the item's parent sheet index).</param>
/// <param name="createItem">Create an item instance.</param>
private SearchableItem TryCreate(ItemType type, int id, Func<SearchableItem, Item> createItem)
private SearchableItem? TryCreate(ItemType type, int id, Func<SearchableItem, Item> createItem)
{
try
{

View File

@ -13,13 +13,13 @@ namespace StardewModdingAPI.Mods.ConsoleCommands
** Fields
*********/
/// <summary>The commands to handle.</summary>
private IConsoleCommand[] Commands;
private IConsoleCommand[] Commands = null!;
/// <summary>The commands which may need to handle update ticks.</summary>
private IConsoleCommand[] UpdateHandlers;
private IConsoleCommand[] UpdateHandlers = null!;
/// <summary>The commands which may need to handle input.</summary>
private IConsoleCommand[] InputHandlers;
private IConsoleCommand[] InputHandlers = null!;
/*********
@ -50,7 +50,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands
/// <summary>The method invoked when a button is pressed.</summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The event arguments.</param>
private void OnButtonPressed(object sender, ButtonPressedEventArgs e)
private void OnButtonPressed(object? sender, ButtonPressedEventArgs e)
{
foreach (IConsoleCommand command in this.InputHandlers)
command.OnButtonPressed(this.Monitor, e.Button);
@ -59,7 +59,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands
/// <summary>The method invoked when the game updates its state.</summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The event arguments.</param>
private void OnUpdateTicked(object sender, EventArgs e)
private void OnUpdateTicked(object? sender, EventArgs e)
{
foreach (IConsoleCommand command in this.UpdateHandlers)
command.OnUpdated(this.Monitor);
@ -71,7 +71,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands
/// <param name="args">The command arguments.</param>
private void HandleCommand(IConsoleCommand command, string commandName, string[] args)
{
ArgumentParser argParser = new ArgumentParser(commandName, args, this.Monitor);
ArgumentParser argParser = new(commandName, args, this.Monitor);
command.Handle(this.Monitor, commandName, argParser);
}
@ -81,7 +81,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands
return (
from type in this.GetType().Assembly.GetTypes()
where !type.IsAbstract && typeof(IConsoleCommand).IsAssignableFrom(type)
select (IConsoleCommand)Activator.CreateInstance(type)
select (IConsoleCommand)Activator.CreateInstance(type)!
);
}
}

View File

@ -1,9 +1,9 @@
{
"Name": "Console Commands",
"Author": "SMAPI",
"Version": "3.13.4",
"Version": "3.14.0",
"Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll",
"MinimumApiVersion": "3.13.4"
"MinimumApiVersion": "3.14.0"
}

View File

@ -30,7 +30,6 @@ namespace StardewModdingAPI.Mods.ErrorHandler
// apply patches
HarmonyPatcher.Apply(this.ModManifest.UniqueID, this.Monitor,
new DialoguePatcher(monitorForGame, this.Helper.Reflection),
new DictionaryPatcher(this.Helper.Reflection),
new EventPatcher(monitorForGame),
new GameLocationPatcher(monitorForGame),
new IClickableMenuPatcher(),
@ -58,7 +57,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler
/// <summary>The method invoked when a save is loaded.</summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The event arguments.</param>
private void OnSaveLoaded(object sender, SaveLoadedEventArgs e)
private void OnSaveLoaded(object? sender, SaveLoadedEventArgs e)
{
// show in-game warning for removed save content
if (this.IsSaveContentRemoved)
@ -81,7 +80,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler
MethodInfo getMonitorForGame = coreType.GetMethod("GetMonitorForGame")
?? throw new InvalidOperationException("Can't access the SMAPI's 'GetMonitorForGame' method. This mod may not work correctly.");
return (IMonitor)getMonitorForGame.Invoke(core, new object[0]) ?? this.Monitor;
return (IMonitor?)getMonitorForGame.Invoke(core, Array.Empty<object>()) ?? this.Monitor;
}
}
}

View File

@ -17,10 +17,10 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
** Fields
*********/
/// <summary>Writes messages to the console and log file on behalf of the game.</summary>
private static IMonitor MonitorForGame;
private static IMonitor MonitorForGame = null!;
/// <summary>Simplifies access to private code.</summary>
private static IReflectionHelper Reflection;
private static IReflectionHelper Reflection = null!;
/*********
@ -54,12 +54,12 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
/// <param name="speaker">The NPC for which the dialogue is being parsed.</param>
/// <param name="__exception">The exception thrown by the wrapped method, if any.</param>
/// <returns>Returns the exception to throw, if any.</returns>
private static Exception Finalize_Constructor(Dialogue __instance, string masterDialogue, NPC speaker, Exception __exception)
private static Exception? Finalize_Constructor(Dialogue __instance, string masterDialogue, NPC? speaker, Exception? __exception)
{
if (__exception != null)
{
// log message
string name = !string.IsNullOrWhiteSpace(speaker?.Name) ? speaker.Name : null;
string? name = !string.IsNullOrWhiteSpace(speaker?.Name) ? speaker.Name : null;
DialoguePatcher.MonitorForGame.Log($"Failed parsing dialogue string{(name != null ? $" for {name}" : "")}:\n{masterDialogue}\n{__exception.GetLogSummary()}", LogLevel.Error);
// set default dialogue

View File

@ -1,98 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using HarmonyLib;
using StardewModdingAPI.Internal.Patching;
using StardewValley.GameData;
using StardewValley.GameData.HomeRenovations;
using StardewValley.GameData.Movies;
namespace StardewModdingAPI.Mods.ErrorHandler.Patches
{
/// <summary>Harmony patches for <see cref="Dictionary{TKey,TValue}"/> which add the accessed key to <see cref="KeyNotFoundException"/> exceptions.</summary>
/// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks>
[SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
[SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
internal class DictionaryPatcher : BasePatcher
{
/*********
** Fields
*********/
/// <summary>Simplifies access to private code.</summary>
private static IReflectionHelper Reflection;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="reflector">Simplifies access to private code.</param>
public DictionaryPatcher(IReflectionHelper reflector)
{
DictionaryPatcher.Reflection = reflector;
}
/// <inheritdoc />
public override void Apply(Harmony harmony, IMonitor monitor)
{
Type[] keyTypes = { typeof(int), typeof(string) };
Type[] valueTypes = { typeof(int), typeof(string), typeof(HomeRenovation), typeof(MovieData), typeof(SpecialOrderData) };
foreach (Type keyType in keyTypes)
{
foreach (Type valueType in valueTypes)
{
Type dictionaryType = typeof(Dictionary<,>).MakeGenericType(keyType, valueType);
harmony.Patch(
original: AccessTools.Method(dictionaryType, "get_Item") ?? throw new InvalidOperationException($"Can't find method {PatchHelper.GetMethodString(dictionaryType, "get_Item")} to patch."),
finalizer: this.GetHarmonyMethod(nameof(DictionaryPatcher.Finalize_GetItem))
);
harmony.Patch(
original: AccessTools.Method(dictionaryType, "Add") ?? throw new InvalidOperationException($"Can't find method {PatchHelper.GetMethodString(dictionaryType, "Add")} to patch."),
finalizer: this.GetHarmonyMethod(nameof(DictionaryPatcher.Finalize_Add))
);
}
}
}
/*********
** Private methods
*********/
/// <summary>The method to call after the dictionary indexer throws an exception.</summary>
/// <param name="key">The dictionary key being fetched.</param>
/// <param name="__exception">The exception thrown by the wrapped method, if any.</param>
/// <returns>Returns the exception to throw, if any.</returns>
private static Exception Finalize_GetItem(object key, Exception __exception)
{
if (__exception is KeyNotFoundException)
DictionaryPatcher.AddKey(__exception, key);
return __exception;
}
/// <summary>The method to call after a dictionary insert throws an exception.</summary>
/// <param name="key">The dictionary key being inserted.</param>
/// <param name="__exception">The exception thrown by the wrapped method, if any.</param>
/// <returns>Returns the exception to throw, if any.</returns>
private static Exception Finalize_Add(object key, Exception __exception)
{
if (__exception is ArgumentException)
DictionaryPatcher.AddKey(__exception, key);
return __exception;
}
/// <summary>Add the dictionary key to an exception message.</summary>
/// <param name="exception">The exception whose message to edit.</param>
/// <param name="key">The dictionary key.</param>
private static void AddKey(Exception exception, object key)
{
DictionaryPatcher.Reflection
.GetField<string>(exception, "_message")
.SetValue($"{exception.Message}\nkey: '{key}'");
}
}
}

View File

@ -16,7 +16,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
** Fields
*********/
/// <summary>Writes messages to the console and log file on behalf of the game.</summary>
private static IMonitor MonitorForGame;
private static IMonitor MonitorForGame = null!;
/*********

View File

@ -17,8 +17,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
** Fields
*********/
/// <summary>Writes messages to the console and log file on behalf of the game.</summary>
private static IMonitor MonitorForGame;
private static IMonitor MonitorForGame = null!;
/*********
** Public methods
@ -52,7 +51,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
/// <param name="precondition">The precondition to be parsed.</param>
/// <param name="__exception">The exception thrown by the wrapped method, if any.</param>
/// <returns>Returns the exception to throw, if any.</returns>
private static Exception Finalize_CheckEventPrecondition(ref int __result, string precondition, Exception __exception)
private static Exception? Finalize_CheckEventPrecondition(ref int __result, string precondition, Exception? __exception)
{
if (__exception != null)
{
@ -68,7 +67,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
/// <param name="map">The map whose tilesheets to update.</param>
/// <param name="__exception">The exception thrown by the wrapped method, if any.</param>
/// <returns>Returns the exception to throw, if any.</returns>
private static Exception Finalize_UpdateSeasonalTileSheets(GameLocation __instance, Map map, Exception __exception)
private static Exception? Finalize_UpdateSeasonalTileSheets(GameLocation __instance, Map map, Exception? __exception)
{
if (__exception != null)
GameLocationPatcher.MonitorForGame.Log($"Failed updating seasonal tilesheets for location '{__instance.NameOrUniqueName}': \n{__exception}", LogLevel.Error);

View File

@ -18,7 +18,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
** Fields
*********/
/// <summary>Writes messages to the console and log file on behalf of the game.</summary>
private static IMonitor MonitorForGame;
private static IMonitor MonitorForGame = null!;
/*********
@ -54,7 +54,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
/// <param name="__result">The return value of the original method.</param>
/// <param name="__exception">The exception thrown by the wrapped method, if any.</param>
/// <returns>Returns the exception to throw, if any.</returns>
private static Exception Finalize_CurrentDialogue(NPC __instance, ref Stack<Dialogue> __result, Exception __exception)
private static Exception? Finalize_CurrentDialogue(NPC __instance, ref Stack<Dialogue> __result, Exception? __exception)
{
if (__exception == null)
return null;
@ -71,7 +71,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
/// <param name="__result">The patched method's return value.</param>
/// <param name="__exception">The exception thrown by the wrapped method, if any.</param>
/// <returns>Returns the exception to throw, if any.</returns>
private static Exception Finalize_ParseMasterSchedule(string rawData, NPC __instance, ref Dictionary<int, SchedulePathDescription> __result, Exception __exception)
private static Exception? Finalize_ParseMasterSchedule(string rawData, NPC __instance, ref Dictionary<int, SchedulePathDescription> __result, Exception? __exception)
{
if (__exception != null)
{

View File

@ -57,7 +57,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
/// <param name="__result">The patched method's return value.</param>
/// <param name="__exception">The exception thrown by the wrapped method, if any.</param>
/// <returns>Returns the exception to throw, if any.</returns>
private static Exception Finalize_Object_loadDisplayName(ref string __result, Exception __exception)
private static Exception? Finalize_Object_loadDisplayName(ref string __result, Exception? __exception)
{
if (__exception is KeyNotFoundException)
{

View File

@ -22,10 +22,10 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
** Fields
*********/
/// <summary>Writes messages to the console and log file.</summary>
private static IMonitor Monitor;
private static IMonitor Monitor = null!;
/// <summary>A callback invoked when custom content is removed from the save data to avoid a crash.</summary>
private static Action OnContentRemoved;
private static Action OnContentRemoved = null!;
/*********
@ -74,10 +74,10 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
/// <summary>The method to call after <see cref="SaveGame.LoadFarmType"/> throws an exception.</summary>
/// <param name="__exception">The exception thrown by the wrapped method, if any.</param>
/// <returns>Returns the exception to throw, if any.</returns>
private static Exception Finalize_LoadFarmType(Exception __exception)
private static Exception? Finalize_LoadFarmType(Exception? __exception)
{
// missing custom farm type
if (__exception?.Message?.Contains("not a valid farm type") == true && !int.TryParse(SaveGame.loaded.whichFarm, out _))
if (__exception?.Message.Contains("not a valid farm type") == true && !int.TryParse(SaveGame.loaded.whichFarm, out _))
{
SaveGamePatcher.Monitor.Log(__exception.GetLogSummary(), LogLevel.Error);
SaveGamePatcher.Monitor.Log($"Removed invalid custom farm type '{SaveGame.loaded.whichFarm}' to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom farm type mod?)", LogLevel.Warn);
@ -108,7 +108,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
/// <summary>Remove content which no longer exists in the game data.</summary>
/// <param name="location">The current game location.</param>
/// <param name="npcs">The NPC data.</param>
private static bool RemoveBrokenContent(GameLocation location, IDictionary<string, string> npcs)
private static bool RemoveBrokenContent(GameLocation? location, IDictionary<string, string> npcs)
{
bool removedAny = false;
if (location == null)
@ -121,7 +121,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
{
try
{
BluePrint _ = new BluePrint(building.buildingType.Value);
BluePrint _ = new(building.buildingType.Value);
}
catch (ContentLoadException)
{

View File

@ -28,9 +28,9 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
/*********
** Private methods
*********/
/// <summary>The method to call after <see cref="SpriteBatch.CheckValid"/>.</summary>
/// <summary>The method to call after <see cref="SpriteBatch.CheckValid(Texture2D)"/>.</summary>
/// <param name="texture">The texture to validate.</param>
private static void After_CheckValid(Texture2D texture)
private static void After_CheckValid(Texture2D? texture)
{
if (texture?.IsDisposed == true)
throw new ObjectDisposedException("Cannot draw this texture because it's disposed.");

View File

@ -33,7 +33,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches
/// <param name="delimiter">The delimiter by which to split the text description.</param>
/// <param name="__exception">The exception thrown by the wrapped method, if any.</param>
/// <returns>Returns the exception to throw, if any.</returns>
private static Exception Finalize_GetItemFromStandardTextDescription(string description, char delimiter, ref Exception __exception)
private static Exception? Finalize_GetItemFromStandardTextDescription(string description, char delimiter, ref Exception? __exception)
{
return __exception != null
? new FormatException($"Failed to parse item text description \"{description}\" with delimiter \"{delimiter}\".", __exception)

View File

@ -1,9 +1,9 @@
{
"Name": "Error Handler",
"Author": "SMAPI",
"Version": "3.13.4",
"Version": "3.14.0",
"Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.",
"UniqueID": "SMAPI.ErrorHandler",
"EntryDll": "ErrorHandler.dll",
"MinimumApiVersion": "3.13.4"
"MinimumApiVersion": "3.14.0"
}

View File

@ -1,5 +1,6 @@
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.IO.Compression;
using System.Linq;
@ -19,7 +20,7 @@ namespace StardewModdingAPI.Mods.SaveBackup
private readonly int BackupsToKeep = 10;
/// <summary>The absolute path to the folder in which to store save backups.</summary>
private readonly string BackupFolder = Path.Combine(Constants.ExecutionPath, "save-backups");
private readonly string BackupFolder = Path.Combine(Constants.GamePath, "save-backups");
/// <summary>A unique label for the save backup to create.</summary>
private readonly string BackupLabel = $"{DateTime.UtcNow:yyyy-MM-dd} - SMAPI {Constants.ApiVersion} with Stardew Valley {Game1.version}";
@ -38,13 +39,13 @@ namespace StardewModdingAPI.Mods.SaveBackup
try
{
// init backup folder
DirectoryInfo backupFolder = new DirectoryInfo(this.BackupFolder);
DirectoryInfo backupFolder = new(this.BackupFolder);
backupFolder.Create();
// back up & prune saves
Task
.Run(() => this.CreateBackup(backupFolder))
.ContinueWith(backupTask => this.PruneBackups(backupFolder, this.BackupsToKeep));
.ContinueWith(_ => this.PruneBackups(backupFolder, this.BackupsToKeep));
}
catch (Exception ex)
{
@ -63,8 +64,8 @@ namespace StardewModdingAPI.Mods.SaveBackup
try
{
// get target path
FileInfo targetFile = new FileInfo(Path.Combine(backupFolder.FullName, this.FileName));
DirectoryInfo fallbackDir = new DirectoryInfo(Path.Combine(backupFolder.FullName, this.BackupLabel));
FileInfo targetFile = new(Path.Combine(backupFolder.FullName, this.FileName));
DirectoryInfo fallbackDir = new(Path.Combine(backupFolder.FullName, this.BackupLabel));
if (targetFile.Exists || fallbackDir.Exists)
{
this.Monitor.Log("Already backed up today.");
@ -72,7 +73,7 @@ namespace StardewModdingAPI.Mods.SaveBackup
}
// copy saves to fallback directory (ignore non-save files/folders)
DirectoryInfo savesDir = new DirectoryInfo(Constants.SavesPath);
DirectoryInfo savesDir = new(Constants.SavesPath);
if (!this.RecursiveCopy(savesDir, fallbackDir, entry => this.MatchSaveFolders(savesDir, entry), copyRoot: false))
{
this.Monitor.Log("No saves found.");
@ -80,7 +81,7 @@ namespace StardewModdingAPI.Mods.SaveBackup
}
// compress backup if possible
if (!this.TryCompress(fallbackDir.FullName, targetFile, out Exception compressError))
if (!this.TryCompress(fallbackDir.FullName, targetFile, out Exception? compressError))
{
this.Monitor.Log(Constants.TargetPlatform != GamePlatform.Android
? $"Backed up to {fallbackDir.FullName}." // expected to fail on Android
@ -140,7 +141,7 @@ namespace StardewModdingAPI.Mods.SaveBackup
/// <param name="destination">The destination file to create.</param>
/// <param name="error">The error which occurred trying to compress, if applicable. This is <see cref="NotSupportedException"/> if compression isn't supported on this platform.</param>
/// <returns>Returns whether compression succeeded.</returns>
private bool TryCompress(string sourcePath, FileInfo destination, out Exception error)
private bool TryCompress(string sourcePath, FileInfo destination, [NotNullWhen(false)] out Exception? error)
{
try
{
@ -170,8 +171,8 @@ namespace StardewModdingAPI.Mods.SaveBackup
try
{
// create compressed backup
Assembly coreAssembly = Assembly.Load("System.IO.Compression, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089") ?? throw new InvalidOperationException("Can't load System.IO.Compression assembly.");
Assembly fsAssembly = Assembly.Load("System.IO.Compression.FileSystem, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089") ?? throw new InvalidOperationException("Can't load System.IO.Compression assembly.");
Assembly coreAssembly = Assembly.Load("System.IO.Compression, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089");
Assembly fsAssembly = Assembly.Load("System.IO.Compression.FileSystem, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089");
Type compressionLevelType = coreAssembly.GetType("System.IO.Compression.CompressionLevel") ?? throw new InvalidOperationException("Can't load CompressionLevel type.");
Type zipFileType = fsAssembly.GetType("System.IO.Compression.ZipFile") ?? throw new InvalidOperationException("Can't load ZipFile type.");
createFromDirectory = zipFileType.GetMethod("CreateFromDirectory", new[] { typeof(string), typeof(string), compressionLevelType, typeof(bool) }) ?? throw new InvalidOperationException("Can't load ZipFile.CreateFromDirectory method.");
@ -190,8 +191,8 @@ namespace StardewModdingAPI.Mods.SaveBackup
/// <param name="destination">The destination file to create.</param>
private void CompressUsingMacProcess(string sourcePath, FileInfo destination)
{
DirectoryInfo saveFolder = new DirectoryInfo(sourcePath);
ProcessStartInfo startInfo = new ProcessStartInfo
DirectoryInfo saveFolder = new(sourcePath);
ProcessStartInfo startInfo = new()
{
FileName = "zip",
Arguments = $"-rq \"{destination.FullName}\" \"{saveFolder.Name}\" -x \"*.DS_Store\" -x \"__MACOSX\"",
@ -208,7 +209,7 @@ namespace StardewModdingAPI.Mods.SaveBackup
/// <param name="filter">A filter which matches the files or directories to copy, or <c>null</c> to copy everything.</param>
/// <remarks>Derived from the SMAPI installer code.</remarks>
/// <returns>Returns whether any files were copied.</returns>
private bool RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool> filter, bool copyRoot = true)
private bool RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool>? filter, bool copyRoot = true)
{
if (!source.Exists || filter?.Invoke(source) == false)
return false;
@ -242,7 +243,7 @@ namespace StardewModdingAPI.Mods.SaveBackup
private bool MatchSaveFolders(DirectoryInfo savesFolder, FileSystemInfo entry)
{
// only need to filter top-level entries
string parentPath = (entry as FileInfo)?.DirectoryName ?? (entry as DirectoryInfo)?.Parent?.FullName;
string? parentPath = (entry as FileInfo)?.DirectoryName ?? (entry as DirectoryInfo)?.Parent?.FullName;
if (parentPath != savesFolder.FullName)
return true;

View File

@ -1,9 +1,9 @@
{
"Name": "Save Backup",
"Author": "SMAPI",
"Version": "3.13.4",
"Version": "3.14.0",
"Description": "Automatically backs up all your saves once per day into its folder.",
"UniqueID": "SMAPI.SaveBackup",
"EntryDll": "SaveBackup.dll",
"MinimumApiVersion": "3.13.4"
"MinimumApiVersion": "3.14.0"
}

View File

@ -0,0 +1,46 @@
using System;
using SMAPI.Tests.ModApiConsumer.Interfaces;
namespace SMAPI.Tests.ModApiConsumer
{
/// <summary>A simulated API consumer.</summary>
public class ApiConsumer
{
/*********
** Public methods
*********/
/// <summary>Call the event field on the given API.</summary>
/// <param name="api">The API to call.</param>
/// <param name="getValues">Get the number of times the event was called and the last value received.</param>
public void UseEventField(ISimpleApi api, out Func<(int timesCalled, int actualValue)> getValues)
{
// act
int calls = 0;
int lastValue = -1;
api.OnEventRaised += (_, value) =>
{
calls++;
lastValue = value;
};
getValues = () => (timesCalled: calls, actualValue: lastValue);
}
/// <summary>Call the event property on the given API.</summary>
/// <param name="api">The API to call.</param>
/// <param name="getValues">Get the number of times the event was called and the last value received.</param>
public void UseEventProperty(ISimpleApi api, out Func<(int timesCalled, int actualValue)> getValues)
{
// act
int calls = 0;
int lastValue = -1;
api.OnEventRaisedProperty += (_, value) =>
{
calls++;
lastValue = value;
};
getValues = () => (timesCalled: calls, actualValue: lastValue);
}
}
}

View File

@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using StardewModdingAPI.Utilities;
namespace SMAPI.Tests.ModApiConsumer.Interfaces
{
/// <summary>A mod-provided API which provides basic events, properties, and methods.</summary>
public interface ISimpleApi
{
/*********
** Test interface
*********/
/****
** Events
****/
/// <summary>A simple event field.</summary>
event EventHandler<int> OnEventRaised;
/// <summary>A simple event property with custom add/remove logic.</summary>
event EventHandler<int> OnEventRaisedProperty;
/****
** Properties
****/
/// <summary>A simple numeric property.</summary>
int NumberProperty { get; set; }
/// <summary>A simple object property.</summary>
object ObjectProperty { get; set; }
/// <summary>A simple list property.</summary>
List<string> ListProperty { get; set; }
/// <summary>A simple list property with an interface.</summary>
IList<string> ListPropertyWithInterface { get; set; }
/// <summary>A property with nested generics.</summary>
IDictionary<string, IList<string>> GenericsProperty { get; set; }
/// <summary>A property using an enum available to both mods.</summary>
BindingFlags EnumProperty { get; set; }
/// <summary>A read-only property.</summary>
int GetterProperty { get; }
/****
** Methods
****/
/// <summary>A simple method with no return value.</summary>
void GetNothing();
/// <summary>A simple method which returns a number.</summary>
int GetInt(int value);
/// <summary>A simple method which returns an object.</summary>
object GetObject(object value);
/// <summary>A simple method which returns a list.</summary>
List<string> GetList(string value);
/// <summary>A simple method which returns a list with an interface.</summary>
IList<string> GetListWithInterface(string value);
/// <summary>A simple method which returns nested generics.</summary>
IDictionary<string, IList<string>> GetGenerics(string key, string value);
/// <summary>A simple method which returns a lambda.</summary>
Func<string, string> GetLambda(Func<string, string> value);
/// <summary>A simple method which returns out parameters.</summary>
bool TryGetOutParameter(int inputNumber, out int outNumber, out string outString, out PerScreen<int> outReference, out IDictionary<int, PerScreen<int>> outComplexType);
/****
** Inherited members
****/
/// <summary>A property inherited from a base class.</summary>
public string InheritedProperty { get; set; }
}
}

View File

@ -0,0 +1,3 @@
This project contains a simulated [mod-provided API] consumer used in the API proxying unit tests.
[mod-provided API]: https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations

View File

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<Import Project="..\..\build\common.targets" />
<ItemGroup>
<ProjectReference Include="..\SMAPI\SMAPI.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,12 @@
namespace SMAPI.Tests.ModApiProvider.Framework
{
/// <summary>The base class for <see cref="SimpleApi"/>.</summary>
public class BaseApi
{
/*********
** Test interface
*********/
/// <summary>A property inherited from a base class.</summary>
public string? InheritedProperty { get; set; }
}
}

View File

@ -0,0 +1,125 @@
// ReSharper disable UnusedMember.Global -- used dynamically through proxies
using System;
using System.Collections.Generic;
using System.Reflection;
using StardewModdingAPI.Utilities;
namespace SMAPI.Tests.ModApiProvider.Framework
{
/// <summary>A mod-provided API which provides basic events, properties, and methods.</summary>
public class SimpleApi : BaseApi
{
/*********
** Test interface
*********/
/****
** Events
****/
/// <summary>A simple event field.</summary>
public event EventHandler<int>? OnEventRaised;
/// <summary>A simple event property with custom add/remove logic.</summary>
public event EventHandler<int> OnEventRaisedProperty
{
add => this.OnEventRaised += value;
remove => this.OnEventRaised -= value;
}
/****
** Properties
****/
/// <summary>A simple numeric property.</summary>
public int NumberProperty { get; set; }
/// <summary>A simple object property.</summary>
public object? ObjectProperty { get; set; }
/// <summary>A simple list property.</summary>
public List<string>? ListProperty { get; set; }
/// <summary>A simple list property with an interface.</summary>
public IList<string>? ListPropertyWithInterface { get; set; }
/// <summary>A property with nested generics.</summary>
public IDictionary<string, IList<string>>? GenericsProperty { get; set; }
/// <summary>A property using an enum available to both mods.</summary>
public BindingFlags EnumProperty { get; set; }
/// <summary>A read-only property.</summary>
public int GetterProperty => 42;
/****
** Methods
****/
/// <summary>A simple method with no return value.</summary>
public void GetNothing() { }
/// <summary>A simple method which returns a number.</summary>
public int GetInt(int value)
{
return value;
}
/// <summary>A simple method which returns an object.</summary>
public object GetObject(object value)
{
return value;
}
/// <summary>A simple method which returns a list.</summary>
public List<string> GetList(string value)
{
return new() { value };
}
/// <summary>A simple method which returns a list with an interface.</summary>
public IList<string> GetListWithInterface(string value)
{
return new List<string> { value };
}
/// <summary>A simple method which returns nested generics.</summary>
public IDictionary<string, IList<string>> GetGenerics(string key, string value)
{
return new Dictionary<string, IList<string>>
{
[key] = new List<string> { value }
};
}
/// <summary>A simple method which returns a lambda.</summary>
public Func<string, string> GetLambda(Func<string, string> value)
{
return value;
}
/// <summary>A simple method which returns out parameters.</summary>
public bool TryGetOutParameter(int inputNumber, out int outNumber, out string outString, out PerScreen<int> outReference, out IDictionary<int, PerScreen<int>> outComplexType)
{
outNumber = inputNumber;
outString = inputNumber.ToString();
outReference = new PerScreen<int>(() => inputNumber);
outComplexType = new Dictionary<int, PerScreen<int>>
{
[inputNumber] = new PerScreen<int>(() => inputNumber)
};
return true;
}
/*********
** Helper methods
*********/
/// <summary>Raise the <see cref="OnEventRaised"/> event.</summary>
/// <param name="value">The value to pass to the event.</param>
public void RaiseEventField(int value)
{
this.OnEventRaised?.Invoke(null, value);
}
}
}

View File

@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Reflection;
using SMAPI.Tests.ModApiProvider.Framework;
namespace SMAPI.Tests.ModApiProvider
{
/// <summary>A simulated mod instance.</summary>
public class ProviderMod
{
/// <summary>The underlying API instance.</summary>
private readonly SimpleApi Api = new();
/// <summary>Get the mod API instance.</summary>
public object GetModApi()
{
return this.Api;
}
/// <summary>Raise the <see cref="SimpleApi.OnEventRaised"/> event.</summary>
/// <param name="value">The value to send as an event argument.</param>
public void RaiseEvent(int value)
{
this.Api.RaiseEventField(value);
}
/// <summary>Set the values for the API property.</summary>
public void SetPropertyValues(int number, object obj, string listValue, string listWithInterfaceValue, string dictionaryKey, string dictionaryListValue, BindingFlags enumValue, string inheritedValue)
{
this.Api.NumberProperty = number;
this.Api.ObjectProperty = obj;
this.Api.ListProperty = new List<string> { listValue };
this.Api.ListPropertyWithInterface = new List<string> { listWithInterfaceValue };
this.Api.GenericsProperty = new Dictionary<string, IList<string>> { [dictionaryKey] = new List<string> { dictionaryListValue } };
this.Api.EnumProperty = enumValue;
this.Api.InheritedProperty = inheritedValue;
}
}
}

View File

@ -0,0 +1,3 @@
This project contains simulated [mod-provided APIs] used in the API proxying unit tests.
[mod-provided APIs]: https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations

View File

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<Import Project="..\..\build\common.targets" />
<ItemGroup>
<ProjectReference Include="..\SMAPI\SMAPI.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,294 @@
using System;
using System.Collections.Generic;
using FluentAssertions;
using NUnit.Framework;
using StardewModdingAPI;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
namespace SMAPI.Tests.Core
{
/// <summary>Unit tests for <see cref="AssetName"/>.</summary>
[TestFixture]
internal class AssetNameTests
{
/*********
** Unit tests
*********/
/****
** Constructor
****/
[Test(Description = $"Assert that the {nameof(AssetName)} constructor creates an instance with the expected values.")]
[TestCase("SimpleName", "SimpleName", null, null)]
[TestCase("Data/Achievements", "Data/Achievements", null, null)]
[TestCase("Characters/Dialogue/Abigail", "Characters/Dialogue/Abigail", null, null)]
[TestCase("Characters/Dialogue/Abigail.fr-FR", "Characters/Dialogue/Abigail", "fr-FR", LocalizedContentManager.LanguageCode.fr)]
[TestCase("Characters/Dialogue\\Abigail.fr-FR", "Characters/Dialogue/Abigail.fr-FR", null, null)]
[TestCase("Characters/Dialogue/Abigail.fr-FR", "Characters/Dialogue/Abigail", "fr-FR", LocalizedContentManager.LanguageCode.fr)]
public void Constructor_Valid(string name, string expectedBaseName, string? expectedLocale, LocalizedContentManager.LanguageCode? expectedLanguageCode)
{
// arrange
name = PathUtilities.NormalizeAssetName(name);
// act
IAssetName assetName = AssetName.Parse(name, parseLocale: _ => expectedLanguageCode);
// assert
assetName.Name.Should()
.NotBeNull()
.And.Be(name.Replace("\\", "/"));
assetName.BaseName.Should()
.NotBeNull()
.And.Be(expectedBaseName);
assetName.LocaleCode.Should()
.Be(expectedLocale);
assetName.LanguageCode.Should()
.Be(expectedLanguageCode);
}
[Test(Description = $"Assert that the {nameof(AssetName)} constructor throws an exception if the value is invalid.")]
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
[TestCase("\t")]
[TestCase(" \t ")]
public void Constructor_NullOrWhitespace(string? name)
{
// act
ArgumentException exception = Assert.Throws<ArgumentException>(() => _ = AssetName.Parse(name!, _ => null))!;
// assert
exception.ParamName.Should().Be("rawName");
exception.Message.Should().Be("The asset name can't be null or empty. (Parameter 'rawName')");
}
/****
** IsEquivalentTo
****/
[Test(Description = $"Assert that {nameof(AssetName.IsEquivalentTo)} compares names as expected when the locale is included.")]
// exact match (ignore case)
[TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)]
[TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)]
// exact match (ignore formatting)
[TestCase("Data/Achievements", "Data\\Achievements", ExpectedResult = true)]
[TestCase("DATA\\achievements", "data/ACHIEVEMENTS", ExpectedResult = true)]
[TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)]
// whitespace-insensitive
[TestCase("Data/Achievements", " Data/Achievements ", ExpectedResult = true)]
[TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)]
// other is null or whitespace
[TestCase("Data/Achievements", null, ExpectedResult = false)]
[TestCase("Data/Achievements", "", ExpectedResult = false)]
[TestCase("Data/Achievements", " ", ExpectedResult = false)]
// with locale codes
[TestCase("Data/Achievements", "Data/Achievements.fr-FR", ExpectedResult = false)]
[TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = false)]
[TestCase("Data/Achievements.fr-FR", "Data/Achievements.fr-FR", ExpectedResult = true)]
public bool IsEquivalentTo_Name(string mainAssetName, string otherAssetName)
{
// arrange
mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName);
// act
AssetName name = AssetName.Parse(mainAssetName, _ => LocalizedContentManager.LanguageCode.fr);
// assert
return name.IsEquivalentTo(otherAssetName);
}
[Test(Description = $"Assert that {nameof(AssetName.IsEquivalentTo)} compares names as expected when the locale is excluded.")]
// a few samples from previous test to make sure
[TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)]
[TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)]
[TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)]
[TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)]
[TestCase("Data/Achievements", " ", ExpectedResult = false)]
// with locale codes
[TestCase("Data/Achievements", "Data/Achievements.fr-FR", ExpectedResult = false)]
[TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)]
[TestCase("Data/Achievements.fr-FR", "Data/Achievements.fr-FR", ExpectedResult = false)]
public bool IsEquivalentTo_BaseName(string mainAssetName, string otherAssetName)
{
// arrange
mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName);
// act
AssetName name = AssetName.Parse(mainAssetName, _ => LocalizedContentManager.LanguageCode.fr);
// assert
return name.IsEquivalentTo(otherAssetName, useBaseName: true);
}
/****
** StartsWith
****/
[Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for inputs that aren't affected by the input options.")]
// exact match (ignore case and formatting)
[TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)]
[TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)]
[TestCase("Data/Achievements", "Data\\Achievements", ExpectedResult = true)]
[TestCase("DATA\\achievements", "data/ACHIEVEMENTS", ExpectedResult = true)]
[TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)]
// whitespace-insensitive
[TestCase("Data/Achievements", " Data/Achievements", ExpectedResult = true)]
[TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)]
[TestCase("Data/Achievements", " ", ExpectedResult = true)]
// invalid prefixes
[TestCase("Data/Achievements", null, ExpectedResult = false)]
// with locale codes
[TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)]
public bool StartsWith_SimpleCases(string mainAssetName, string prefix)
{
// arrange
mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName);
// act
AssetName name = AssetName.Parse(mainAssetName, _ => null);
// assert value is the same for any combination of options
bool result = name.StartsWith(prefix);
foreach (bool allowPartialWord in new[] { true, false })
{
foreach (bool allowSubfolder in new[] { true, true })
{
if (allowPartialWord && allowSubfolder)
continue;
name.StartsWith(prefix, allowPartialWord, allowSubfolder)
.Should().Be(result, $"the value returned for options ({nameof(allowPartialWord)}: {allowPartialWord}, {nameof(allowSubfolder)}: {allowSubfolder}) should match the base case");
}
}
// assert value
return result;
}
[Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for the 'allowPartialWord' option.")]
[TestCase("Data/AchievementsToIgnore", "Data/Achievements", true, ExpectedResult = true)]
[TestCase("Data/AchievementsToIgnore", "Data/Achievements", false, ExpectedResult = false)]
[TestCase("Data/Achievements X", "Data/Achievements", true, ExpectedResult = true)]
[TestCase("Data/Achievements X", "Data/Achievements", false, ExpectedResult = true)]
[TestCase("Data/Achievements.X", "Data/Achievements", true, ExpectedResult = true)]
[TestCase("Data/Achievements.X", "Data/Achievements", false, ExpectedResult = true)]
// with locale codes
[TestCase("Data/Achievements.fr-FR", "Data/Achievements", true, ExpectedResult = true)]
[TestCase("Data/Achievements.fr-FR", "Data/Achievements", false, ExpectedResult = true)]
public bool StartsWith_PartialWord(string mainAssetName, string prefix, bool allowPartialWord)
{
// arrange
mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName);
// act
AssetName name = AssetName.Parse(mainAssetName, _ => null);
// assert value is the same for any combination of options
bool result = name.StartsWith(prefix, allowPartialWord: allowPartialWord, allowSubfolder: true);
name.StartsWith(prefix, allowPartialWord, allowSubfolder: false)
.Should().Be(result, "specifying allowSubfolder should have no effect for these inputs");
// assert value
return result;
}
[Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for the 'allowSubfolder' option.")]
// simple cases
[TestCase("Data/Achievements/Path", "Data/Achievements", true, ExpectedResult = true)]
[TestCase("Data/Achievements/Path", "Data/Achievements", false, ExpectedResult = false)]
[TestCase("Data/Achievements/Path", "Data\\Achievements", true, ExpectedResult = true)]
[TestCase("Data/Achievements/Path", "Data\\Achievements", false, ExpectedResult = false)]
// trailing slash
[TestCase("Data/Achievements/Path", "Data/", true, ExpectedResult = true)]
[TestCase("Data/Achievements/Path", "Data/", false, ExpectedResult = false)]
// normalize slash style
[TestCase("Data/Achievements/Path", "Data\\", true, ExpectedResult = true)]
[TestCase("Data/Achievements/Path", "Data\\", false, ExpectedResult = false)]
[TestCase("Data/Achievements/Path", "Data/\\/", true, ExpectedResult = true)]
[TestCase("Data/Achievements/Path", "Data/\\/", false, ExpectedResult = false)]
// with locale code
[TestCase("Data/Achievements/Path.fr-FR", "Data/Achievements", true, ExpectedResult = true)]
[TestCase("Data/Achievements/Path.fr-FR", "Data/Achievements", false, ExpectedResult = false)]
public bool StartsWith_Subfolder(string mainAssetName, string otherAssetName, bool allowSubfolder)
{
// arrange
mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName);
// act
AssetName name = AssetName.Parse(mainAssetName, _ => null);
// assert value is the same for any combination of options
bool result = name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder);
name.StartsWith(otherAssetName, allowPartialWord: false, allowSubfolder: allowSubfolder)
.Should().Be(result, "specifying allowPartialWord should have no effect for these inputs");
// assert value
return result;
}
/****
** GetHashCode
****/
[Test(Description = $"Assert that {nameof(AssetName.GetHashCode)} generates the same hash code for two asset names which differ only by capitalization.")]
public void GetHashCode_IsCaseInsensitive()
{
// arrange
string left = "data/ACHIEVEMENTS";
string right = "DATA/achievements";
// act
int leftHash = AssetName.Parse(left, _ => null).GetHashCode();
int rightHash = AssetName.Parse(right, _ => null).GetHashCode();
// assert
leftHash.Should().Be(rightHash, "two asset names which differ only by capitalization should produce the same hash code");
}
[Test(Description = $"Assert that {nameof(AssetName.GetHashCode)} generates few hash code collisions for an arbitrary set of asset names.")]
public void GetHashCode_HasFewCollisions()
{
// generate list of names
List<string> names = new();
{
Random random = new();
string characters = "abcdefghijklmnopqrstuvwxyz1234567890/";
while (names.Count < 1000)
{
char[] name = new char[random.Next(5, 20)];
for (int i = 0; i < name.Length; i++)
name[i] = characters[random.Next(0, characters.Length)];
names.Add(new string(name));
}
}
// get distinct hash codes
HashSet<int> hashCodes = new();
foreach (string name in names)
hashCodes.Add(AssetName.Parse(name, _ => null).GetHashCode());
// assert a collision frequency under 0.1%
float collisionFrequency = 1 - (hashCodes.Count / (names.Count * 1f));
collisionFrequency.Should().BeLessOrEqualTo(0.001f, "hash codes should be relatively distinct with a collision rate under 0.1% for a small sample set");
}
}
}

View File

@ -0,0 +1,400 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using FluentAssertions;
using NUnit.Framework;
using SMAPI.Tests.ModApiConsumer;
using SMAPI.Tests.ModApiConsumer.Interfaces;
using SMAPI.Tests.ModApiProvider;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Utilities;
namespace SMAPI.Tests.Core
{
/// <summary>Unit tests for <see cref="InterfaceProxyFactory"/>.</summary>
[TestFixture]
internal class InterfaceProxyTests
{
/*********
** Fields
*********/
/// <summary>The mod ID providing an API.</summary>
private readonly string FromModId = "From.ModId";
/// <summary>The mod ID consuming an API.</summary>
private readonly string ToModId = "From.ModId";
/// <summary>The random number generator with which to create sample values.</summary>
private readonly Random Random = new();
/// <summary>Sample user inputs for season names.</summary>
private static readonly IInterfaceProxyFactory[] ProxyFactories = {
new InterfaceProxyFactory(),
new OriginalInterfaceProxyFactory()
};
/*********
** Unit tests
*********/
/****
** Events
****/
/// <summary>Assert that an event field can be proxied correctly.</summary>
/// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
public void CanProxy_EventField([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
ProviderMod providerMod = new();
object implementation = providerMod.GetModApi();
int expectedValue = this.Random.Next();
// act
ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
new ApiConsumer().UseEventField(proxy, out Func<(int timesCalled, int lastValue)> getValues);
providerMod.RaiseEvent(expectedValue);
(int timesCalled, int lastValue) = getValues();
// assert
timesCalled.Should().Be(1, "Expected the proxied event to be raised once.");
lastValue.Should().Be(expectedValue, "The proxy received a different event argument than the implementation raised.");
}
/// <summary>Assert that an event property can be proxied correctly.</summary>
/// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
public void CanProxy_EventProperty([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
ProviderMod providerMod = new();
object implementation = providerMod.GetModApi();
int expectedValue = this.Random.Next();
// act
ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
new ApiConsumer().UseEventProperty(proxy, out Func<(int timesCalled, int lastValue)> getValues);
providerMod.RaiseEvent(expectedValue);
(int timesCalled, int lastValue) = getValues();
// assert
timesCalled.Should().Be(1, "Expected the proxied event to be raised once.");
lastValue.Should().Be(expectedValue, "The proxy received a different event argument than the implementation raised.");
}
/****
** Properties
****/
/// <summary>Assert that properties can be proxied correctly.</summary>
/// <param name="proxyFactory">The proxy factory to test.</param>
/// <param name="setVia">Whether to set the properties through the <c>provider mod</c> or <c>proxy interface</c>.</param>
[Test]
public void CanProxy_Properties([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory, [Values("set via provider mod", "set via proxy interface")] string setVia)
{
// arrange
ProviderMod providerMod = new();
object implementation = providerMod.GetModApi();
int expectedNumber = this.Random.Next();
int expectedObject = this.Random.Next();
string expectedListValue = this.GetRandomString();
string expectedListWithInterfaceValue = this.GetRandomString();
string expectedDictionaryKey = this.GetRandomString();
string expectedDictionaryListValue = this.GetRandomString();
string expectedInheritedString = this.GetRandomString();
BindingFlags expectedEnum = BindingFlags.Instance | BindingFlags.Public;
// act
ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
switch (setVia)
{
case "set via provider mod":
providerMod.SetPropertyValues(
number: expectedNumber,
obj: expectedObject,
listValue: expectedListValue,
listWithInterfaceValue: expectedListWithInterfaceValue,
dictionaryKey: expectedDictionaryKey,
dictionaryListValue: expectedDictionaryListValue,
enumValue: expectedEnum,
inheritedValue: expectedInheritedString
);
break;
case "set via proxy interface":
proxy.NumberProperty = expectedNumber;
proxy.ObjectProperty = expectedObject;
proxy.ListProperty = new() { expectedListValue };
proxy.ListPropertyWithInterface = new List<string> { expectedListWithInterfaceValue };
proxy.GenericsProperty = new Dictionary<string, IList<string>>
{
[expectedDictionaryKey] = new List<string> { expectedDictionaryListValue }
};
proxy.EnumProperty = expectedEnum;
proxy.InheritedProperty = expectedInheritedString;
break;
default:
throw new InvalidOperationException($"Invalid 'set via' option '{setVia}.");
}
// assert number
this
.GetPropertyValue(implementation, nameof(proxy.NumberProperty))
.Should().Be(expectedNumber);
proxy.NumberProperty
.Should().Be(expectedNumber);
// assert object
this
.GetPropertyValue(implementation, nameof(proxy.ObjectProperty))
.Should().Be(expectedObject);
proxy.ObjectProperty
.Should().Be(expectedObject);
// assert list
(this.GetPropertyValue(implementation, nameof(proxy.ListProperty)) as IList<string>)
.Should().NotBeNull()
.And.HaveCount(1)
.And.BeEquivalentTo(expectedListValue);
proxy.ListProperty
.Should().NotBeNull()
.And.HaveCount(1)
.And.BeEquivalentTo(expectedListValue);
// assert list with interface
(this.GetPropertyValue(implementation, nameof(proxy.ListPropertyWithInterface)) as IList<string>)
.Should().NotBeNull()
.And.HaveCount(1)
.And.BeEquivalentTo(expectedListWithInterfaceValue);
proxy.ListPropertyWithInterface
.Should().NotBeNull()
.And.HaveCount(1)
.And.BeEquivalentTo(expectedListWithInterfaceValue);
// assert generics
(this.GetPropertyValue(implementation, nameof(proxy.GenericsProperty)) as IDictionary<string, IList<string>>)
.Should().NotBeNull()
.And.HaveCount(1)
.And.ContainKey(expectedDictionaryKey).WhoseValue.Should().BeEquivalentTo(expectedDictionaryListValue);
proxy.GenericsProperty
.Should().NotBeNull()
.And.HaveCount(1)
.And.ContainKey(expectedDictionaryKey).WhoseValue.Should().BeEquivalentTo(expectedDictionaryListValue);
// assert enum
this
.GetPropertyValue(implementation, nameof(proxy.EnumProperty))
.Should().Be(expectedEnum);
proxy.EnumProperty
.Should().Be(expectedEnum);
// assert getter
this
.GetPropertyValue(implementation, nameof(proxy.GetterProperty))
.Should().Be(42);
proxy.GetterProperty
.Should().Be(42);
// assert inherited methods
this
.GetPropertyValue(implementation, nameof(proxy.InheritedProperty))
.Should().Be(expectedInheritedString);
proxy.InheritedProperty
.Should().Be(expectedInheritedString);
}
/// <summary>Assert that a simple method with no return value can be proxied correctly.</summary>
/// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
public void CanProxy_SimpleMethod_Void([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
object implementation = new ProviderMod().GetModApi();
// act
ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
proxy.GetNothing();
}
/// <summary>Assert that a simple int method can be proxied correctly.</summary>
/// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
public void CanProxy_SimpleMethod_Int([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
object implementation = new ProviderMod().GetModApi();
int expectedValue = this.Random.Next();
// act
ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
int actualValue = proxy.GetInt(expectedValue);
// assert
actualValue.Should().Be(expectedValue);
}
/// <summary>Assert that a simple object method can be proxied correctly.</summary>
/// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
public void CanProxy_SimpleMethod_Object([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
object implementation = new ProviderMod().GetModApi();
object expectedValue = new();
// act
ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
object actualValue = proxy.GetObject(expectedValue);
// assert
actualValue.Should().BeSameAs(expectedValue);
}
/// <summary>Assert that a simple list method can be proxied correctly.</summary>
/// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
public void CanProxy_SimpleMethod_List([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
object implementation = new ProviderMod().GetModApi();
string expectedValue = this.GetRandomString();
// act
ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
IList<string> actualValue = proxy.GetList(expectedValue);
// assert
actualValue.Should().BeEquivalentTo(expectedValue);
}
/// <summary>Assert that a simple list with interface method can be proxied correctly.</summary>
/// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
public void CanProxy_SimpleMethod_ListWithInterface([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
object implementation = new ProviderMod().GetModApi();
string expectedValue = this.GetRandomString();
// act
ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
IList<string> actualValue = proxy.GetListWithInterface(expectedValue);
// assert
actualValue.Should().BeEquivalentTo(expectedValue);
}
/// <summary>Assert that a simple method which returns generic types can be proxied correctly.</summary>
/// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
public void CanProxy_SimpleMethod_GenericTypes([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
object implementation = new ProviderMod().GetModApi();
string expectedKey = this.GetRandomString();
string expectedValue = this.GetRandomString();
// act
ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
IDictionary<string, IList<string>> actualValue = proxy.GetGenerics(expectedKey, expectedValue);
// assert
actualValue
.Should().NotBeNull()
.And.HaveCount(1)
.And.ContainKey(expectedKey).WhoseValue.Should().BeEquivalentTo(expectedValue);
}
/// <summary>Assert that a simple lambda method can be proxied correctly.</summary>
/// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
[SuppressMessage("ReSharper", "ConvertToLocalFunction")]
public void CanProxy_SimpleMethod_Lambda([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
object implementation = new ProviderMod().GetModApi();
Func<string, string> expectedValue = _ => "test";
// act
ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
object actualValue = proxy.GetObject(expectedValue);
// assert
actualValue.Should().BeSameAs(expectedValue);
}
/// <summary>Assert that a method with out parameters can be proxied correctly.</summary>
/// <param name="proxyFactory">The proxy factory to test.</param>
[Test]
[SuppressMessage("ReSharper", "ConvertToLocalFunction")]
public void CanProxy_Method_OutParameters([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory)
{
// arrange
object implementation = new ProviderMod().GetModApi();
const int expectedNumber = 42;
// act
ISimpleApi proxy = this.GetProxy(proxyFactory, implementation);
bool result = proxy.TryGetOutParameter(
inputNumber: expectedNumber,
out int outNumber,
out string outString,
out PerScreen<int> outReference,
out IDictionary<int, PerScreen<int>> outComplexType
);
// assert
result.Should().BeTrue();
outNumber.Should().Be(expectedNumber);
outString.Should().Be(expectedNumber.ToString());
outReference.Should().NotBeNull();
outReference.Value.Should().Be(expectedNumber);
outComplexType.Should().NotBeNull();
outComplexType.Count.Should().Be(1);
outComplexType.Keys.First().Should().Be(expectedNumber);
outComplexType.Values.First().Should().NotBeNull();
outComplexType.Values.First().Value.Should().Be(expectedNumber);
}
/*********
** Private methods
*********/
/// <summary>Get a property value from an instance.</summary>
/// <param name="parent">The instance whose property to read.</param>
/// <param name="name">The property name.</param>
private object? GetPropertyValue(object parent, string name)
{
if (parent is null)
throw new ArgumentNullException(nameof(parent));
Type type = parent.GetType();
PropertyInfo? property = type.GetProperty(name);
if (property is null)
throw new InvalidOperationException($"The '{type.FullName}' type has no public property named '{name}'.");
return property.GetValue(parent);
}
/// <summary>Get a random test string.</summary>
private string GetRandomString()
{
return this.Random.Next().ToString();
}
/// <summary>Get a proxy API instance.</summary>
/// <param name="proxyFactory">The proxy factory to use.</param>
/// <param name="implementation">The underlying API instance.</param>
private ISimpleApi GetProxy(IInterfaceProxyFactory proxyFactory, object implementation)
{
return proxyFactory.CreateProxy<ISimpleApi>(implementation, this.FromModId, this.ToModId);
}
}
}

View File

@ -38,6 +38,9 @@ namespace SMAPI.Tests.Core
// assert
Assert.AreEqual(0, mods.Length, 0, $"Expected to find zero manifests, found {mods.Length} instead.");
// cleanup
Directory.Delete(rootFolder, recursive: true);
}
[Test(Description = "Assert that the resolver correctly returns a failed metadata if there's an empty mod folder.")]
@ -50,12 +53,15 @@ namespace SMAPI.Tests.Core
// act
IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray();
IModMetadata mod = mods.FirstOrDefault();
IModMetadata? mod = mods.FirstOrDefault();
// assert
Assert.AreEqual(1, mods.Length, 0, $"Expected to find one manifest, found {mods.Length} instead.");
Assert.AreEqual(ModMetadataStatus.Failed, mod.Status, "The mod metadata was not marked failed.");
Assert.AreEqual(ModMetadataStatus.Failed, mod!.Status, "The mod metadata was not marked failed.");
Assert.IsNotNull(mod.Error, "The mod metadata did not have an error message set.");
// cleanup
Directory.Delete(rootFolder, recursive: true);
}
[Test(Description = "Assert that the resolver correctly reads manifest data from a randomized file.")]
@ -89,12 +95,12 @@ namespace SMAPI.Tests.Core
// act
IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray();
IModMetadata mod = mods.FirstOrDefault();
IModMetadata? mod = mods.FirstOrDefault();
// assert
Assert.AreEqual(1, mods.Length, 0, "Expected to find one manifest.");
Assert.IsNotNull(mod, "The loaded manifest shouldn't be null.");
Assert.AreEqual(null, mod.DataRecord, "The data record should be null since we didn't provide one.");
Assert.AreEqual(null, mod!.DataRecord, "The data record should be null since we didn't provide one.");
Assert.AreEqual(modFolder, mod.DirectoryPath, "The directory path doesn't match.");
Assert.AreEqual(null, mod.Error, "The error should be null since parsing should have succeeded.");
Assert.AreEqual(ModMetadataStatus.Found, mod.Status, "The status doesn't match.");
@ -115,6 +121,9 @@ namespace SMAPI.Tests.Core
Assert.IsNotNull(mod.Manifest.Dependencies, "The dependencies field should not be null.");
Assert.AreEqual(1, mod.Manifest.Dependencies.Length, "The dependencies field should contain one value.");
Assert.AreEqual(originalDependency[nameof(IManifestDependency.UniqueID)], mod.Manifest.Dependencies[0].UniqueID, "The first dependency's unique ID doesn't match.");
// cleanup
Directory.Delete(rootFolder, recursive: true);
}
/****
@ -123,7 +132,7 @@ namespace SMAPI.Tests.Core
[Test(Description = "Assert that validation doesn't fail if there are no mods installed.")]
public void ValidateManifests_NoMods_DoesNothing()
{
new ModResolver().ValidateManifests(new ModMetadata[0], apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null);
new ModResolver().ValidateManifests(Array.Empty<ModMetadata>(), apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false);
}
[Test(Description = "Assert that validation skips manifests that have already failed without calling any other properties.")]
@ -134,7 +143,7 @@ namespace SMAPI.Tests.Core
mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed);
// act
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null);
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false);
// assert
mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status.");
@ -144,14 +153,14 @@ namespace SMAPI.Tests.Core
public void ValidateManifests_ModStatus_AssumeBroken_Fails()
{
// arrange
Mock<IModMetadata> mock = this.GetMetadata("Mod A", new string[0], allowStatusChange: true);
this.SetupMetadataForValidation(mock, new ModDataRecordVersionedFields
Mock<IModMetadata> mock = this.GetMetadata("Mod A", Array.Empty<string>(), allowStatusChange: true);
mock.Setup(p => p.DataRecord).Returns(() => new ModDataRecordVersionedFields(this.GetModDataRecord())
{
Status = ModStatus.AssumeBroken
});
// act
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null);
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false);
// assert
mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the metadata.");
@ -161,12 +170,11 @@ namespace SMAPI.Tests.Core
public void ValidateManifests_MinimumApiVersion_Fails()
{
// arrange
Mock<IModMetadata> mock = this.GetMetadata("Mod A", new string[0], allowStatusChange: true);
Mock<IModMetadata> mock = this.GetMetadata("Mod A", Array.Empty<string>(), allowStatusChange: true);
mock.Setup(p => p.Manifest).Returns(this.GetManifest(minimumApiVersion: "1.1"));
this.SetupMetadataForValidation(mock);
// act
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null);
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false);
// assert
mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the metadata.");
@ -176,32 +184,33 @@ namespace SMAPI.Tests.Core
public void ValidateManifests_MissingEntryDLL_Fails()
{
// arrange
Mock<IModMetadata> mock = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0", entryDll: "Missing.dll"), allowStatusChange: true);
this.SetupMetadataForValidation(mock);
string directoryPath = this.GetTempFolderPath();
Mock<IModMetadata> mock = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0", entryDll: "Missing.dll"), allowStatusChange: true, directoryPath: directoryPath);
Directory.CreateDirectory(directoryPath);
// act
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null);
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null);
// assert
mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the metadata.");
// cleanup
Directory.Delete(directoryPath);
}
[Test(Description = "Assert that validation fails when multiple mods have the same unique ID.")]
public void ValidateManifests_DuplicateUniqueID_Fails()
{
// arrange
Mock<IModMetadata> modA = this.GetMetadata("Mod A", new string[0], allowStatusChange: true);
Mock<IModMetadata> modA = this.GetMetadata("Mod A", Array.Empty<string>(), allowStatusChange: true);
Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest(id: "Mod A", name: "Mod B", version: "1.0"), allowStatusChange: true);
Mock<IModMetadata> modC = this.GetMetadata("Mod C", new string[0], allowStatusChange: false);
foreach (Mock<IModMetadata> mod in new[] { modA, modB, modC })
this.SetupMetadataForValidation(mod);
// act
new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null);
new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false);
// assert
modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the first mod with a unique ID.");
modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the second mod with a unique ID.");
modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, It.IsAny<string>(), It.IsAny<string>()), Times.AtLeastOnce, "The validation did not fail the first mod with a unique ID.");
modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, It.IsAny<string>(), It.IsAny<string>()), Times.AtLeastOnce, "The validation did not fail the second mod with a unique ID.");
}
[Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")]
@ -213,20 +222,23 @@ namespace SMAPI.Tests.Core
// create DLL
string modFolder = Path.Combine(this.GetTempFolderPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(modFolder);
File.WriteAllText(Path.Combine(modFolder, manifest.EntryDll), "");
File.WriteAllText(Path.Combine(modFolder, manifest.EntryDll!), "");
// arrange
Mock<IModMetadata> mock = new Mock<IModMetadata>(MockBehavior.Strict);
Mock<IModMetadata> mock = new(MockBehavior.Strict);
mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found);
mock.Setup(p => p.DataRecord).Returns(() => null);
mock.Setup(p => p.DataRecord).Returns(this.GetModDataRecordVersionedFields());
mock.Setup(p => p.Manifest).Returns(manifest);
mock.Setup(p => p.DirectoryPath).Returns(modFolder);
// act
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null);
new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null);
// assert
// if Moq doesn't throw a method-not-setup exception, the validation didn't override the status.
// cleanup
Directory.Delete(modFolder, recursive: true);
}
/****
@ -236,7 +248,7 @@ namespace SMAPI.Tests.Core
public void ProcessDependencies_NoMods_DoesNothing()
{
// act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new IModMetadata[0], new ModDatabase()).ToArray();
IModMetadata[] mods = new ModResolver().ProcessDependencies(Array.Empty<IModMetadata>(), new ModDatabase()).ToArray();
// assert
Assert.AreEqual(0, mods.Length, 0, "Expected to get an empty list of mods.");
@ -265,7 +277,7 @@ namespace SMAPI.Tests.Core
public void ProcessDependencies_Skips_Failed()
{
// arrange
Mock<IModMetadata> mock = new Mock<IModMetadata>(MockBehavior.Strict);
Mock<IModMetadata> mock = new(MockBehavior.Strict);
mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed);
// act
@ -380,7 +392,7 @@ namespace SMAPI.Tests.Core
Mock<IModMetadata> modA = this.GetMetadata("Mod A");
Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }, allowStatusChange: true);
Mock<IModMetadata> modD = new Mock<IModMetadata>(MockBehavior.Strict);
Mock<IModMetadata> modD = new(MockBehavior.Strict);
modD.Setup(p => p.Manifest).Returns<IManifest>(null);
modD.Setup(p => p.Status).Returns(ModMetadataStatus.Failed);
@ -478,21 +490,20 @@ namespace SMAPI.Tests.Core
/// <param name="contentPackForID">The <see cref="IManifest.ContentPackFor"/> value.</param>
/// <param name="minimumApiVersion">The <see cref="IManifest.MinimumApiVersion"/> value.</param>
/// <param name="dependencies">The <see cref="IManifest.Dependencies"/> value.</param>
private Manifest GetManifest(string id = null, string name = null, string version = null, string entryDll = null, string contentPackForID = null, string minimumApiVersion = null, IManifestDependency[] dependencies = null)
private Manifest GetManifest(string? id = null, string? name = null, string? version = null, string? entryDll = null, string? contentPackForID = null, string? minimumApiVersion = null, IManifestDependency[]? dependencies = null)
{
return new Manifest
{
UniqueID = id ?? $"{Sample.String()}.{Sample.String()}",
Name = name ?? id ?? Sample.String(),
Author = Sample.String(),
Description = Sample.String(),
Version = version != null ? new SemanticVersion(version) : new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()),
EntryDll = entryDll ?? $"{Sample.String()}.dll",
ContentPackFor = contentPackForID != null ? new ManifestContentPackFor { UniqueID = contentPackForID } : null,
MinimumApiVersion = minimumApiVersion != null ? new SemanticVersion(minimumApiVersion) : null,
Dependencies = dependencies ?? new IManifestDependency[0],
UpdateKeys = new string[0]
};
return new Manifest(
uniqueId: id ?? $"{Sample.String()}.{Sample.String()}",
name: name ?? id ?? Sample.String(),
author: Sample.String(),
description: Sample.String(),
version: version != null ? new SemanticVersion(version) : new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()),
entryDll: entryDll ?? $"{Sample.String()}.dll",
contentPackFor: contentPackForID != null ? new ManifestContentPackFor(contentPackForID, null) : null,
minimumApiVersion: minimumApiVersion != null ? new SemanticVersion(minimumApiVersion) : null,
dependencies: dependencies ?? Array.Empty<IManifestDependency>(),
updateKeys: Array.Empty<string>()
);
}
/// <summary>Get a randomized basic manifest.</summary>
@ -508,21 +519,27 @@ namespace SMAPI.Tests.Core
/// <param name="allowStatusChange">Whether the code being tested is allowed to change the mod status.</param>
private Mock<IModMetadata> GetMetadata(string uniqueID, string[] dependencies, bool allowStatusChange = false)
{
IManifest manifest = this.GetManifest(id: uniqueID, version: "1.0", dependencies: dependencies?.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID, null)).ToArray());
IManifest manifest = this.GetManifest(id: uniqueID, version: "1.0", dependencies: dependencies.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID, null as ISemanticVersion)).ToArray());
return this.GetMetadata(manifest, allowStatusChange);
}
/// <summary>Get a randomized basic manifest.</summary>
/// <param name="manifest">The mod manifest.</param>
/// <param name="allowStatusChange">Whether the code being tested is allowed to change the mod status.</param>
private Mock<IModMetadata> GetMetadata(IManifest manifest, bool allowStatusChange = false)
/// <param name="directoryPath">The directory path the mod metadata should be pointed at, or <c>null</c> to generate a fake path.</param>
private Mock<IModMetadata> GetMetadata(IManifest manifest, bool allowStatusChange = false, string? directoryPath = null)
{
Mock<IModMetadata> mod = new Mock<IModMetadata>(MockBehavior.Strict);
mod.Setup(p => p.DataRecord).Returns(() => null);
directoryPath ??= this.GetTempFolderPath();
Mock<IModMetadata> mod = new(MockBehavior.Strict);
mod.Setup(p => p.DataRecord).Returns(this.GetModDataRecordVersionedFields());
mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found);
mod.Setup(p => p.DisplayName).Returns(manifest.UniqueID);
mod.Setup(p => p.DirectoryPath).Returns(directoryPath);
mod.Setup(p => p.Manifest).Returns(manifest);
mod.Setup(p => p.HasID(It.IsAny<string>())).Returns((string id) => manifest.UniqueID == id);
mod.Setup(p => p.GetUpdateKeys(It.IsAny<bool>())).Returns(Enumerable.Empty<UpdateKey>());
mod.Setup(p => p.GetRelativePathWithRoot()).Returns(directoryPath);
if (allowStatusChange)
{
mod
@ -533,17 +550,16 @@ namespace SMAPI.Tests.Core
return mod;
}
/// <summary>Set up a mock mod metadata for <see cref="ModResolver.ValidateManifests"/>.</summary>
/// <param name="mod">The mock mod metadata.</param>
/// <param name="modRecord">The extra metadata about the mod from SMAPI's internal data (if any).</param>
private void SetupMetadataForValidation(Mock<IModMetadata> mod, ModDataRecordVersionedFields modRecord = null)
/// <summary>Generate a default mod data record.</summary>
private ModDataRecord GetModDataRecord()
{
mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found);
mod.Setup(p => p.DataRecord).Returns(() => null);
mod.Setup(p => p.Manifest).Returns(this.GetManifest());
mod.Setup(p => p.DirectoryPath).Returns(Path.GetTempPath());
mod.Setup(p => p.DataRecord).Returns(modRecord);
mod.Setup(p => p.GetUpdateKeys(It.IsAny<bool>())).Returns(Enumerable.Empty<UpdateKey>());
return new("Default Display Name", new ModDataModel("Sample ID", null, ModWarning.None));
}
/// <summary>Generate a default mod data versioned fields instance.</summary>
private ModDataRecordVersionedFields GetModDataRecordVersionedFields()
{
return new ModDataRecordVersionedFields(this.GetModDataRecord());
}
}
}

View File

@ -1,9 +1,14 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using NUnit.Framework;
using StardewModdingAPI;
using StardewModdingAPI.Framework;
using StardewModdingAPI.Framework.ModHelpers;
using StardewModdingAPI.Framework.ModLoading;
using StardewModdingAPI.Toolkit.Serialization.Models;
using StardewValley;
namespace SMAPI.Tests.Core
@ -16,7 +21,7 @@ namespace SMAPI.Tests.Core
** Data
*********/
/// <summary>Sample translation text for unit tests.</summary>
public static string[] Samples = { null, "", " ", "boop", " boop " };
public static string?[] Samples = { null, "", " ", "boop", " boop " };
/*********
@ -32,15 +37,15 @@ namespace SMAPI.Tests.Core
var data = new Dictionary<string, IDictionary<string, string>>();
// act
ITranslationHelper helper = new TranslationHelper("ModID", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data);
ITranslationHelper helper = new TranslationHelper(this.CreateModMetadata(), "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data);
Translation translation = helper.Get("key");
Translation[] translationList = helper.GetTranslations()?.ToArray();
Translation[]? translationList = helper.GetTranslations()?.ToArray();
// assert
Assert.AreEqual("en", helper.Locale, "The locale doesn't match the input value.");
Assert.AreEqual(LocalizedContentManager.LanguageCode.en, helper.LocaleEnum, "The locale enum doesn't match the input value.");
Assert.IsNotNull(translationList, "The full list of translations is unexpectedly null.");
Assert.AreEqual(0, translationList.Length, "The full list of translations is unexpectedly not empty.");
Assert.AreEqual(0, translationList!.Length, "The full list of translations is unexpectedly not empty.");
Assert.IsNotNull(translation, "The translation helper unexpectedly returned a null translation.");
Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value.");
@ -54,8 +59,8 @@ namespace SMAPI.Tests.Core
var expected = this.GetExpectedTranslations();
// act
var actual = new Dictionary<string, Translation[]>();
TranslationHelper helper = new TranslationHelper("ModID", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data);
var actual = new Dictionary<string, Translation[]?>();
TranslationHelper helper = new TranslationHelper(this.CreateModMetadata(), "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data);
foreach (string locale in expected.Keys)
{
this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en);
@ -79,7 +84,7 @@ namespace SMAPI.Tests.Core
// act
var actual = new Dictionary<string, Translation[]>();
TranslationHelper helper = new TranslationHelper("ModID", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data);
TranslationHelper helper = new TranslationHelper(this.CreateModMetadata(), "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data);
foreach (string locale in expected.Keys)
{
this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en);
@ -107,16 +112,16 @@ namespace SMAPI.Tests.Core
[TestCase(" ", ExpectedResult = true)]
[TestCase("boop", ExpectedResult = true)]
[TestCase(" boop ", ExpectedResult = true)]
public bool Translation_HasValue(string text)
public bool Translation_HasValue(string? text)
{
return new Translation("pt-BR", "key", text).HasValue();
}
[Test(Description = "Assert that the translation's ToString method returns the expected text for various inputs.")]
public void Translation_ToString([ValueSource(nameof(TranslationTests.Samples))] string text)
public void Translation_ToString([ValueSource(nameof(TranslationTests.Samples))] string? text)
{
// act
Translation translation = new Translation("pt-BR", "key", text);
Translation translation = new("pt-BR", "key", text);
// assert
if (translation.HasValue())
@ -126,20 +131,20 @@ namespace SMAPI.Tests.Core
}
[Test(Description = "Assert that the translation's implicit string conversion returns the expected text for various inputs.")]
public void Translation_ImplicitStringConversion([ValueSource(nameof(TranslationTests.Samples))] string text)
public void Translation_ImplicitStringConversion([ValueSource(nameof(TranslationTests.Samples))] string? text)
{
// act
Translation translation = new Translation("pt-BR", "key", text);
Translation translation = new("pt-BR", "key", text);
// assert
if (translation.HasValue())
Assert.AreEqual(text, (string)translation, "The translation returned an unexpected value given a valid input.");
Assert.AreEqual(text, (string?)translation, "The translation returned an unexpected value given a valid input.");
else
Assert.AreEqual(this.GetPlaceholderText("key"), (string)translation, "The translation returned an unexpected value given a null or empty input.");
Assert.AreEqual(this.GetPlaceholderText("key"), (string?)translation, "The translation returned an unexpected value given a null or empty input.");
}
[Test(Description = "Assert that the translation returns the expected text given a use-placeholder setting.")]
public void Translation_UsePlaceholder([Values(true, false)] bool value, [ValueSource(nameof(TranslationTests.Samples))] string text)
public void Translation_UsePlaceholder([Values(true, false)] bool value, [ValueSource(nameof(TranslationTests.Samples))] string? text)
{
// act
Translation translation = new Translation("pt-BR", "key", text).UsePlaceholder(value);
@ -154,7 +159,7 @@ namespace SMAPI.Tests.Core
}
[Test(Description = "Assert that the translation returns the expected text after setting the default.")]
public void Translation_Default([ValueSource(nameof(TranslationTests.Samples))] string text, [ValueSource(nameof(TranslationTests.Samples))] string @default)
public void Translation_Default([ValueSource(nameof(TranslationTests.Samples))] string? text, [ValueSource(nameof(TranslationTests.Samples))] string? @default)
{
// act
Translation translation = new Translation("pt-BR", "key", text).Default(@default);
@ -182,7 +187,7 @@ namespace SMAPI.Tests.Core
string expected = $"{start} tokens are properly replaced (including {middle} {middle}) {end}";
// act
Translation translation = new Translation("pt-BR", "key", input);
Translation translation = new("pt-BR", "key", input);
switch (structure)
{
case "anonymous object":
@ -190,7 +195,7 @@ namespace SMAPI.Tests.Core
break;
case "class":
translation = translation.Tokens(new TokenModel { Start = start, Middle = middle, End = end });
translation = translation.Tokens(new TokenModel(start, middle, end));
break;
case "IDictionary<string, object>":
@ -324,21 +329,63 @@ namespace SMAPI.Tests.Core
return string.Format(Translation.PlaceholderText, key);
}
/// <summary>Create a fake mod manifest.</summary>
private IModMetadata CreateModMetadata()
{
string id = $"smapi.unit-tests.fake-mod-{Guid.NewGuid():N}";
string tempPath = Path.Combine(Path.GetTempPath(), id);
return new ModMetadata(
displayName: "Mod Display Name",
directoryPath: tempPath,
rootPath: tempPath,
manifest: new Manifest(
uniqueID: id,
name: "Mod Name",
author: "Mod Author",
description: "Mod Description",
version: new SemanticVersion(1, 0, 0)
),
dataRecord: null,
isIgnored: false
);
}
/*********
** Test models
*********/
/// <summary>A model used to test token support.</summary>
[SuppressMessage("ReSharper", "NotAccessedField.Local", Justification = "Used dynamically via translation helper.")]
[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used dynamically via translation helper.")]
private class TokenModel
{
/*********
** Accessors
*********/
/// <summary>A sample token property.</summary>
public string Start { get; set; }
public string Start { get; }
/// <summary>A sample token property.</summary>
public string Middle { get; set; }
public string Middle { get; }
/// <summary>A sample token field.</summary>
public string End;
/*********
** public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="start">A sample token property.</param>
/// <param name="middle">A sample token field.</param>
/// <param name="end">A sample token property.</param>
public TokenModel(string start, string middle, string end)
{
this.Start = start;
this.Middle = middle;
this.End = end;
}
}
}
}

View File

@ -1,21 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>SMAPI.Tests</AssemblyName>
<RootNamespace>SMAPI.Tests</RootNamespace>
<TargetFramework>net5.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<Import Project="..\..\build\common.targets" />
<ItemGroup>
<ProjectReference Include="..\SMAPI.Tests.ModApiConsumer\SMAPI.Tests.ModApiConsumer.csproj" />
<ProjectReference Include="..\SMAPI.Tests.ModApiProvider\SMAPI.Tests.ModApiProvider.csproj" />
<ProjectReference Include="..\SMAPI.Toolkit.CoreInterfaces\SMAPI.Toolkit.CoreInterfaces.csproj" />
<ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" />
<ProjectReference Include="..\SMAPI\SMAPI.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.5.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />

View File

@ -9,7 +9,7 @@ namespace SMAPI.Tests
** Fields
*********/
/// <summary>A random number generator.</summary>
private static readonly Random Random = new Random();
private static readonly Random Random = new();
/*********

View File

@ -21,12 +21,12 @@ namespace SMAPI.Tests.Utilities
public void TryParse_SimpleValue(SButton button)
{
// act
bool success = KeybindList.TryParse($"{button}", out KeybindList parsed, out string[] errors);
bool success = KeybindList.TryParse($"{button}", out KeybindList? parsed, out string[] errors);
// assert
Assert.IsTrue(success, "Parsing unexpectedly failed.");
Assert.IsNotNull(parsed, "The parsed result should not be null.");
Assert.AreEqual(parsed.ToString(), $"{button}");
Assert.AreEqual(parsed!.ToString(), $"{button}");
Assert.IsNotNull(errors, message: "The errors should never be null.");
Assert.IsEmpty(errors, message: "The input bindings incorrectly reported errors.");
}
@ -44,17 +44,17 @@ namespace SMAPI.Tests.Utilities
[TestCase(",", ExpectedResult = "None")]
[TestCase("A,", ExpectedResult = "A")]
[TestCase(",A", ExpectedResult = "A")]
public string TryParse_MultiValues(string input)
public string TryParse_MultiValues(string? input)
{
// act
bool success = KeybindList.TryParse(input, out KeybindList parsed, out string[] errors);
bool success = KeybindList.TryParse(input, out KeybindList? parsed, out string[] errors);
// assert
Assert.IsTrue(success, "Parsing unexpectedly failed.");
Assert.IsNotNull(parsed, "The parsed result should not be null.");
Assert.IsNotNull(errors, message: "The errors should never be null.");
Assert.IsEmpty(errors, message: "The input bindings incorrectly reported errors.");
return parsed.ToString();
return parsed!.ToString();
}
/// <summary>Assert invalid values are rejected.</summary>
@ -67,7 +67,7 @@ namespace SMAPI.Tests.Utilities
public void TryParse_InvalidValues(string input, string expectedError)
{
// act
bool success = KeybindList.TryParse(input, out KeybindList parsed, out string[] errors);
bool success = KeybindList.TryParse(input, out KeybindList? parsed, out string[] errors);
// assert
Assert.IsFalse(success, "Parsing unexpectedly succeeded.");
@ -98,21 +98,23 @@ namespace SMAPI.Tests.Utilities
public SButtonState GetState(string input, string stateMap)
{
// act
bool success = KeybindList.TryParse(input, out KeybindList parsed, out string[] errors);
bool success = KeybindList.TryParse(input, out KeybindList? parsed, out string[] errors);
if (success && parsed?.Keybinds != null)
{
foreach (var keybind in parsed.Keybinds)
foreach (Keybind? keybind in parsed.Keybinds)
{
#pragma warning disable 618 // method is marked obsolete because it should only be used in unit tests
keybind.GetButtonState = key => this.GetStateFromMap(key, stateMap);
#pragma warning restore 618
}
}
// assert
Assert.IsTrue(success, "Parsing unexpected failed");
Assert.IsNotNull(parsed, "The parsed result should not be null.");
Assert.IsNotNull(errors, message: "The errors should never be null.");
Assert.IsEmpty(errors, message: "The input bindings incorrectly reported errors.");
return parsed.GetState();
return parsed!.GetState();
}

View File

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.IO;
using NUnit.Framework;
using StardewModdingAPI.Toolkit.Utilities;
@ -6,6 +7,7 @@ namespace SMAPI.Tests.Utilities
{
/// <summary>Unit tests for <see cref="PathUtilities"/>.</summary>
[TestFixture]
[SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These are standard game install paths.")]
internal class PathUtilitiesTests
{
/*********
@ -14,136 +16,125 @@ namespace SMAPI.Tests.Utilities
/// <summary>Sample paths used in unit tests.</summary>
public static readonly SamplePath[] SamplePaths = {
// Windows absolute path
new SamplePath
{
OriginalPath = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley",
new(
OriginalPath: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley",
Segments = new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" },
SegmentsLimit3 = new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley" },
Segments: new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" },
SegmentsLimit3: new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley" },
NormalizedOnWindows = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley",
NormalizedOnUnix = @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley"
},
NormalizedOnWindows: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley",
NormalizedOnUnix: @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley"
),
// Windows absolute path (with trailing slash)
new SamplePath
{
OriginalPath = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\",
new(
OriginalPath: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\",
Segments = new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" },
SegmentsLimit3 = new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley\" },
Segments: new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" },
SegmentsLimit3: new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley\" },
NormalizedOnWindows = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\",
NormalizedOnUnix = @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley/"
},
NormalizedOnWindows: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\",
NormalizedOnUnix: @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley/"
),
// Windows relative path
new SamplePath
{
OriginalPath = @"Content\Characters\Dialogue\Abigail",
new(
OriginalPath: @"Content\Characters\Dialogue\Abigail",
Segments = new [] { "Content", "Characters", "Dialogue", "Abigail" },
SegmentsLimit3 = new [] { "Content", "Characters", @"Dialogue\Abigail" },
Segments: new [] { "Content", "Characters", "Dialogue", "Abigail" },
SegmentsLimit3: new [] { "Content", "Characters", @"Dialogue\Abigail" },
NormalizedOnWindows = @"Content\Characters\Dialogue\Abigail",
NormalizedOnUnix = @"Content/Characters/Dialogue/Abigail"
},
NormalizedOnWindows: @"Content\Characters\Dialogue\Abigail",
NormalizedOnUnix: @"Content/Characters/Dialogue/Abigail"
),
// Windows relative path (with directory climbing)
new SamplePath
{
OriginalPath = @"..\..\Content",
new(
OriginalPath: @"..\..\Content",
Segments = new [] { "..", "..", "Content" },
SegmentsLimit3 = new [] { "..", "..", "Content" },
Segments: new [] { "..", "..", "Content" },
SegmentsLimit3: new [] { "..", "..", "Content" },
NormalizedOnWindows = @"..\..\Content",
NormalizedOnUnix = @"../../Content"
},
NormalizedOnWindows: @"..\..\Content",
NormalizedOnUnix: @"../../Content"
),
// Windows UNC path
new SamplePath
{
OriginalPath = @"\\unc\path",
new(
OriginalPath: @"\\unc\path",
Segments = new [] { "unc", "path" },
SegmentsLimit3 = new [] { "unc", "path" },
Segments: new [] { "unc", "path" },
SegmentsLimit3: new [] { "unc", "path" },
NormalizedOnWindows = @"\\unc\path",
NormalizedOnUnix = "/unc/path" // there's no good way to normalize this on Unix since UNC paths aren't supported; path normalization is meant for asset names anyway, so this test only ensures it returns some sort of sane value
},
NormalizedOnWindows: @"\\unc\path",
NormalizedOnUnix: "/unc/path" // there's no good way to normalize this on Unix since UNC paths aren't supported; path normalization is meant for asset names anyway, so this test only ensures it returns some sort of sane value
),
// Linux absolute path
new SamplePath
{
OriginalPath = @"/home/.steam/steam/steamapps/common/Stardew Valley",
new(
OriginalPath: @"/home/.steam/steam/steamapps/common/Stardew Valley",
Segments = new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" },
SegmentsLimit3 = new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley" },
Segments: new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" },
SegmentsLimit3: new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley" },
NormalizedOnWindows = @"\home\.steam\steam\steamapps\common\Stardew Valley",
NormalizedOnUnix = @"/home/.steam/steam/steamapps/common/Stardew Valley"
},
NormalizedOnWindows: @"\home\.steam\steam\steamapps\common\Stardew Valley",
NormalizedOnUnix: @"/home/.steam/steam/steamapps/common/Stardew Valley"
),
// Linux absolute path (with trailing slash)
new SamplePath
{
OriginalPath = @"/home/.steam/steam/steamapps/common/Stardew Valley/",
new(
OriginalPath: @"/home/.steam/steam/steamapps/common/Stardew Valley/",
Segments = new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" },
SegmentsLimit3 = new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley/" },
Segments: new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" },
SegmentsLimit3: new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley/" },
NormalizedOnWindows = @"\home\.steam\steam\steamapps\common\Stardew Valley\",
NormalizedOnUnix = @"/home/.steam/steam/steamapps/common/Stardew Valley/"
},
NormalizedOnWindows: @"\home\.steam\steam\steamapps\common\Stardew Valley\",
NormalizedOnUnix: @"/home/.steam/steam/steamapps/common/Stardew Valley/"
),
// Linux absolute path (with ~)
new SamplePath
{
OriginalPath = @"~/.steam/steam/steamapps/common/Stardew Valley",
new(
OriginalPath: @"~/.steam/steam/steamapps/common/Stardew Valley",
Segments = new [] { "~", ".steam", "steam", "steamapps", "common", "Stardew Valley" },
SegmentsLimit3 = new [] { "~", ".steam", "steam/steamapps/common/Stardew Valley" },
Segments: new [] { "~", ".steam", "steam", "steamapps", "common", "Stardew Valley" },
SegmentsLimit3: new [] { "~", ".steam", "steam/steamapps/common/Stardew Valley" },
NormalizedOnWindows = @"~\.steam\steam\steamapps\common\Stardew Valley",
NormalizedOnUnix = @"~/.steam/steam/steamapps/common/Stardew Valley"
},
NormalizedOnWindows: @"~\.steam\steam\steamapps\common\Stardew Valley",
NormalizedOnUnix: @"~/.steam/steam/steamapps/common/Stardew Valley"
),
// Linux relative path
new SamplePath
{
OriginalPath = @"Content/Characters/Dialogue/Abigail",
new(
OriginalPath: @"Content/Characters/Dialogue/Abigail",
Segments = new [] { "Content", "Characters", "Dialogue", "Abigail" },
SegmentsLimit3 = new [] { "Content", "Characters", "Dialogue/Abigail" },
Segments: new [] { "Content", "Characters", "Dialogue", "Abigail" },
SegmentsLimit3: new [] { "Content", "Characters", "Dialogue/Abigail" },
NormalizedOnWindows = @"Content\Characters\Dialogue\Abigail",
NormalizedOnUnix = @"Content/Characters/Dialogue/Abigail"
},
NormalizedOnWindows: @"Content\Characters\Dialogue\Abigail",
NormalizedOnUnix: @"Content/Characters/Dialogue/Abigail"
),
// Linux relative path (with directory climbing)
new SamplePath
{
OriginalPath = @"../../Content",
new(
OriginalPath: @"../../Content",
Segments = new [] { "..", "..", "Content" },
SegmentsLimit3 = new [] { "..", "..", "Content" },
Segments: new [] { "..", "..", "Content" },
SegmentsLimit3: new [] { "..", "..", "Content" },
NormalizedOnWindows = @"..\..\Content",
NormalizedOnUnix = @"../../Content"
},
NormalizedOnWindows: @"..\..\Content",
NormalizedOnUnix: @"../../Content"
),
// Mixed directory separators
new SamplePath
{
OriginalPath = @"C:\some/mixed\path/separators",
new(
OriginalPath: @"C:\some/mixed\path/separators",
Segments = new [] { "C:", "some", "mixed", "path", "separators" },
SegmentsLimit3 = new [] { "C:", "some", @"mixed\path/separators" },
Segments: new [] { "C:", "some", "mixed", "path", "separators" },
SegmentsLimit3: new [] { "C:", "some", @"mixed\path/separators" },
NormalizedOnWindows = @"C:\some\mixed\path\separators",
NormalizedOnUnix = @"C:/some/mixed/path/separators"
},
NormalizedOnWindows: @"C:\some\mixed\path\separators",
NormalizedOnUnix: @"C:/some/mixed/path/separators"
)
};
@ -281,14 +272,14 @@ namespace SMAPI.Tests.Utilities
/*********
** Private classes
*********/
public class SamplePath
/// <summary>A sample path in multiple formats.</summary>
/// <param name="OriginalPath">The original path to pass to the <see cref="PathUtilities"/>.</param>
/// <param name="Segments">The normalized path segments.</param>
/// <param name="SegmentsLimit3">The normalized path segments, if we stop segmenting after the second one.</param>
/// <param name="NormalizedOnWindows">The normalized form on Windows.</param>
/// <param name="NormalizedOnUnix">The normalized form on Linux or macOS.</param>
public record SamplePath(string OriginalPath, string[] Segments, string[] SegmentsLimit3, string NormalizedOnWindows, string NormalizedOnUnix)
{
public string OriginalPath { get; set; }
public string[] Segments { get; set; }
public string[] SegmentsLimit3 { get; set; }
public string NormalizedOnWindows { get; set; }
public string NormalizedOnUnix { get; set; }
public override string ToString()
{
return this.OriginalPath;

View File

@ -16,9 +16,12 @@ namespace SMAPI.Tests.Utilities
/*********
** Fields
*********/
/// <summary>All valid seasons.</summary>
/// <summary>The valid seasons.</summary>
private static readonly string[] ValidSeasons = { "spring", "summer", "fall", "winter" };
/// <summary>Sample user inputs for season names.</summary>
private static readonly string[] SampleSeasonValues = SDateTests.ValidSeasons.Concat(new[] { " WIntEr " }).ToArray();
/// <summary>All valid days of a month.</summary>
private static readonly int[] ValidDays = Enumerable.Range(1, 28).ToArray();
@ -55,19 +58,18 @@ namespace SMAPI.Tests.Utilities
** Constructor
****/
[Test(Description = "Assert that the constructor sets the expected values for all valid dates.")]
public void Constructor_SetsExpectedValues([ValueSource(nameof(SDateTests.ValidSeasons))] string season, [ValueSource(nameof(SDateTests.ValidDays))] int day, [Values(1, 2, 100)] int year)
public void Constructor_SetsExpectedValues([ValueSource(nameof(SDateTests.SampleSeasonValues))] string season, [ValueSource(nameof(SDateTests.ValidDays))] int day, [Values(1, 2, 100)] int year)
{
// act
SDate date = new SDate(day, season, year);
SDate date = new(day, season, year);
// assert
Assert.AreEqual(day, date.Day);
Assert.AreEqual(season, date.Season);
Assert.AreEqual(season.Trim().ToLowerInvariant(), date.Season);
Assert.AreEqual(year, date.Year);
}
[Test(Description = "Assert that the constructor throws an exception if the values are invalid.")]
[TestCase(01, "Spring", 1)] // seasons are case-sensitive
[TestCase(01, "springs", 1)] // invalid season name
[TestCase(-1, "spring", 1)] // day < 0
[TestCase(0, "spring", 1)] // day zero
@ -252,9 +254,9 @@ namespace SMAPI.Tests.Utilities
{
foreach (int day in SDateTests.ValidDays)
{
SDate date = new SDate(day, season, year);
SDate date = new(day, season, year);
int hash = date.GetHashCode();
if (hashes.TryGetValue(hash, out SDate otherDate))
if (hashes.TryGetValue(hash, out SDate? otherDate))
Assert.Fail($"Received identical hash code {hash} for dates {otherDate} and {date}.");
if (hash < lastHash)
Assert.Fail($"Received smaller hash code for date {date} ({hash}) relative to {hashes[lastHash]} ({lastHash}).");
@ -294,7 +296,7 @@ namespace SMAPI.Tests.Utilities
[TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)]
[TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)]
[TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)]
public bool Operators_Equals(string now, string other)
public bool Operators_Equals(string? now, string other)
{
return this.GetDate(now) == this.GetDate(other);
}
@ -308,7 +310,7 @@ namespace SMAPI.Tests.Utilities
[TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)]
[TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)]
[TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)]
public bool Operators_NotEquals(string now, string other)
public bool Operators_NotEquals(string? now, string other)
{
return this.GetDate(now) != this.GetDate(other);
}
@ -322,7 +324,7 @@ namespace SMAPI.Tests.Utilities
[TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)]
[TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)]
[TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)]
public bool Operators_LessThan(string now, string other)
public bool Operators_LessThan(string? now, string other)
{
return this.GetDate(now) < this.GetDate(other);
}
@ -336,7 +338,7 @@ namespace SMAPI.Tests.Utilities
[TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)]
[TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)]
[TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)]
public bool Operators_LessThanOrEqual(string now, string other)
public bool Operators_LessThanOrEqual(string? now, string other)
{
return this.GetDate(now) <= this.GetDate(other);
}
@ -350,7 +352,7 @@ namespace SMAPI.Tests.Utilities
[TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)]
[TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)]
[TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)]
public bool Operators_MoreThan(string now, string other)
public bool Operators_MoreThan(string? now, string other)
{
return this.GetDate(now) > this.GetDate(other);
}
@ -364,7 +366,7 @@ namespace SMAPI.Tests.Utilities
[TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)]
[TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)]
[TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)]
public bool Operators_MoreThanOrEqual(string now, string other)
public bool Operators_MoreThanOrEqual(string? now, string other)
{
return this.GetDate(now) > this.GetDate(other);
}
@ -375,7 +377,8 @@ namespace SMAPI.Tests.Utilities
*********/
/// <summary>Convert a string date into a game date, to make unit tests easier to read.</summary>
/// <param name="dateStr">The date string like "dd MMMM yy".</param>
private SDate GetDate(string dateStr)
[return: NotNullIfNotNull("dateStr")]
private SDate? GetDate(string? dateStr)
{
if (dateStr == null)
return null;

Some files were not shown because too many files have changed in this diff Show More