Skip to content

Commit

Permalink
Added: Automatic Package Creator, Including some Simple Tests
Browse files Browse the repository at this point in the history
  • Loading branch information
Sewer56 committed Sep 29, 2022
1 parent 9ed0cba commit ee1c632
Show file tree
Hide file tree
Showing 8 changed files with 272 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageReference Include="IoC.Container" Version="1.3.7" />
<PackageReference Include="McMaster.NETCore.Plugins" Version="2.0.0-beta.0" />
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
<PackageReference Include="PhotoSauce.NativeCodecs.Libjxl" Version="0.6.1-preview2" />
<PackageReference Include="PropertyChanged.Fody" Version="3.4.1">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
Expand Down
32 changes: 32 additions & 0 deletions source/Reloaded.Mod.Launcher.Lib/Utility/JxlImageConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using PhotoSauce.MagicScaler;
using PhotoSauce.NativeCodecs.Libjxl;

namespace Reloaded.Mod.Launcher.Lib.Utility;

/// <summary>
/// Converts image to JPEG XL format.
/// </summary>
public class JxlImageConverter : IImageConverter
{
private static readonly IEncoderOptions EncoderOptions = new JxlLosslessEncoderOptions(JxlEncodeSpeed.Squirrel, JxlDecodeSpeed.Slowest);

static JxlImageConverter()
{
CodecManager.Configure(codecs => { codecs.UseLibjxl(); });
}

/// <inheritdoc />
public MemoryStream Convert(Span<byte> source, out string extension)
{
var settings = new ProcessImageSettings();
settings.TrySetEncoderFormat(ImageMimeTypes.Jxl);
settings.EncoderOptions = EncoderOptions;

var output = new MemoryStream();
MagicImageProcessor.ProcessImage(source, output, settings);

output.Position = 0;
extension = ".jxl";
return output;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@

<ItemGroup>
<ProjectReference Include="..\Mods\Reloaded.Utils.Server\Reloaded.Utils.Server.csproj" />
<ProjectReference Include="..\Reloaded.Mod.Launcher.Lib\Reloaded.Mod.Launcher.Lib.csproj" />
<ProjectReference Include="..\Reloaded.Mod.Loader.Community\Reloaded.Mod.Loader.Community.csproj" />
<ProjectReference Include="..\Reloaded.Mod.Loader.Server\Reloaded.Mod.Loader.Server.csproj" />
<ProjectReference Include="..\Reloaded.Mod.Loader.Update\Reloaded.Mod.Loader.Update.csproj" />
Expand Down
4 changes: 2 additions & 2 deletions source/Reloaded.Mod.Loader.Tests/SETUP/TestEnvironmoent.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Environment = Reloaded.Mod.Shared.Environment;
using System.Diagnostics;
using Paths = Reloaded.Mod.Loader.IO.Paths;

namespace Reloaded.Mod.Loader.Tests.SETUP;
Expand Down Expand Up @@ -92,7 +92,7 @@ public TestEnvironmoent()

ThisApplication = new ApplicationConfig(IdOfThisApp,
"Reloaded Mod Loader Tests",
Environment.CurrentProcessLocation.Value,
Path.GetFullPath(Process.GetCurrentProcess().MainModule!.FileName!),
new[] { TestModConfigA.ModId, TestModConfigB.ModId, TestModConfigD.ModId });

ConfigurationPathOfThisApp = Path.Combine(TestConfig.GetApplicationConfigDirectory(), IdOfThisApp, ApplicationConfig.ConfigFileName);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using Reloaded.Mod.Launcher.Lib.Utility;
using Reloaded.Mod.Loader.Tests.Update.Pack.Mocks;
using Reloaded.Mod.Loader.Update.Packs;
using Sewer56.Update.Resolvers.GameBanana;

namespace Reloaded.Mod.Loader.Tests.Update.Pack;

public class AutoPackageCreatorTests
{
[Fact]
public void CannotCreate_WithMissingUpdateInfo()
{
using var tempFolderAlloc = new TemporaryFolderAllocation();
var conf = CreateTestConfig(tempFolderAlloc.FolderPath);

Assert.False(AutoPackCreator.ValidateCanCreate(new []{ conf }, out var incompatible));
Assert.Single(incompatible);
}

[Fact]
public void CanCreate_WithUpdateInfo()
{
using var tempFolderAlloc = new TemporaryFolderAllocation();
var conf = CreateTestConfig(tempFolderAlloc.FolderPath);
AddGitHubUpdateResolver(conf);

Assert.True(AutoPackCreator.ValidateCanCreate(new []{ conf }, out var incompatible));
Assert.Empty(incompatible);
}

[Fact]
public async Task AutoPackCreator_CanImportFromModSearch()
{
// Arrange
using var tempMod = new TemporaryFolderAllocation();
var modConf = CreateTestConfig(tempMod.FolderPath);
AddGitHubUpdateResolver(modConf);

var provider = GetGameBananaPackageProvider();

// Act
var pack = await AutoPackCreator.CreateAsync(new ModConfig[] { modConf.Config },
new DummyImageConverter(),
new List<IDownloadablePackageProvider>() { provider});
var built = pack.Build(out var package);

// Assert
File.WriteAllBytes("build.zip", built.ToArray());
Assert.Single(package.Items);
Assert.True(package.Items[0].ImageFiles.Count > 1);
}

private PathTuple<ModConfig> CreateTestConfig(string folder)
{
return new PathTuple<ModConfig>(Path.Combine(folder, ModConfig.ConfigFileName), new ModConfig()
{
ModId = "sonicheroes.essentials.graphics",
ModName = "Graphics Essentials" // so it finds on GameBanana
});
}

private void AddGitHubUpdateResolver(PathTuple<ModConfig> modTuple)
{
var factory = new GitHubReleasesUpdateResolverFactory();
factory.SetConfiguration(modTuple, new GitHubReleasesUpdateResolverFactory.GitHubConfig()
{
RepositoryName = "Heroes.Graphics.Essentials.ReloadedII",
UserName = "Sewer56"
});
}

private GameBananaPackageProvider GetGameBananaPackageProvider()
{
return new GameBananaPackageProvider(6061);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Reloaded.Mod.Loader.Tests.Update.Pack.Mocks;

public class DummyImageConverter : IImageConverter
{
public MemoryStream Convert(Span<byte> source, out string extension)
{
extension = ".org";
return new MemoryStream(source.ToArray());
}
}
15 changes: 15 additions & 0 deletions source/Reloaded.Mod.Loader.Update/Interfaces/IImageConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Reloaded.Mod.Loader.Update.Interfaces;

/// <summary>
/// Interface that can be used to provide support for converting images.
/// </summary>
public interface IImageConverter
{
/// <summary>
/// Converts the image to new format.
/// </summary>
/// <param name="source">Span containing the source image to be converted.</param>
/// <param name="extension">The extension for the image.</param>
/// <returns>The converted image in a stream. Stream should have position 0 and end at end of file.</returns>
public MemoryStream Convert(Span<byte> source, out string extension);
}
135 changes: 135 additions & 0 deletions source/Reloaded.Mod.Loader.Update/Packs/AutoPackCreator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
namespace Reloaded.Mod.Loader.Update.Packs;

/// <summary>
/// Utility class for automatic creation of packages based on a given sample input of mods.
/// </summary>
public static class AutoPackCreator
{
/// <summary>
/// Checks if all mods in given list can be used in the pack by verifying they have enabled updates.
/// </summary>
/// <param name="configurations">List of mod configurations to check.</param>
/// <param name="incompatibleMods">List of mods to check for compatibility.</param>
/// <returns>True if mods can be packed, else false.</returns>
public static bool ValidateCanCreate(IEnumerable<PathTuple<ModConfig>> configurations, out List<PathTuple<ModConfig>> incompatibleMods)
{
incompatibleMods = new List<PathTuple<ModConfig>>();
foreach (var config in configurations)
{
if (!PackageResolverFactory.HasAnyConfiguredResolver(config))
incompatibleMods.Add(config);
}

return incompatibleMods.Count <= 0;
}

/// <summary>
/// Automatically creates a package.
/// </summary>
/// <param name="configurations">The configurations used to create the config. Must at least have update data, ModId and ModName.</param>
/// <param name="imageConverter">Used for converting images.</param>
/// <param name="packageProviders">Providers that can be used to search for packages.</param>
/// <param name="token">Token to cancel the operation.</param>
public static async Task<ReloadedPackBuilder> CreateAsync(IEnumerable<ModConfig> configurations, IImageConverter imageConverter, IList<IDownloadablePackageProvider> packageProviders, CancellationToken token = default)
{
var builder = new ReloadedPackBuilder();
var imageDownloader = new ImageCacheService();

builder.SetName("My Autogenerated Package");
builder.SetReadme("You should probably add description here.");

foreach (var config in configurations)
{
var itemBuilder = builder.AddModItem(config.ModId);
itemBuilder.SetName(config.ModName);
itemBuilder.SetPluginData(config.PluginData);

var bestPkg = await GetBestPackageForTemplateAsync(config.ModId, config.ModName, packageProviders, token);

// Add images if possible
if (bestPkg.images != null)
{
foreach (var image in bestPkg.images)
{
var file = await imageDownloader.GetOrDownloadFileFromUrl(image.Uri, imageDownloader.ModPreviewExpiration, false, token);
var converted = imageConverter.Convert(file, out string ext);
itemBuilder.AddImage(converted, ext, image.Caption);
}
}

// Add readme if possible
if (!string.IsNullOrEmpty(bestPkg.markdownReadme))
itemBuilder.SetReadme(bestPkg.markdownReadme);
}

return builder;
}

private static async Task<(DownloadableImage[]? images, string markdownReadme)> GetBestPackageForTemplateAsync(string modId, string modName,
IList<IDownloadablePackageProvider> downloadablePackageProviders, CancellationToken cancellationToken)
{
(DownloadableImage[]? images, string markdownReadme) result = new();

// We will get all packages with highest version and copy images & readme down the road.
NuGetVersion? highestVersion = null;
List<IDownloadablePackage> itemsForHighestVersion = new List<IDownloadablePackage>();

// Note: Some sources don't support e.g. images.
// We prioritise sources based on whether they contain images, then by readme.
// If preferred source does not contain both readme and image, we stitch them together from sources that do.
foreach (var provider in downloadablePackageProviders)
{
// Override if better than current best.
var candidates = await provider.SearchForModAsync(modId, modName, 50, 4, true, cancellationToken);
foreach (var candidate in candidates)
{
// Find items for highest version
if (candidate.Version != null)
{
if (highestVersion == null)
highestVersion = candidate.Version;

if (candidate.Version == highestVersion)
itemsForHighestVersion.Add(candidate);

if (candidate.Version > highestVersion)
{
itemsForHighestVersion.Clear();
highestVersion = candidate.Version;
itemsForHighestVersion.Add(candidate);
}
}

// Set images if more than existing best result.
if (candidate.Images != null)
{
// Assign images if unassigned
result.images ??= candidate.Images;

// Prefer source with more images.
if (candidate.Images.Length > result.images.Length)
result.images = candidate.Images;
}

// Set description if unassigned.
if (candidate.MarkdownReadme != null)
result.markdownReadme ??= candidate.MarkdownReadme;
}
}

// Override supported items from
foreach (var item in itemsForHighestVersion)
{
// Set description if unassigned.
if (item.MarkdownReadme != null)
result.markdownReadme = item.MarkdownReadme;

// Set images to ones from newest version in case older version had more images.
// But only if more than 1 image. We want to filter out entries with only thumbnail.
if (item.Images is { Length: > 1 })
result.images = item.Images;
}

return result;
}
}

0 comments on commit ee1c632

Please sign in to comment.