This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using YMhut.Box.Core.App;
|
||||
using YMhut.Box.Core.Logging;
|
||||
using YMhut.Box.Core.Settings;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class AgreementAcceptanceStoreTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void OldBooleanAgreementSettingRequiresResign()
|
||||
{
|
||||
var settings = new AppSettings
|
||||
{
|
||||
UserAgreementAccepted = true
|
||||
};
|
||||
|
||||
Assert.IsFalse(AgreementDocument.SettingsMatchCurrent(settings));
|
||||
|
||||
settings.UserAgreementVersion = AgreementDocument.CurrentVersion;
|
||||
|
||||
Assert.IsTrue(AgreementDocument.SettingsMatchCurrent(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task AgreementAcceptanceUsesMainSqliteAndSurvivesLogCleanup()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-agreement-store");
|
||||
var paths = AppPaths.ForCurrentUser(workspace.Root);
|
||||
var store = new AgreementAcceptanceStore(paths);
|
||||
var logService = new SqliteLogService(paths);
|
||||
var acceptedAt = new DateTimeOffset(2026, 6, 15, 9, 30, 0, TimeSpan.Zero);
|
||||
|
||||
await store.RecordAcceptedAsync(
|
||||
AgreementDocument.CurrentVersion,
|
||||
AgreementDocument.CurrentRevision,
|
||||
"2.0.6.2",
|
||||
"zh-CN",
|
||||
acceptedAt);
|
||||
await logService.WriteAsync("Information", "test", "log before cleanup");
|
||||
await logService.ClearAllAsync();
|
||||
|
||||
var latest = await store.GetLatestAsync();
|
||||
|
||||
Assert.AreEqual(Path.Combine(paths.Logs, AppDatabasePaths.MainDatabaseFileName), store.DatabasePath);
|
||||
Assert.IsNotNull(latest);
|
||||
Assert.AreEqual(AgreementDocument.CurrentVersion, latest.Version);
|
||||
Assert.AreEqual(AgreementDocument.CurrentRevision, latest.Revision);
|
||||
Assert.AreEqual("2.0.6.2", latest.AppVersion);
|
||||
Assert.AreEqual("zh-CN", latest.Language);
|
||||
Assert.AreEqual(acceptedAt, latest.AcceptedAt);
|
||||
}
|
||||
|
||||
private sealed class TestWorkspace : IDisposable
|
||||
{
|
||||
private TestWorkspace(string root)
|
||||
{
|
||||
Root = root;
|
||||
}
|
||||
|
||||
public string Root { get; }
|
||||
|
||||
public static TestWorkspace Create(string prefix)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), prefix, Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
return new TestWorkspace(root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (Directory.Exists(Root))
|
||||
{
|
||||
Directory.Delete(Root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.Settings;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class AppSettingsStoreTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task NewSettingsDefaultToDashboardHome()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
|
||||
var store = new AppSettingsStore(root);
|
||||
|
||||
var settings = await store.LoadAsync();
|
||||
|
||||
Assert.AreEqual("dashboard", settings.HomePageMode);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task RecordRecentToolKeepsNewestFirstAndUnique()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
|
||||
var store = new AppSettingsStore(root);
|
||||
|
||||
await store.RecordRecentToolAsync("json_formatter");
|
||||
await store.RecordRecentToolAsync("safe_browser");
|
||||
await store.RecordRecentToolAsync("json_formatter");
|
||||
|
||||
var settings = await store.LoadAsync();
|
||||
CollectionAssert.AreEqual(
|
||||
new[] { "json_formatter", "safe_browser" },
|
||||
settings.RecentToolIds);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task LoadAsyncImportsLegacySettingsOnce()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"), "WinUI");
|
||||
var legacyPath = Path.Combine(Path.GetDirectoryName(root)!, "YMhut Box", "settings.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(legacyPath)!);
|
||||
await File.WriteAllTextAsync(legacyPath, """
|
||||
{
|
||||
"theme": "Dark",
|
||||
"recentToolIds": [
|
||||
"safe_browser"
|
||||
],
|
||||
"legacyImportCompleted": false
|
||||
}
|
||||
""");
|
||||
|
||||
var store = new AppSettingsStore(root);
|
||||
var settings = await store.LoadAsync();
|
||||
|
||||
Assert.IsTrue(settings.LegacyImportCompleted);
|
||||
Assert.AreEqual("Dark", settings.Theme);
|
||||
CollectionAssert.AreEqual(new[] { "safe_browser" }, settings.RecentToolIds);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task LoadAsyncImportsLegacySharedPreferences()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"), "WinUI");
|
||||
var legacyPath = Path.Combine(Path.GetDirectoryName(root)!, "ymhut_box", "shared_preferences.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(legacyPath)!);
|
||||
var legacyPrefix = string.Concat("flut", "ter.");
|
||||
await File.WriteAllTextAsync(legacyPath, """
|
||||
{
|
||||
"__PREFIX__theme": "dark",
|
||||
"__PREFIX__pitch_black": true,
|
||||
"__PREFIX__seed_color": -10458044,
|
||||
"__PREFIX__card_opacity": 0.64,
|
||||
"__PREFIX__background_image": "D:\\Images\\wallpaper.png",
|
||||
"__PREFIX__background_opacity": 0.42,
|
||||
"__PREFIX__user_agreement_accepted": true,
|
||||
"__PREFIX__sidebar_collapsed": true,
|
||||
"__PREFIX__desktop_titlebar": false,
|
||||
"__PREFIX__recent_tool_ids": [
|
||||
"media_player",
|
||||
"safe_browser"
|
||||
],
|
||||
"__PREFIX__pinned_tool_ids": [
|
||||
"json_formatter"
|
||||
],
|
||||
"__PREFIX__window_x": 12.5,
|
||||
"__PREFIX__window_y": 24.5,
|
||||
"__PREFIX__window_width": 1280,
|
||||
"__PREFIX__window_height": 760,
|
||||
"__PREFIX__window_maximized": true,
|
||||
"__PREFIX__proxy_enabled": true,
|
||||
"__PREFIX__proxy_mode": "manual",
|
||||
"__PREFIX__proxy_host": "127.0.0.1",
|
||||
"__PREFIX__proxy_port": 7891,
|
||||
"__PREFIX__animations_enabled": false,
|
||||
"__PREFIX__font_size": 1.15,
|
||||
"__PREFIX__tool_display_mode": "list",
|
||||
"__PREFIX__close_behavior": "minimize_then_exit",
|
||||
"__PREFIX__restore_window_position": false,
|
||||
"__PREFIX__auto_start": true,
|
||||
"__PREFIX__log_retention_count": 300,
|
||||
"__PREFIX__data_refresh_interval": 45,
|
||||
"__PREFIX__update_notification": false,
|
||||
"__PREFIX__hardware_acceleration_enabled": false,
|
||||
"__PREFIX__proxy_test_timeout_seconds": 9
|
||||
}
|
||||
""".Replace("__PREFIX__", legacyPrefix));
|
||||
|
||||
var store = new AppSettingsStore(root);
|
||||
var settings = await store.LoadAsync();
|
||||
|
||||
Assert.AreEqual("Dark", settings.Theme);
|
||||
Assert.IsTrue(settings.PitchBlack);
|
||||
Assert.AreEqual(-10458044, settings.SeedColor);
|
||||
Assert.AreEqual(0.64, settings.CardOpacity, 0.001);
|
||||
Assert.AreEqual("D:\\Images\\wallpaper.png", settings.BackgroundImage);
|
||||
Assert.AreEqual(0.42, settings.BackgroundOpacity, 0.001);
|
||||
Assert.IsTrue(settings.UserAgreementAccepted);
|
||||
Assert.IsTrue(settings.SidebarCollapsed);
|
||||
Assert.IsFalse(settings.DesktopTitlebar);
|
||||
CollectionAssert.AreEqual(new[] { "media_player", "safe_browser" }, settings.RecentToolIds);
|
||||
CollectionAssert.AreEqual(new[] { "json_formatter" }, settings.PinnedToolIds);
|
||||
Assert.AreEqual(12.5, settings.WindowX);
|
||||
Assert.AreEqual(24.5, settings.WindowY);
|
||||
Assert.AreEqual(1280, settings.WindowWidth);
|
||||
Assert.AreEqual(760, settings.WindowHeight);
|
||||
Assert.IsTrue(settings.WindowMaximized);
|
||||
Assert.IsTrue(settings.ProxyEnabled);
|
||||
Assert.AreEqual("manual", settings.ProxyMode);
|
||||
Assert.AreEqual("127.0.0.1", settings.ProxyHost);
|
||||
Assert.AreEqual(7891, settings.ProxyPort);
|
||||
Assert.IsFalse(settings.AnimationsEnabled);
|
||||
Assert.AreEqual(1.15, settings.FontSize, 0.001);
|
||||
Assert.AreEqual("list", settings.ToolDisplayMode);
|
||||
Assert.AreEqual("minimize_then_exit", settings.CloseBehavior);
|
||||
Assert.IsFalse(settings.RestoreWindowPosition);
|
||||
Assert.IsTrue(settings.AutoStart);
|
||||
Assert.AreEqual(300, settings.LogRetentionCount);
|
||||
Assert.AreEqual(45, settings.DataRefreshInterval);
|
||||
Assert.IsFalse(settings.UpdateNotification);
|
||||
Assert.IsFalse(settings.HardwareAccelerationEnabled);
|
||||
Assert.AreEqual(9, settings.ProxyTestTimeoutSeconds);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PinnedToolSettingsTogglesUniquelyAndTrims()
|
||||
{
|
||||
var pinned = Enumerable.Range(0, 70)
|
||||
.Select(index => $"tool_{index:00}")
|
||||
.ToList();
|
||||
pinned.Insert(10, "json_formatter");
|
||||
|
||||
PinnedToolSettings.Toggle(pinned, "json_formatter", wasPinned: false);
|
||||
|
||||
Assert.HasCount(64, pinned);
|
||||
Assert.AreEqual("json_formatter", pinned[0]);
|
||||
Assert.AreEqual(1, pinned.Count(id => string.Equals(id, "json_formatter", StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
PinnedToolSettings.Toggle(pinned, "JSON_FORMATTER", wasPinned: true);
|
||||
|
||||
Assert.HasCount(63, pinned);
|
||||
Assert.IsFalse(pinned.Any(id => string.Equals(id, "json_formatter", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task LoadAsyncNormalizesGlobalMaterialSettings()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "settings.json"), """
|
||||
{
|
||||
"legacyImportCompleted": true,
|
||||
"windowBackdrop": "desktop_acrylic",
|
||||
"settingsPanelMaterial": "frosted_glass",
|
||||
"topBarMaterial": "frosted_glass"
|
||||
}
|
||||
""");
|
||||
|
||||
var store = new AppSettingsStore(root);
|
||||
var settings = await store.LoadAsync();
|
||||
|
||||
Assert.AreEqual("acrylic", settings.WindowBackdrop);
|
||||
Assert.AreEqual("glass", settings.SettingsPanelMaterial);
|
||||
Assert.AreEqual("glass", settings.TopBarMaterial);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
|
||||
@@ -0,0 +1,377 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.App;
|
||||
using YMhut.Box.Core.DevEnvironments;
|
||||
using YMhut.Box.Core.Downloads;
|
||||
using YMhut.Box.Core.Net;
|
||||
using YMhut.Box.Core.Updates;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class DownloadAndDevEnvironmentTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void DevEnvironmentVersionParserRecognizesSupportedTools()
|
||||
{
|
||||
Assert.AreEqual("1.23.4", DevEnvironmentVersionParser.Parse("go", "go version go1.23.4 windows/amd64"));
|
||||
Assert.AreEqual("3.12.7", DevEnvironmentVersionParser.Parse("python", "Python 3.12.7"));
|
||||
Assert.AreEqual("21.0.2", DevEnvironmentVersionParser.Parse("java", "openjdk version \"21.0.2\" 2024-01-16"));
|
||||
Assert.AreEqual("27.3.1", DevEnvironmentVersionParser.Parse("docker", "Docker version 27.3.1, build ce12230"));
|
||||
Assert.AreEqual("8.0.36", DevEnvironmentVersionParser.Parse("mysql", "mysql Ver 8.0.36 for Win64 on x86_64"));
|
||||
Assert.AreEqual("22.11.0", DevEnvironmentVersionParser.Parse("node", "v22.11.0"));
|
||||
Assert.AreEqual("10.0.100", DevEnvironmentVersionParser.Parse("dotnet", "10.0.100\r\n9.0.308"));
|
||||
Assert.AreEqual("2.47.1.windows.2", DevEnvironmentVersionParser.Parse("git", "git version 2.47.1.windows.2"));
|
||||
Assert.AreEqual("1.82.0", DevEnvironmentVersionParser.Parse("rust", "rustc 1.82.0 (f6e511eec 2024-10-15)"));
|
||||
Assert.AreEqual("3.31.0", DevEnvironmentVersionParser.Parse("cmake", "cmake version 3.31.0"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DevEnvironmentCatalogReadsOfficialVersionMetadata()
|
||||
{
|
||||
var workspace = TempWorkspace();
|
||||
var paths = AppPaths.ForCurrentUser(workspace);
|
||||
var service = new DevEnvironmentCatalogService(
|
||||
paths,
|
||||
new FakeHttpService(uri => uri.AbsoluteUri switch
|
||||
{
|
||||
"https://go.dev/dl/?mode=json" => """
|
||||
[
|
||||
{
|
||||
"version": "go1.23.4",
|
||||
"stable": true,
|
||||
"files": [
|
||||
{ "filename": "go1.23.4.windows-amd64.msi", "sha256": "abc" },
|
||||
{ "filename": "go1.23.4.src.tar.gz", "sha256": "def" }
|
||||
]
|
||||
}
|
||||
]
|
||||
""",
|
||||
"https://nodejs.org/dist/index.json" => """
|
||||
[
|
||||
{ "version": "v22.11.0", "date": "2024-10-29" }
|
||||
]
|
||||
""",
|
||||
"https://api.github.com/repos/git-for-windows/git/releases" => """
|
||||
[
|
||||
{
|
||||
"tag_name": "v2.47.1.windows.2",
|
||||
"published_at": "2024-10-01T00:00:00Z",
|
||||
"html_url": "https://github.com/git-for-windows/git/releases/tag/v2.47.1.windows.2",
|
||||
"assets": [
|
||||
{
|
||||
"name": "Git-2.47.1-64-bit.exe",
|
||||
"browser_download_url": "https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.2/Git-2.47.1-64-bit.exe",
|
||||
"size": 1024
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
""",
|
||||
_ => """
|
||||
{
|
||||
"releases-index": [
|
||||
{
|
||||
"channel-version": "10.0",
|
||||
"latest-sdk": "10.0.100",
|
||||
"release-date": "2025-11-11"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
}));
|
||||
|
||||
var go = await service.GetVersionsAsync("go");
|
||||
var node = await service.GetVersionsAsync("node");
|
||||
var dotnet = await service.GetVersionsAsync("dotnet");
|
||||
|
||||
Assert.AreEqual("1.23.4", go[0].Version);
|
||||
Assert.AreEqual("go1.23.4.windows-amd64.msi", go[0].Installer?.FileName);
|
||||
Assert.AreEqual("go1.23.4.src.tar.gz", go[0].SourceArchive?.FileName);
|
||||
Assert.AreEqual("22.11.0", node[0].Version);
|
||||
Assert.AreEqual("node-v22.11.0-x64.msi", node[0].Installer?.FileName);
|
||||
Assert.IsTrue(node[0].AllCandidates.Any(candidate => candidate.SourceType == "MirrorCN"));
|
||||
Assert.AreEqual("10.0.100", dotnet[0].Version);
|
||||
Assert.AreEqual("dotnet-sdk-10.0.100-win-x64.exe", dotnet[0].Installer?.FileName);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DevEnvironmentCatalogReadsGithubReleaseAssets()
|
||||
{
|
||||
var workspace = TempWorkspace();
|
||||
var service = new DevEnvironmentCatalogService(
|
||||
AppPaths.ForCurrentUser(workspace),
|
||||
new FakeHttpService(_ => """
|
||||
[
|
||||
{
|
||||
"tag_name": "v3.31.0",
|
||||
"published_at": "2024-11-01T00:00:00Z",
|
||||
"html_url": "https://github.com/Kitware/CMake/releases/tag/v3.31.0",
|
||||
"assets": [
|
||||
{
|
||||
"name": "cmake-3.31.0-windows-x86_64.msi",
|
||||
"browser_download_url": "https://github.com/Kitware/CMake/releases/download/v3.31.0/cmake-3.31.0-windows-x86_64.msi",
|
||||
"size": 2048
|
||||
},
|
||||
{
|
||||
"name": "cmake-3.31.0.tar.gz",
|
||||
"browser_download_url": "https://github.com/Kitware/CMake/releases/download/v3.31.0/cmake-3.31.0.tar.gz",
|
||||
"size": 4096
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"""));
|
||||
|
||||
var cmake = await service.GetVersionsAsync("cmake");
|
||||
|
||||
Assert.HasCount(1, cmake);
|
||||
Assert.AreEqual("3.31.0", cmake[0].Version);
|
||||
Assert.IsTrue(cmake[0].AllCandidates.Any(candidate => candidate.Mode == DevEnvironmentInstallMode.QuickInstall && candidate.SourceType == "GitHub"));
|
||||
Assert.IsTrue(cmake[0].AllCandidates.Any(candidate => candidate.Mode == DevEnvironmentInstallMode.SourceBuild && candidate.Source.SizeBytes == 4096));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DownloadQueueStorePersistsAndRestoresItems()
|
||||
{
|
||||
var workspace = TempWorkspace();
|
||||
var store = new DownloadQueueStore(AppPaths.ForCurrentUser(workspace));
|
||||
var item = DownloadItem.Create(
|
||||
new DownloadSource("https://example.com/tool.exe", "Tool", "tool.exe"),
|
||||
Path.Combine(workspace, "tool.exe"),
|
||||
"installer")
|
||||
.WithResumeMetadata(
|
||||
"\"abc\"",
|
||||
"Wed, 10 Jun 2026 08:00:00 GMT",
|
||||
"bytes",
|
||||
2048,
|
||||
"https://cdn.example.com/tool.exe",
|
||||
true)
|
||||
.WithProgress(DownloadState.Completed, 1024, 2048, 0);
|
||||
|
||||
await store.SaveAsync([item]);
|
||||
var loaded = await store.LoadAsync();
|
||||
|
||||
Assert.HasCount(1, loaded);
|
||||
Assert.AreEqual(item.Id, loaded[0].Id);
|
||||
Assert.AreEqual(DownloadState.Completed, loaded[0].State);
|
||||
Assert.AreEqual("tool.exe", loaded[0].Source.FileName);
|
||||
Assert.AreEqual("installer", loaded[0].InstallCommand);
|
||||
Assert.AreEqual(Path.Combine(workspace, "tool.exe.partial"), loaded[0].EffectivePartialPath);
|
||||
Assert.AreEqual("\"abc\"", loaded[0].ETag);
|
||||
Assert.AreEqual("Wed, 10 Jun 2026 08:00:00 GMT", loaded[0].LastModified);
|
||||
Assert.AreEqual("bytes", loaded[0].AcceptRanges);
|
||||
Assert.AreEqual(2048, loaded[0].ContentLength);
|
||||
Assert.AreEqual("https://cdn.example.com/tool.exe", loaded[0].FinalUrl);
|
||||
Assert.IsTrue(loaded[0].ResumeSupported);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DownloadQueueStorePersistsSettings()
|
||||
{
|
||||
var workspace = TempWorkspace();
|
||||
var store = new DownloadQueueStore(AppPaths.ForCurrentUser(workspace));
|
||||
var directory = Path.Combine(workspace, "custom");
|
||||
|
||||
await store.SaveSettingsAsync(new DownloadSettings(directory, 99));
|
||||
var loaded = await store.LoadSettingsAsync();
|
||||
|
||||
Assert.AreEqual(directory, loaded.DefaultDirectory);
|
||||
Assert.AreEqual(5, loaded.MaxConcurrentDownloads);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DownloadProgressAndProtocolRoundTrip()
|
||||
{
|
||||
var progress = new DownloadProgressSnapshot(
|
||||
"a",
|
||||
DownloadState.Running,
|
||||
512,
|
||||
1024,
|
||||
2048,
|
||||
ETag: "\"abc\"",
|
||||
LastModified: "Wed, 10 Jun 2026 08:00:00 GMT",
|
||||
AcceptRanges: "bytes",
|
||||
ContentLength: 1024,
|
||||
FinalUrl: "https://cdn.example.com/a.zip");
|
||||
var message = new DownloadHostMessage(DownloadHostProtocol.Progress, progress.Id, DownloadHostProtocol.Version, Progress: progress);
|
||||
|
||||
var serialized = DownloadHostProtocol.Serialize(message);
|
||||
var roundTrip = DownloadHostProtocol.Deserialize(serialized);
|
||||
|
||||
Assert.AreEqual(0.5, progress.Progress, 0.001);
|
||||
Assert.IsNotNull(roundTrip);
|
||||
Assert.AreEqual(DownloadHostProtocol.Progress, roundTrip.Type);
|
||||
Assert.AreEqual(DownloadState.Running, roundTrip.Progress?.State);
|
||||
Assert.AreEqual(2048, roundTrip.Progress?.BytesPerSecond);
|
||||
Assert.AreEqual("\"abc\"", roundTrip.Progress?.ETag);
|
||||
Assert.AreEqual("bytes", roundTrip.Progress?.AcceptRanges);
|
||||
Assert.AreEqual("https://cdn.example.com/a.zip", roundTrip.Progress?.FinalUrl);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DirectDownloadValidatorParsesInternetShortcut()
|
||||
{
|
||||
using var validator = new DirectDownloadValidator(new HttpClient(new StubHttpHandler(request =>
|
||||
{
|
||||
var content = new ByteArrayContent(Encoding.UTF8.GetBytes("package"));
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip");
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = content
|
||||
};
|
||||
})));
|
||||
|
||||
var result = await validator.ValidateAsync("""
|
||||
[InternetShortcut]
|
||||
URL=https://example.com/package.zip
|
||||
""");
|
||||
|
||||
Assert.IsTrue(result.IsDirectDownload);
|
||||
Assert.IsTrue(result.WasInternetShortcut);
|
||||
Assert.AreEqual("https://example.com/package.zip", result.EffectiveUrl);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DirectDownloadValidatorRejectsHtmlDownloadPage()
|
||||
{
|
||||
using var validator = new DirectDownloadValidator(new HttpClient(new StubHttpHandler(request =>
|
||||
{
|
||||
var content = new ByteArrayContent(Encoding.UTF8.GetBytes("<!doctype html><html></html>"));
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/html");
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = content
|
||||
};
|
||||
})));
|
||||
|
||||
var result = await validator.ValidateAsync("https://example.com/download");
|
||||
|
||||
Assert.IsFalse(result.IsDirectDownload);
|
||||
StringAssert.Contains(result.Message, "web page");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ReleaseManifestSerializerRoundTripsFullInstallerManifest()
|
||||
{
|
||||
var workspace = TempWorkspace();
|
||||
var path = Path.Combine(workspace, "manifest.json");
|
||||
var manifest = new ReleaseManifest(
|
||||
"2.0.7.0",
|
||||
"2.0.7.0",
|
||||
"stable",
|
||||
DateTimeOffset.Parse("2026-06-13T00:00:00Z"),
|
||||
[
|
||||
new ReleaseFileEntry("downloads/YMhut_Box_WinUI_Setup_2.0.7.0.exe", 445_000_000, "installer"),
|
||||
new ReleaseFileEntry("downloads/YMhut_Box_WinUI_2.0.7.0.msix", 571_000_000, "msix")
|
||||
],
|
||||
[],
|
||||
"https://update.ymhut.cn/downloads/",
|
||||
"Full");
|
||||
|
||||
await ReleaseManifestSerializer.WriteManifestAsync(path, manifest);
|
||||
var loaded = await ReleaseManifestSerializer.ReadManifestAsync(path);
|
||||
|
||||
Assert.AreEqual("2.0.7.0", loaded.Version);
|
||||
Assert.AreEqual("stable", loaded.Channel);
|
||||
Assert.AreEqual("Full", loaded.Flavor);
|
||||
Assert.HasCount(2, loaded.Files);
|
||||
Assert.IsTrue(loaded.Files.All(file =>
|
||||
file.Path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ||
|
||||
file.Path.EndsWith(".msix", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateInfoEndpointPolicyPrefersCanonicalUpdateInfo()
|
||||
{
|
||||
var endpoints = UpdateInfoEndpointPolicy.BuildDefaultUris("https://update.ymhut.cn/");
|
||||
|
||||
CollectionAssert.AreEqual(
|
||||
new[]
|
||||
{
|
||||
"https://update.ymhut.cn/api/client/bootstrap",
|
||||
"https://update.ymhut.cn/update-info.json",
|
||||
"https://update.ymhut.cn/update-info",
|
||||
"https://update.ymhut.cn/api/update-info"
|
||||
},
|
||||
endpoints.Select(endpoint => endpoint.ToString()).ToArray());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task HttpRedirectResolverFollowsMultiHopAndRelativeLocations()
|
||||
{
|
||||
using var client = new HttpClient(new StubHttpHandler(request =>
|
||||
{
|
||||
return request.RequestUri!.AbsoluteUri switch
|
||||
{
|
||||
"https://media.example/start" => new HttpResponseMessage(HttpStatusCode.Redirect)
|
||||
{
|
||||
Headers = { Location = new Uri("/hop-1", UriKind.Relative) },
|
||||
RequestMessage = request
|
||||
},
|
||||
"https://media.example/hop-1" => new HttpResponseMessage(HttpStatusCode.TemporaryRedirect)
|
||||
{
|
||||
Headers = { Location = new Uri("https://cdn.example/final.mp4") },
|
||||
RequestMessage = request
|
||||
},
|
||||
"https://cdn.example/final.mp4" => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = new ByteArrayContent(Array.Empty<byte>())
|
||||
},
|
||||
_ => new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = new StringContent("missing")
|
||||
}
|
||||
};
|
||||
}));
|
||||
|
||||
var resolved = await HttpRedirectResolver.ResolveAsync(new Uri("https://media.example/start"), client);
|
||||
|
||||
Assert.AreEqual("https://cdn.example/final.mp4", resolved.ToString());
|
||||
}
|
||||
|
||||
private static string TempWorkspace()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "ymhut-box-download-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private sealed class FakeHttpService(Func<Uri, string> handler) : IHttpService
|
||||
{
|
||||
public Task<HttpServiceResult> GetAsync(Uri uri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new HttpServiceResult(HttpStatusCode.OK, handler(uri), new Dictionary<string, string[]>(), TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public Task<string> GetStringAsync(Uri uri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(handler(uri));
|
||||
}
|
||||
|
||||
public Task<HttpServiceResult> SendAsync(
|
||||
Uri uri,
|
||||
string method = "GET",
|
||||
string? body = null,
|
||||
IReadOnlyDictionary<string, string>? headers = null,
|
||||
bool ensureSuccess = true,
|
||||
HttpRequestPolicy? policy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetAsync(uri, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubHttpHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(handler(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.App;
|
||||
using YMhut.Box.Core.Feedback;
|
||||
using YMhut.Box.Core.Logging;
|
||||
using YMhut.Box.Core.System;
|
||||
using YMhut.Box.Core.Tools;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class FeedbackServiceTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void FeedbackCodeCreatesServerCompatibleCode()
|
||||
{
|
||||
var code = FeedbackCode.Create(DateTimeOffset.Parse("2026-06-04T10:00:00+08:00"));
|
||||
|
||||
Assert.IsTrue(Regex.IsMatch(code, "^FB-20260604-[A-F0-9]{6}$"));
|
||||
Assert.AreEqual("FB-20260604-ABC123", FeedbackCode.Normalize(" fb-20260604-abc123 "));
|
||||
Assert.IsTrue(FeedbackCode.IsValid("fb-20260604-abc123"));
|
||||
Assert.IsFalse(FeedbackCode.IsValid("FB-20260604-XYZ123"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task FeedbackPackageIncludesSelectedAttachmentsAndLocalizedLogs()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-tests", Guid.NewGuid().ToString("N"));
|
||||
var paths = AppPaths.ForCurrentUser(tempRoot);
|
||||
var logs = new JsonFileLogService(paths);
|
||||
var service = new FeedbackPackageService(logs, new FakeSystemMetricsService(), new ToolCatalog());
|
||||
|
||||
await logs.WriteAsync("Information", "navigation", "Open toolbox", "https://example.com/private?token=secret");
|
||||
|
||||
var package = await service.BuildPackageAsync(new FeedbackRequest(
|
||||
"同步状态卡住",
|
||||
"issue",
|
||||
"major",
|
||||
"dev@example.com",
|
||||
"首页一直显示后台校验中。",
|
||||
IncludeTodayLogs: true,
|
||||
IncludeToolStatus: true,
|
||||
IncludeSystemSummary: true,
|
||||
Language: "zh-CN",
|
||||
FeedbackCode: "FB-20260604-ABC123"));
|
||||
|
||||
try
|
||||
{
|
||||
Assert.IsTrue(File.Exists(package.PackagePath));
|
||||
Assert.IsGreaterThan(0, package.PackageBytes);
|
||||
Assert.AreEqual(
|
||||
Path.GetFullPath(FeedbackPackageService.FeedbackPackagesRoot()),
|
||||
Path.GetFullPath(Path.GetDirectoryName(package.PackagePath)!));
|
||||
Assert.IsTrue(package.IncludedFiles.ContainsKey("feedback.json"));
|
||||
Assert.IsTrue(package.IncludedFiles.ContainsKey("tool-status.json"));
|
||||
Assert.IsTrue(package.IncludedFiles.ContainsKey("system-summary.json"));
|
||||
Assert.IsTrue(package.IncludedFiles.Keys.Any(name => name.StartsWith("logs-", StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.IsTrue(package.SummaryText.Contains("首页一直显示后台校验中。", StringComparison.Ordinal));
|
||||
Assert.IsTrue(package.SummaryText.Contains("FB-20260604-ABC123", StringComparison.Ordinal));
|
||||
|
||||
using var archive = ZipFile.OpenRead(package.PackagePath);
|
||||
Assert.IsNotNull(archive.GetEntry("feedback.json"));
|
||||
Assert.IsNotNull(archive.GetEntry("summary.txt"));
|
||||
var feedbackJson = ReadEntry(archive.GetEntry("feedback.json")!);
|
||||
using var feedbackDocument = JsonDocument.Parse(feedbackJson);
|
||||
Assert.AreEqual("FB-20260604-ABC123", feedbackDocument.RootElement.GetProperty("feedbackCode").GetString());
|
||||
Assert.AreEqual("FB-20260604-ABC123", feedbackDocument.RootElement.GetProperty("request").GetProperty("feedbackCode").GetString());
|
||||
|
||||
var logsEntry = archive.Entries.Single(entry => entry.FullName.StartsWith("logs-", StringComparison.OrdinalIgnoreCase));
|
||||
var logsJson = ReadEntry(logsEntry);
|
||||
using var logsDocument = JsonDocument.Parse(logsJson);
|
||||
var firstLog = logsDocument.RootElement[0];
|
||||
Assert.AreEqual("导航", firstLog.GetProperty("display").GetProperty("category").GetString());
|
||||
Assert.AreEqual("打开工具箱", firstLog.GetProperty("display").GetProperty("message").GetString());
|
||||
Assert.AreEqual("远程服务", firstLog.GetProperty("display").GetProperty("detail").GetString());
|
||||
Assert.AreEqual("远程服务", firstLog.GetProperty("raw").GetProperty("detail").GetString());
|
||||
Assert.IsFalse(logsJson.Contains("example.com/private", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var toolStatus = ReadEntry(archive.GetEntry("tool-status.json")!);
|
||||
StringAssert.Contains(toolStatus, "\"id\": \"compression_codec\"");
|
||||
StringAssert.Contains(toolStatus, "\"exists\": true");
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteIfExists(package.PackagePath);
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task FeedbackPackageCryptoRoundTripsLocalZip()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
var sourceZip = Path.Combine(tempRoot, "feedback.zip");
|
||||
var decryptedZip = Path.Combine(tempRoot, "feedback-decrypted.zip");
|
||||
|
||||
try
|
||||
{
|
||||
using (var archive = ZipFile.Open(sourceZip, ZipArchiveMode.Create))
|
||||
{
|
||||
var entry = archive.CreateEntry("summary.txt");
|
||||
await using var stream = entry.Open();
|
||||
await using var writer = new StreamWriter(stream);
|
||||
await writer.WriteAsync("反馈包加密测试");
|
||||
}
|
||||
|
||||
var encrypted = await FeedbackPackageCrypto.EncryptPackageAsync(sourceZip);
|
||||
Assert.IsTrue(File.Exists(encrypted.PackagePath));
|
||||
Assert.AreEqual(".ymfb", Path.GetExtension(encrypted.PackagePath));
|
||||
Assert.IsGreaterThan(new FileInfo(sourceZip).Length, encrypted.PackageBytes);
|
||||
|
||||
await FeedbackPackageCrypto.DecryptPackageAsync(encrypted.PackagePath, decryptedZip);
|
||||
|
||||
CollectionAssert.AreEqual(
|
||||
await File.ReadAllBytesAsync(sourceZip),
|
||||
await File.ReadAllBytesAsync(decryptedZip));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task FeedbackPackageRejectsScreenshotOverFiveMegabytes()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-tests", Guid.NewGuid().ToString("N"));
|
||||
var paths = AppPaths.ForCurrentUser(tempRoot);
|
||||
var logs = new JsonFileLogService(paths);
|
||||
var service = new FeedbackPackageService(logs, new FakeSystemMetricsService(), new ToolCatalog());
|
||||
var screenshot = Path.Combine(tempRoot, "large.png");
|
||||
await File.WriteAllBytesAsync(screenshot, new byte[FeedbackPackageService.MaxScreenshotBytes + 1]);
|
||||
|
||||
try
|
||||
{
|
||||
await Assert.ThrowsExactlyAsync<InvalidOperationException>(() =>
|
||||
service.BuildPackageAsync(new FeedbackRequest(
|
||||
"截图过大",
|
||||
"issue",
|
||||
"normal",
|
||||
string.Empty,
|
||||
"测试截图限制。",
|
||||
IncludeTodayLogs: false,
|
||||
IncludeToolStatus: false,
|
||||
IncludeSystemSummary: false,
|
||||
ScreenshotPath: screenshot)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FeedbackSubmissionSignatureIsStable()
|
||||
{
|
||||
var signature = FeedbackSubmissionService.Sign("1760000000", "abc123", new string('a', 64), "{\"ok\":true}");
|
||||
|
||||
Assert.AreEqual(64, signature.Length);
|
||||
Assert.IsTrue(signature.All(character => Uri.IsHexDigit(character) && !char.IsUpper(character)));
|
||||
Assert.AreEqual(signature, FeedbackSubmissionService.Sign("1760000000", "abc123", new string('a', 64), "{\"ok\":true}"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FeedbackStatusResponseParsesExtendedServerFields()
|
||||
{
|
||||
var response = ParseStatusResponse("""
|
||||
{
|
||||
"ok": true,
|
||||
"code": "FB-20260604-ABC123",
|
||||
"status": "investigating",
|
||||
"statusLabel": "处理中",
|
||||
"statusDetail": "已收到日志,正在定位。",
|
||||
"category": "issue",
|
||||
"priority": "major",
|
||||
"hasReply": true,
|
||||
"reply": "请先保留现场文件。",
|
||||
"receivedAt": "2026-06-04T10:00:00+08:00",
|
||||
"updatedAt": "2026-06-04T10:30:00+08:00",
|
||||
"mailSent": true
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.IsTrue(response.Ok);
|
||||
Assert.AreEqual("FB-20260604-ABC123", response.Code);
|
||||
Assert.AreEqual("investigating", response.Status);
|
||||
Assert.AreEqual("处理中", response.StatusLabel);
|
||||
Assert.AreEqual("已收到日志,正在定位。", response.StatusDetail);
|
||||
Assert.AreEqual("issue", response.Category);
|
||||
Assert.AreEqual("major", response.Priority);
|
||||
Assert.IsTrue(response.HasReply);
|
||||
Assert.AreEqual("请先保留现场文件。", response.Reply);
|
||||
Assert.AreEqual("2026-06-04T10:30:00+08:00", response.UpdatedAt);
|
||||
Assert.IsTrue(response.MailSent);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task FeedbackRecordStoreReadsOldRecordsWithoutExtendedFields()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-record-tests", Guid.NewGuid().ToString("N"));
|
||||
var storagePath = Path.Combine(tempRoot, "feedback-records.json");
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
await File.WriteAllTextAsync(storagePath, """
|
||||
[
|
||||
{
|
||||
"code": "FB-20260604-ABC123",
|
||||
"title": "Old record",
|
||||
"type": "issue",
|
||||
"severity": "normal",
|
||||
"createdAtUtc": "2026-06-04T02:00:00+00:00",
|
||||
"updatedAtUtc": "2026-06-04T02:10:00+00:00",
|
||||
"state": "Sent",
|
||||
"statusLabel": "Sent",
|
||||
"lastMessage": "The service received it."
|
||||
}
|
||||
]
|
||||
""");
|
||||
|
||||
try
|
||||
{
|
||||
var record = await new FeedbackRecordStore(storagePath).FindAsync("fb-20260604-abc123");
|
||||
|
||||
Assert.IsNotNull(record);
|
||||
Assert.AreEqual("FB-20260604-ABC123", record.Code);
|
||||
Assert.AreEqual("Sent", record.StatusLabel);
|
||||
Assert.IsNull(record.ServiceStatusDetail);
|
||||
Assert.IsNull(record.ServiceCategory);
|
||||
Assert.IsNull(record.ServicePriority);
|
||||
Assert.IsNull(record.ServiceMailSent);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task FeedbackRecordStoreKeepsStatusMessageAndPublicReplySeparate()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-record-tests", Guid.NewGuid().ToString("N"));
|
||||
var storagePath = Path.Combine(tempRoot, "feedback-records.json");
|
||||
var store = new FeedbackRecordStore(storagePath);
|
||||
var now = DateTimeOffset.Parse("2026-06-04T03:00:00Z");
|
||||
|
||||
try
|
||||
{
|
||||
await store.UpsertAsync(new FeedbackRecord(
|
||||
"FB-20260604-ABC123",
|
||||
"Status query",
|
||||
"issue",
|
||||
"major",
|
||||
now,
|
||||
now,
|
||||
FeedbackRecordState.StatusUpdated,
|
||||
"处理中",
|
||||
"已收到日志,正在定位。",
|
||||
LastQueryAtUtc: now,
|
||||
ServiceStatus: "investigating",
|
||||
PublicReply: "请先保留现场文件。",
|
||||
ServiceStatusDetail: "已收到日志,正在定位。",
|
||||
ServiceCategory: "issue",
|
||||
ServicePriority: "major",
|
||||
ServiceMailSent: true,
|
||||
ServiceReceivedAt: "2026-06-04T10:00:00+08:00",
|
||||
ServiceUpdatedAt: "2026-06-04T10:30:00+08:00"));
|
||||
|
||||
var record = await store.FindAsync("FB-20260604-ABC123");
|
||||
|
||||
Assert.IsNotNull(record);
|
||||
Assert.AreEqual("已收到日志,正在定位。", record.LastMessage);
|
||||
Assert.IsFalse(record.LastMessage.Contains("请先保留现场文件。", StringComparison.Ordinal));
|
||||
Assert.AreEqual("请先保留现场文件。", record.PublicReply);
|
||||
Assert.AreEqual("investigating", record.ServiceStatus);
|
||||
Assert.AreEqual("issue", record.ServiceCategory);
|
||||
Assert.AreEqual("major", record.ServicePriority);
|
||||
Assert.IsTrue(record.ServiceMailSent);
|
||||
Assert.AreEqual("2026-06-04T10:30:00+08:00", record.ServiceUpdatedAt);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task FeedbackRecordStoreArchivesOldRecordsAndKeepsThemQueryable()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-feedback-record-tests", Guid.NewGuid().ToString("N"));
|
||||
var storagePath = Path.Combine(tempRoot, "feedback-records.json");
|
||||
var store = new FeedbackRecordStore(storagePath);
|
||||
var now = DateTimeOffset.Parse("2026-06-05T12:00:00Z");
|
||||
var oldCreated = now.AddDays(-11);
|
||||
|
||||
try
|
||||
{
|
||||
await store.UpsertAsync(new FeedbackRecord(
|
||||
"FB-20260525-ABC123",
|
||||
"旧反馈",
|
||||
"issue",
|
||||
"normal",
|
||||
oldCreated,
|
||||
oldCreated,
|
||||
FeedbackRecordState.Sent,
|
||||
"发送成功",
|
||||
"服务端已接收。",
|
||||
PackagePath: Path.Combine(tempRoot, "feedback.zip"),
|
||||
PackageSha256: new string('a', 64),
|
||||
PackageBytes: 128,
|
||||
SubmittedAtUtc: oldCreated));
|
||||
|
||||
var archived = await store.ArchiveOlderThanAsync(TimeSpan.FromDays(10), now);
|
||||
var record = archived.Single();
|
||||
Assert.IsTrue(record.IsArchived);
|
||||
Assert.AreEqual(FeedbackRecordState.Archived, record.State);
|
||||
Assert.AreEqual("已归档", record.StatusLabel);
|
||||
Assert.IsNotNull(record.ArchivedAtUtc);
|
||||
|
||||
var updated = await store.UpsertAsync(record with
|
||||
{
|
||||
State = FeedbackRecordState.StatusUpdated,
|
||||
StatusLabel = "状态已更新",
|
||||
LastMessage = "当前状态:已处理。",
|
||||
UpdatedAtUtc = now.AddMinutes(5),
|
||||
LastQueryAtUtc = now.AddMinutes(5)
|
||||
});
|
||||
|
||||
Assert.IsTrue(updated.IsArchived);
|
||||
Assert.AreEqual(FeedbackRecordState.StatusUpdated, updated.State);
|
||||
Assert.AreEqual("状态已更新", updated.StatusLabel);
|
||||
Assert.AreEqual("当前状态:已处理。", updated.LastMessage);
|
||||
|
||||
var reloaded = await new FeedbackRecordStore(storagePath).FindAsync("fb-20260525-abc123");
|
||||
Assert.IsNotNull(reloaded);
|
||||
Assert.IsTrue(reloaded.IsArchived);
|
||||
Assert.AreEqual("FB-20260525-ABC123", reloaded.Code);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadEntry(ZipArchiveEntry entry)
|
||||
{
|
||||
using var stream = entry.Open();
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
private static FeedbackStatusResponse ParseStatusResponse(string json)
|
||||
{
|
||||
var method = typeof(FeedbackSubmissionService).GetMethod("ParseStatusResponse", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.IsNotNull(method);
|
||||
return (FeedbackStatusResponse)method.Invoke(null, new object?[] { json })!;
|
||||
}
|
||||
|
||||
private static void DeleteIfExists(string path)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSystemMetricsService : ISystemMetricsService
|
||||
{
|
||||
public SystemMetricsSnapshot Capture()
|
||||
{
|
||||
return new SystemMetricsSnapshot(
|
||||
"TEST-PC",
|
||||
"Windows Test",
|
||||
8,
|
||||
1024,
|
||||
2048,
|
||||
42.5,
|
||||
16UL * 1024 * 1024 * 1024,
|
||||
8UL * 1024 * 1024 * 1024,
|
||||
8UL * 1024 * 1024 * 1024,
|
||||
50,
|
||||
1.2,
|
||||
TimeSpan.FromMinutes(12),
|
||||
24,
|
||||
512,
|
||||
256UL * 1024 * 1024 * 1024,
|
||||
128UL * 1024 * 1024 * 1024,
|
||||
128UL * 1024 * 1024 * 1024,
|
||||
50,
|
||||
true,
|
||||
".NET 10.0",
|
||||
DateTimeOffset.Parse("2026-06-04T10:00:00+08:00"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.App;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
[DoNotParallelize]
|
||||
public sealed class InstallLayoutPathsTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void ResolveInstallRootPrefersCurrentInstallRootEnvironment()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-install-root-tests");
|
||||
var installRoot = workspace.CreateDirectory("install");
|
||||
var baseRoot = workspace.CreateDirectory("runtime");
|
||||
|
||||
using var environment = EnvironmentScope.Capture(
|
||||
InstallLayoutPaths.InstallRootEnvironmentVariable,
|
||||
InstallLayoutPaths.ArchivedLayoutEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
environment.Set(InstallLayoutPaths.ArchivedLayoutEnvironmentVariable, null);
|
||||
|
||||
Assert.AreEqual(installRoot, InstallLayoutPaths.ResolveInstallRoot(baseRoot));
|
||||
Assert.AreEqual(installRoot, InstallLayoutPaths.CandidateRoots(baseRoot).First());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ResolveInstallRootIgnoresLegacyArchivedLayoutForHealth()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-archived-layout-tests");
|
||||
var archivedRoot = workspace.CreateDirectory("archived");
|
||||
var baseRoot = workspace.CreateDirectory("runtime");
|
||||
workspace.CreateFile(["runtime", "YMhutBox.exe"], string.Empty);
|
||||
|
||||
using var environment = EnvironmentScope.Capture(
|
||||
InstallLayoutPaths.InstallRootEnvironmentVariable,
|
||||
InstallLayoutPaths.ArchivedLayoutEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, null);
|
||||
environment.Set(InstallLayoutPaths.ArchivedLayoutEnvironmentVariable, archivedRoot);
|
||||
|
||||
Assert.AreEqual(baseRoot, InstallLayoutPaths.ResolveInstallRoot(baseRoot));
|
||||
Assert.IsFalse(InstallLayoutPaths.CandidateRoots(baseRoot).Contains(archivedRoot));
|
||||
Assert.AreEqual(archivedRoot, InstallLayoutPaths.ResolveArchivedLayoutRoot());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ResolveAssetsRootAndExecutableUseInstallRoot()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-install-assets-tests");
|
||||
var installRoot = workspace.CreateDirectory("install");
|
||||
var assetsRoot = workspace.CreateDirectory("install", "Assets");
|
||||
var executable = workspace.CreateFile(["install", "YMhutBox.exe"], string.Empty);
|
||||
var runtimeRoot = workspace.CreateDirectory("runtime");
|
||||
|
||||
using var environment = EnvironmentScope.Capture(
|
||||
InstallLayoutPaths.InstallRootEnvironmentVariable,
|
||||
InstallLayoutPaths.ArchivedLayoutEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
environment.Set(InstallLayoutPaths.ArchivedLayoutEnvironmentVariable, null);
|
||||
|
||||
Assert.AreEqual(assetsRoot, InstallLayoutPaths.ResolveAssetsRoot(runtimeRoot));
|
||||
Assert.AreEqual(executable, InstallLayoutPaths.ResolveInstalledExecutablePath(runtimeRoot));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RuntimeLayoutPolicySkipsLargeToolsAndUserDataRoots()
|
||||
{
|
||||
foreach (var relativePath in new[]
|
||||
{
|
||||
"Tools/vendor/tool.exe",
|
||||
"Metadata/tools.json",
|
||||
"data/settings.json",
|
||||
"FeedbackPackages/feedback.zip"
|
||||
})
|
||||
{
|
||||
Assert.IsTrue(RuntimeLayoutPolicy.ShouldSkipRelativePath(relativePath), relativePath);
|
||||
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile(relativePath), relativePath);
|
||||
}
|
||||
|
||||
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile("unins000.exe"));
|
||||
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile("YMhutBox.exe"));
|
||||
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile("YMhutBox.dll"));
|
||||
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile("resources.pri"));
|
||||
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile("zh-CN/Microsoft.ui.xaml.dll.mui"));
|
||||
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile("lang/zh-CN/Microsoft.ui.xaml.dll.mui"));
|
||||
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile("runtimes/win-x64/native/Microsoft.ui.xaml.dll"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InstallManifestParserReadsReleaseRequiredFilesAndPayloadFiles()
|
||||
{
|
||||
var manifest = InstallManifest.Parse(
|
||||
"""
|
||||
[Release]
|
||||
Version=2.0.6
|
||||
Build=2
|
||||
Channel=stable
|
||||
PackageVersion=2.0.6.2
|
||||
|
||||
[RequiredFiles]
|
||||
YMhutBox.exe
|
||||
YMhutBox.dll
|
||||
|
||||
[Files]
|
||||
YMhutBox.exe
|
||||
lang/zh-CN/Microsoft.ui.xaml.dll.mui
|
||||
Tools/SampleTool/tool.exe
|
||||
""");
|
||||
|
||||
Assert.IsNotNull(manifest.Release);
|
||||
Assert.AreEqual("2.0.6", manifest.Release.Version);
|
||||
Assert.AreEqual("2", manifest.Release.Build);
|
||||
Assert.AreEqual("stable", manifest.Release.Channel);
|
||||
Assert.AreEqual("2.0.6.2", manifest.Release.PackageVersion);
|
||||
Assert.IsTrue(manifest.RequiredFiles.SequenceEqual(["YMhutBox.exe", "YMhutBox.dll"]));
|
||||
Assert.IsTrue(manifest.ContainsFile(@"lang\zh-CN\Microsoft.ui.xaml.dll.mui"));
|
||||
Assert.IsTrue(manifest.ContainsFile("Tools/SampleTool/tool.exe"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RuntimeLayoutPolicySkipsRuntimePayloadRoots()
|
||||
{
|
||||
foreach (var relativePath in new[]
|
||||
{
|
||||
"lang/en-US/Microsoft.UI.Xaml.Phone.dll.mui",
|
||||
"Microsoft.UI.Xaml/Microsoft.UI.Xaml.dll",
|
||||
"runtimes/win-x64/native/WebView2Loader.dll",
|
||||
"worker/YMhut.Box.Worker.exe"
|
||||
})
|
||||
{
|
||||
Assert.IsFalse(RuntimeLayoutPolicy.ShouldCopyFile(relativePath), relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SourceFilesDoNotContainCommonMojibakeSequences()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var scanRoots = new[]
|
||||
{
|
||||
Path.Combine(root, "src", "YMhut.Box.Core"),
|
||||
Path.Combine(root, "src", "box-winUI"),
|
||||
Path.Combine(root, "installer")
|
||||
};
|
||||
var extensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".cs", ".xaml", ".iss" };
|
||||
var mojibakeMarkers = new[]
|
||||
{
|
||||
"Ã",
|
||||
"Â",
|
||||
"â",
|
||||
"鈥",
|
||||
"锛",
|
||||
"銆",
|
||||
"鐑",
|
||||
"澶",
|
||||
"璁",
|
||||
"鏍",
|
||||
"棰",
|
||||
"缃"
|
||||
};
|
||||
|
||||
var offenders = scanRoots
|
||||
.Where(Directory.Exists)
|
||||
.SelectMany(scanRoot => Directory.EnumerateFiles(scanRoot, "*.*", SearchOption.AllDirectories))
|
||||
.Where(path => extensions.Contains(Path.GetExtension(path)))
|
||||
.Where(path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
.SelectMany(path => FindMojibakeMarkers(path, mojibakeMarkers))
|
||||
.Take(12)
|
||||
.ToArray();
|
||||
|
||||
Assert.IsEmpty(offenders, string.Join(Environment.NewLine, offenders));
|
||||
}
|
||||
|
||||
private static IEnumerable<string> FindMojibakeMarkers(string path, IReadOnlyList<string> markers)
|
||||
{
|
||||
var text = File.ReadAllText(path);
|
||||
foreach (var marker in markers)
|
||||
{
|
||||
if (text.Contains(marker, StringComparison.Ordinal))
|
||||
{
|
||||
yield return $"{Path.GetFileName(path)} contains '{marker}'";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(directory.FullName, "src")) &&
|
||||
Directory.Exists(Path.Combine(directory.FullName, "installer")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
Assert.Fail("Repository root could not be found from test output directory.");
|
||||
return AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
private sealed class EnvironmentScope : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, string?> _snapshot;
|
||||
|
||||
private EnvironmentScope(IEnumerable<string> names)
|
||||
{
|
||||
_snapshot = names.ToDictionary(name => name, Environment.GetEnvironmentVariable, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public static EnvironmentScope Capture(params string[] names) => new(names);
|
||||
|
||||
public void Set(string name, string? value) => Environment.SetEnvironmentVariable(name, value);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var item in _snapshot)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(item.Key, item.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestWorkspace : IDisposable
|
||||
{
|
||||
private TestWorkspace(string root)
|
||||
{
|
||||
Root = root;
|
||||
}
|
||||
|
||||
public string Root { get; }
|
||||
|
||||
public static TestWorkspace Create(string prefix)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), prefix, Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
return new TestWorkspace(root);
|
||||
}
|
||||
|
||||
public string CreateDirectory(params string[] parts)
|
||||
{
|
||||
var directory = Path.Combine([Root, .. parts]);
|
||||
Directory.CreateDirectory(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
public string CreateFile(string[] parts, string contents)
|
||||
{
|
||||
var path = Path.Combine([Root, .. parts]);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, contents);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Root))
|
||||
{
|
||||
Directory.Delete(Root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.App;
|
||||
using YMhut.Box.Core.Logging;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class LogServiceTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void LogDisplayLocalizerUsesEmptyDetailTextInsteadOfUnknownError()
|
||||
{
|
||||
Assert.AreEqual("暂无详情", LogDisplayLocalizer.Detail(null, "zh-CN"));
|
||||
Assert.AreEqual("No details", LogDisplayLocalizer.Detail(" ", "en-US"));
|
||||
Assert.AreEqual("真实详情", LogDisplayLocalizer.Detail("真实详情", "zh-CN"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LogDisplayLocalizerTranslatesCommonEnglishMessagesInChineseMode()
|
||||
{
|
||||
Assert.AreEqual("打开启动自检结果", LogDisplayLocalizer.Message("Open startup check results", "zh-CN"));
|
||||
Assert.AreEqual("工具目录已重建", LogDisplayLocalizer.Message("Tool catalog rebuilt", "zh-CN"));
|
||||
StringAssert.Contains(LogDisplayLocalizer.Message("External tool success: launch", "zh-CN"), "外部工具成功");
|
||||
Assert.AreEqual("外部工具成功", LogDisplayLocalizer.Message("External tool success", "zh-CN"));
|
||||
StringAssert.Contains(LogDisplayLocalizer.Message("Tool result canceled: export", "zh-CN"), "工具结果已取消");
|
||||
Assert.AreEqual("工具工作进程已就绪", LogDisplayLocalizer.Message("Tool worker ready", "zh-CN"));
|
||||
Assert.AreEqual("下载宿主进程已启动", LogDisplayLocalizer.Message("Download host process started", "zh-CN"));
|
||||
Assert.AreEqual("打开插件页面:sample/main", LogDisplayLocalizer.Message("Open plugin surface: sample/main", "zh-CN"));
|
||||
Assert.AreEqual("打开 JSON formatter", LogDisplayLocalizer.Message("Open JSON formatter", "zh-CN"));
|
||||
Assert.AreEqual("\u66f4\u65b0\u901a\u77e5\u68c0\u67e5\u5931\u8d25", LogDisplayLocalizer.Message("Update notice check failed", "zh-CN"));
|
||||
Assert.AreEqual("\u542f\u52a8\u81ea\u68c0\u7ed3\u679c\u9884\u52a0\u8f7d\u5931\u8d25", LogDisplayLocalizer.Message("Startup check result preload failed", "zh-CN"));
|
||||
Assert.AreEqual("\u8bbe\u7f6e\u9875\u52a0\u8f7d\u5931\u8d25", LogDisplayLocalizer.Message("Settings page load failed", "zh-CN"));
|
||||
Assert.AreEqual("\u5df2\u8bb0\u4f4f\u5173\u95ed\u786e\u8ba4\u9009\u62e9", LogDisplayLocalizer.Message("Close confirmation remembered", "zh-CN"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LogDisplayLocalizerTranslatesCommonDetailKeysInChineseMode()
|
||||
{
|
||||
var detail = LogDisplayLocalizer.Detail("result=success; operation=launch; elapsedMs=12; root=C:\\Tools; count=8", "zh-CN");
|
||||
|
||||
StringAssert.Contains(detail, "结果=成功");
|
||||
StringAssert.Contains(detail, "操作=启动");
|
||||
StringAssert.Contains(detail, "耗时毫秒=12");
|
||||
StringAssert.Contains(detail, "根目录=C:\\Tools");
|
||||
StringAssert.Contains(detail, "数量=8");
|
||||
|
||||
var windowDetail = LogDisplayLocalizer.Detail("source=tray; behavior=minimize_to_tray; result=primary; dialog=com_exception", "zh-CN");
|
||||
|
||||
StringAssert.Contains(windowDetail, "来源=托盘");
|
||||
StringAssert.Contains(windowDetail, "行为=最小化到托盘");
|
||||
StringAssert.Contains(windowDetail, "结果=主按钮");
|
||||
StringAssert.Contains(windowDetail, "弹窗=COM 异常");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ClearFilteredTodayDeletesOnlyMatchingRows()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var service = new SqliteLogService(AppPaths.ForCurrentUser(tempRoot));
|
||||
await service.WriteAsync("Information", "tool", "Open tool: JSON formatter");
|
||||
await service.WriteAsync("Warning", "network", "Proxy diagnostic failed");
|
||||
|
||||
await service.ClearFilteredTodayAsync("Information", "JSON");
|
||||
|
||||
var entries = await service.ReadAsync(take: 10);
|
||||
Assert.HasCount(1, entries);
|
||||
Assert.AreEqual("network", entries[0].Category);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ReadByDateReturnsAllMatchingRowsForSelectedDay()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var service = new SqliteLogService(AppPaths.ForCurrentUser(tempRoot));
|
||||
await service.WriteAsync("Information", "tool", "Open JSON formatter");
|
||||
await service.WriteAsync("Information", "tool", "Open URL codec");
|
||||
await service.WriteAsync("Warning", "network", "Proxy diagnostic failed");
|
||||
|
||||
var allToday = await service.ReadByDateAsync(DateOnly.FromDateTime(DateTime.Now));
|
||||
var filtered = await service.ReadByDateAsync(DateOnly.FromDateTime(DateTime.Now), "Information", "Open");
|
||||
|
||||
Assert.HasCount(3, allToday);
|
||||
Assert.HasCount(2, filtered);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ReadByDatePageReturnsStablePages()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var service = new SqliteLogService(AppPaths.ForCurrentUser(tempRoot));
|
||||
for (var index = 0; index < 25; index++)
|
||||
{
|
||||
await service.WriteAsync("Information", "stream", $"Row {index:00}");
|
||||
}
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||
var first = await service.ReadByDatePageAsync(today, "Information", "Row", skip: 0, take: 20);
|
||||
var second = await service.ReadByDatePageAsync(today, "Information", "Row", skip: 20, take: 20);
|
||||
|
||||
Assert.HasCount(20, first);
|
||||
Assert.HasCount(5, second);
|
||||
CollectionAssert.AreNotEquivalent(
|
||||
first.Select(entry => entry.Message).ToArray(),
|
||||
second.Select(entry => entry.Message).ToArray());
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.Media;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class MediaVolumeModelTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void FromPercentClampsAndMapsOutputModes()
|
||||
{
|
||||
var silent = MediaVolumeModel.FromPercent(-5);
|
||||
Assert.AreEqual(0, silent.Percent);
|
||||
Assert.AreEqual(0, silent.PlatformVolume, 0.001);
|
||||
Assert.AreEqual(0, silent.AudioGain, 0.001);
|
||||
Assert.AreEqual(MediaVolumeOutputMode.Silent, silent.Mode);
|
||||
|
||||
var normal = MediaVolumeModel.FromPercent(80);
|
||||
Assert.AreEqual(80, normal.Percent);
|
||||
Assert.AreEqual(0.8, normal.PlatformVolume, 0.001);
|
||||
Assert.AreEqual(0.8, normal.AudioGain, 0.001);
|
||||
Assert.AreEqual(MediaVolumeOutputMode.Normal, normal.Mode);
|
||||
|
||||
var boosted = MediaVolumeModel.FromPercent(120);
|
||||
Assert.AreEqual(120, boosted.Percent);
|
||||
Assert.AreEqual(1.0, boosted.PlatformVolume, 0.001);
|
||||
Assert.AreEqual(1.2, boosted.AudioGain, 0.001);
|
||||
Assert.AreEqual(MediaVolumeOutputMode.Boosted, boosted.Mode);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FormatPercentNamesMutedAndBoostedStates()
|
||||
{
|
||||
Assert.AreEqual("0%(静音)", MediaVolumeModel.FormatPercent(0, "zh-CN"));
|
||||
Assert.AreEqual("120%(增强音量)", MediaVolumeModel.FormatPercent(120, "zh-CN"));
|
||||
Assert.AreEqual("0% (Muted)", MediaVolumeModel.FormatPercent(0, "en-US"));
|
||||
Assert.AreEqual("120% (Boosted)", MediaVolumeModel.FormatPercent(120, "en-US"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using YMhut.Box.Core.App;
|
||||
using YMhut.Box.Core.Plugins;
|
||||
using YMhut.Box.Core.Settings;
|
||||
using YMhut.Box.Core.Tools;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class PluginTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task PluginRegistryLoadsValidManifestAndToolModule()
|
||||
{
|
||||
using var workspace = TempWorkspace();
|
||||
var pluginRoot = CreatePlugin(workspace.Path, "hello-tools", """
|
||||
{
|
||||
"id": "hello-tools",
|
||||
"name": "Hello Tools",
|
||||
"version": "1.0.0",
|
||||
"author": "tester",
|
||||
"description": "Test plugin",
|
||||
"entry": "index.html",
|
||||
"permissions": ["Input", "Output", "Log", "Storage"],
|
||||
"surfaces": [
|
||||
{ "kind": "ToolboxTool", "id": "hello", "name": "Hello", "description": "Hello tool", "entry": "index.html", "category": "dev" },
|
||||
{ "kind": "NavPage", "id": "home", "name": "Home", "description": "Home page", "entry": "index.html" }
|
||||
],
|
||||
"resources": ["index.html"]
|
||||
}
|
||||
""");
|
||||
File.WriteAllText(Path.Combine(pluginRoot, "index.html"), "<h1>Hello</h1>");
|
||||
var paths = AppPaths.ForCurrentUser(workspace.Path);
|
||||
var stateStore = new PluginStateStore(paths);
|
||||
await stateStore.SetEnabledAsync("hello-tools", true);
|
||||
|
||||
var registry = new PluginRegistryService(paths, stateStore);
|
||||
var plugins = await registry.LoadPluginsAsync();
|
||||
var tools = await registry.LoadEnabledToolModulesAsync();
|
||||
|
||||
Assert.HasCount(1, plugins);
|
||||
Assert.IsTrue(plugins[0].IsValid, string.Join(", ", plugins[0].Errors));
|
||||
Assert.HasCount(1, tools);
|
||||
Assert.AreEqual("plugin:hello-tools:hello", tools[0].Id);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task PluginRegistryRejectsInvalidAndCoreAssetOverride()
|
||||
{
|
||||
using var workspace = TempWorkspace();
|
||||
var pluginRoot = CreatePlugin(workspace.Path, "bad", """
|
||||
{
|
||||
"id": "plugin:bad",
|
||||
"name": "Bad",
|
||||
"version": "1.0.0",
|
||||
"author": "tester",
|
||||
"description": "Bad plugin",
|
||||
"entry": "index.html",
|
||||
"permissions": [],
|
||||
"surfaces": [
|
||||
{ "kind": "ToolboxTool", "id": "json_formatter", "name": "Override", "description": "conflict", "entry": "index.html" }
|
||||
],
|
||||
"resources": ["Assets/icons/app_icon.ico"]
|
||||
}
|
||||
""");
|
||||
File.WriteAllText(Path.Combine(pluginRoot, "index.html"), "<h1>Bad</h1>");
|
||||
var paths = AppPaths.ForCurrentUser(workspace.Path);
|
||||
var registry = new PluginRegistryService(paths, new PluginStateStore(paths));
|
||||
|
||||
var plugin = (await registry.LoadPluginsAsync()).Single();
|
||||
|
||||
Assert.IsFalse(plugin.IsValid);
|
||||
Assert.IsTrue(plugin.Errors.Any(error => error.Contains("plugin:", StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.IsTrue(plugin.Errors.Any(error => error.Contains("built-in", StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.IsTrue(plugin.Errors.Any(error => error.Contains("Core asset", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task PluginStateStorePersistsStatePermissionsSurfacesAndKv()
|
||||
{
|
||||
using var workspace = TempWorkspace();
|
||||
var paths = AppPaths.ForCurrentUser(workspace.Path);
|
||||
var store = new PluginStateStore(paths);
|
||||
|
||||
await store.SetEnabledAsync("demo", true);
|
||||
await store.SetPermissionAsync("demo", PluginPermission.Log, true);
|
||||
await store.SetSurfaceMountedAsync("demo", "tool", true);
|
||||
await store.SetValueAsync("demo", "name", "value");
|
||||
|
||||
var secondStore = new PluginStateStore(paths);
|
||||
var state = await secondStore.GetStateAsync("demo");
|
||||
var values = await secondStore.ListValuesAsync("demo");
|
||||
|
||||
Assert.IsTrue(state.Enabled);
|
||||
CollectionAssert.Contains(state.GrantedPermissions.ToList(), PluginPermission.Log);
|
||||
CollectionAssert.Contains(state.MountedSurfaceIds.ToList(), "tool");
|
||||
Assert.AreEqual("value", values["name"]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task PluginToolsMergeIntoToolCatalogWhenEnabled()
|
||||
{
|
||||
using var workspace = TempWorkspace();
|
||||
var pluginRoot = CreatePlugin(workspace.Path, "merge-demo", """
|
||||
{
|
||||
"id": "merge-demo",
|
||||
"name": "Merge Demo",
|
||||
"version": "1.0.0",
|
||||
"author": "tester",
|
||||
"description": "Merge plugin",
|
||||
"entry": "index.html",
|
||||
"permissions": [],
|
||||
"surfaces": [
|
||||
{ "kind": "ToolboxTool", "id": "tool", "name": "Merged Tool", "description": "Merged", "entry": "index.html" }
|
||||
],
|
||||
"resources": ["index.html"]
|
||||
}
|
||||
""");
|
||||
File.WriteAllText(Path.Combine(pluginRoot, "index.html"), "<h1>Merged</h1>");
|
||||
var paths = AppPaths.ForCurrentUser(workspace.Path);
|
||||
var store = new PluginStateStore(paths);
|
||||
await store.SetEnabledAsync("merge-demo", true);
|
||||
var registry = new PluginRegistryService(paths, store);
|
||||
|
||||
var catalog = new ToolCatalog(ToolCatalog.DefaultModules().Concat(await registry.LoadEnabledToolModulesAsync()));
|
||||
|
||||
Assert.IsNotNull(catalog.GetById("plugin:merge-demo:tool"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task PluginRegistryHonorsSettingsEnabledAndCustomRoot()
|
||||
{
|
||||
using var workspace = TempWorkspace();
|
||||
var paths = AppPaths.ForCurrentUser(workspace.Path);
|
||||
var settings = new AppSettingsService(new AppSettingsStore(paths.Root));
|
||||
await settings.LoadAsync();
|
||||
var store = new PluginStateStore(paths);
|
||||
var defaultPluginRoot = CreatePlugin(workspace.Path, "default-demo", BasicManifest("default-demo"));
|
||||
File.WriteAllText(Path.Combine(defaultPluginRoot, "index.html"), "<h1>Default</h1>");
|
||||
await store.SetEnabledAsync("default-demo", true);
|
||||
var registry = new PluginRegistryService(paths, store, settingsService: settings);
|
||||
|
||||
Assert.IsFalse(settings.Current.PluginsEnabled);
|
||||
Assert.HasCount(0, await registry.LoadEnabledToolModulesAsync());
|
||||
|
||||
var customPluginsRoot = Path.Combine(workspace.Path, "ExternalPlugins");
|
||||
var customPluginRoot = CreatePluginAtRoot(customPluginsRoot, "custom-demo", BasicManifest("custom-demo"));
|
||||
File.WriteAllText(Path.Combine(customPluginRoot, "index.html"), "<h1>Custom</h1>");
|
||||
await store.SetEnabledAsync("custom-demo", true);
|
||||
await settings.UpdateAsync(value =>
|
||||
{
|
||||
value.PluginsEnabled = true;
|
||||
value.PluginRootPath = customPluginsRoot;
|
||||
});
|
||||
|
||||
Assert.AreEqual(Path.GetFullPath(customPluginsRoot), registry.PluginsRoot);
|
||||
var tools = await registry.LoadEnabledToolModulesAsync();
|
||||
Assert.HasCount(1, tools);
|
||||
Assert.AreEqual("plugin:custom-demo:tool", tools[0].Id);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task PluginRegistryRequiresReadmeFile()
|
||||
{
|
||||
using var workspace = TempWorkspace();
|
||||
var pluginRoot = CreatePlugin(workspace.Path, "no-readme", BasicManifest("no-readme"));
|
||||
File.WriteAllText(Path.Combine(pluginRoot, "index.html"), "<h1>No readme</h1>");
|
||||
File.Delete(Path.Combine(pluginRoot, "README.md"));
|
||||
var paths = AppPaths.ForCurrentUser(workspace.Path);
|
||||
var registry = new PluginRegistryService(paths, new PluginStateStore(paths));
|
||||
|
||||
var plugin = (await registry.LoadPluginsAsync()).Single();
|
||||
|
||||
Assert.IsFalse(plugin.IsValid);
|
||||
Assert.IsTrue(plugin.Errors.Any(error => error.Contains("README", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task PluginSnapshotRoundTripsEnabledToolModules()
|
||||
{
|
||||
using var workspace = TempWorkspace();
|
||||
var pluginRoot = CreatePlugin(workspace.Path, "snapshot-demo", BasicManifest("snapshot-demo"));
|
||||
File.WriteAllText(Path.Combine(pluginRoot, "index.html"), "<h1>Snapshot</h1>");
|
||||
var paths = AppPaths.ForCurrentUser(workspace.Path);
|
||||
var store = new PluginStateStore(paths);
|
||||
await store.SetEnabledAsync("snapshot-demo", true);
|
||||
var registry = new PluginRegistryService(paths, store);
|
||||
var plugin = (await registry.LoadPluginsAsync()).Single();
|
||||
var tool = (await registry.LoadEnabledToolModulesAsync()).Single();
|
||||
|
||||
var snapshot = new PluginSnapshot(
|
||||
true,
|
||||
registry.PluginsRoot,
|
||||
[LoadedPluginDto.FromLoadedPlugin(plugin)],
|
||||
[PluginToolDto.FromPluginToolModule(tool)],
|
||||
DateTimeOffset.Now);
|
||||
var message = new PluginHostMessage(PluginHostProtocol.SnapshotChanged, Snapshot: snapshot);
|
||||
var roundTrip = PluginHostProtocol.Deserialize(PluginHostProtocol.Serialize(message));
|
||||
|
||||
Assert.IsNotNull(roundTrip?.Snapshot);
|
||||
Assert.HasCount(1, roundTrip.Snapshot.EnabledTools);
|
||||
Assert.AreEqual("plugin:snapshot-demo:tool", roundTrip.Snapshot.EnabledTools[0].ToToolModule().Id);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task BuiltInPluginInstallerCopiesMissingPluginOnly()
|
||||
{
|
||||
using var workspace = TempWorkspace();
|
||||
var paths = AppPaths.ForCurrentUser(Path.Combine(workspace.Path, "App"));
|
||||
var installer = new BuiltInPluginInstallerService(paths);
|
||||
await installer.EnsureInstalledAsync();
|
||||
|
||||
var installed = Path.Combine(paths.Root, "Plugins", "ipcheck-demo", "index.html");
|
||||
var installedReadme = Path.Combine(paths.Root, "Plugins", "ipcheck-demo", "README.md");
|
||||
var installedManifest = Path.Combine(paths.Root, "Plugins", "ipcheck-demo", PluginManifest.FileName);
|
||||
Assert.IsTrue(File.Exists(installed));
|
||||
Assert.IsTrue(File.Exists(installedReadme));
|
||||
Assert.IsTrue(File.Exists(installedManifest));
|
||||
Assert.IsTrue(File.ReadAllText(installed).Contains("IPCheck 网络工具箱", StringComparison.OrdinalIgnoreCase));
|
||||
var manifestText = File.ReadAllText(installedManifest);
|
||||
Assert.IsTrue(manifestText.Contains("\"Http\"", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.IsTrue(manifestText.Contains("\"OpenExternal\"", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.IsTrue(manifestText.Contains("\"README.md\"", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
File.WriteAllText(installed, "<h1>User version</h1>");
|
||||
await installer.EnsureInstalledAsync();
|
||||
|
||||
Assert.AreEqual("<h1>User version</h1>", File.ReadAllText(installed));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task BuiltInSamplePluginIsValidAndDocumentsBridgeCapabilities()
|
||||
{
|
||||
using var workspace = TempWorkspace();
|
||||
var paths = AppPaths.ForCurrentUser(Path.Combine(workspace.Path, "App"));
|
||||
var installer = new BuiltInPluginInstallerService(paths);
|
||||
await installer.EnsureInstalledAsync();
|
||||
|
||||
var stateStore = new PluginStateStore(paths);
|
||||
var registry = new PluginRegistryService(paths, stateStore);
|
||||
var plugin = (await registry.LoadPluginsAsync()).Single(item => item.Manifest.Id == "ipcheck-demo");
|
||||
|
||||
Assert.IsTrue(plugin.IsValid, string.Join("; ", plugin.Errors));
|
||||
CollectionAssert.Contains(plugin.Manifest.Permissions.ToList(), PluginPermission.Storage);
|
||||
CollectionAssert.Contains(plugin.Manifest.Permissions.ToList(), PluginPermission.Output);
|
||||
CollectionAssert.Contains(plugin.Manifest.Permissions.ToList(), PluginPermission.OpenExternal);
|
||||
CollectionAssert.Contains(plugin.Manifest.Resources.ToList(), "README.md");
|
||||
|
||||
var readme = File.ReadAllText(Path.Combine(plugin.RootPath, "README.md"));
|
||||
StringAssert.Contains(readme, "Bridge 能力示例");
|
||||
StringAssert.Contains(readme, "安全浏览器");
|
||||
StringAssert.Contains(readme, "AI 实现提示");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PluginDocsAndToolResultAssetsExposeRequiredSections()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var pluginDocs = File.ReadAllText(Path.Combine(root, "docs", "plugins", "README.md"));
|
||||
var aiDocs = File.ReadAllText(Path.Combine(root, "docs", "plugins", "AI-INTEGRATION.md"));
|
||||
var helpPage = File.ReadAllText(Path.Combine(root, "src", "box-winUI", "Views", "PluginDocsPage.cs"));
|
||||
var resultJs = File.ReadAllText(Path.Combine(root, "src", "box-winUI", "Assets", "tool-results", "result.js"));
|
||||
var resultCss = File.ReadAllText(Path.Combine(root, "src", "box-winUI", "Assets", "tool-results", "result.css"));
|
||||
var pageJs = File.ReadAllText(Path.Combine(root, "src", "box-winUI", "Assets", "tool-pages", "tool-page.js"));
|
||||
var pageCss = File.ReadAllText(Path.Combine(root, "src", "box-winUI", "Assets", "tool-pages", "tool-page.css"));
|
||||
|
||||
StringAssert.Contains(pluginDocs, "权限");
|
||||
StringAssert.Contains(pluginDocs, "安全边界");
|
||||
StringAssert.Contains(pluginDocs, "独立窗口");
|
||||
StringAssert.Contains(aiDocs, "Acceptance Checklist");
|
||||
StringAssert.Contains(helpPage, "输出区");
|
||||
StringAssert.Contains(helpPage, "常见问题");
|
||||
|
||||
StringAssert.Contains(resultJs, "openSystemBrowser");
|
||||
StringAssert.Contains(pageJs, "openSystemBrowser");
|
||||
StringAssert.Contains(resultCss, "rank-1");
|
||||
StringAssert.Contains(pageCss, "rank-1");
|
||||
StringAssert.Contains(resultCss, "prefers-reduced-motion");
|
||||
StringAssert.Contains(pageCss, "prefers-reduced-motion");
|
||||
}
|
||||
|
||||
private static string CreatePlugin(string root, string folder, string manifest)
|
||||
{
|
||||
var pluginRoot = Path.Combine(root, "Plugins", folder);
|
||||
Directory.CreateDirectory(pluginRoot);
|
||||
File.WriteAllText(Path.Combine(pluginRoot, PluginManifest.FileName), manifest);
|
||||
File.WriteAllText(Path.Combine(pluginRoot, "README.md"), "# Test plugin");
|
||||
return pluginRoot;
|
||||
}
|
||||
|
||||
private static string CreatePluginAtRoot(string pluginsRoot, string folder, string manifest)
|
||||
{
|
||||
var pluginRoot = Path.Combine(pluginsRoot, folder);
|
||||
Directory.CreateDirectory(pluginRoot);
|
||||
File.WriteAllText(Path.Combine(pluginRoot, PluginManifest.FileName), manifest);
|
||||
File.WriteAllText(Path.Combine(pluginRoot, "README.md"), "# Test plugin");
|
||||
return pluginRoot;
|
||||
}
|
||||
|
||||
private static string BasicManifest(string id) => $$"""
|
||||
{
|
||||
"id": "{{id}}",
|
||||
"name": "{{id}}",
|
||||
"version": "1.0.0",
|
||||
"author": "tester",
|
||||
"description": "Test plugin",
|
||||
"entry": "index.html",
|
||||
"permissions": [],
|
||||
"surfaces": [
|
||||
{ "kind": "ToolboxTool", "id": "tool", "name": "Tool", "description": "Tool", "entry": "index.html" }
|
||||
],
|
||||
"resources": ["index.html"]
|
||||
}
|
||||
""";
|
||||
|
||||
private static TempDirectory TempWorkspace()
|
||||
{
|
||||
return new TempDirectory(Path.Combine(Path.GetTempPath(), "ymhut-plugin-tests", Guid.NewGuid().ToString("N")));
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(directory.FullName, "src")) &&
|
||||
Directory.Exists(Path.Combine(directory.FullName, "docs")) &&
|
||||
File.Exists(Path.Combine(directory.FullName, "YMhut.Box.Native.sln")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate repository root from test output path.");
|
||||
}
|
||||
|
||||
private sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public TempDirectory(string path)
|
||||
{
|
||||
Path = path;
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.App;
|
||||
using YMhut.Box.Core.Logging;
|
||||
using YMhut.Box.Core.Tools;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
[DoNotParallelize]
|
||||
public sealed class ReferenceToolsTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task ExternalToolCatalogScansLaunchablesFromToolsDirectory()
|
||||
{
|
||||
var category = "YMhutTestTools_" + Guid.NewGuid().ToString("N");
|
||||
var toolsRoot = Path.Combine(AppContext.BaseDirectory, "Tools");
|
||||
var categoryRoot = Path.Combine(toolsRoot, category);
|
||||
var toolRoot = Path.Combine(categoryRoot, "SampleTool");
|
||||
Directory.CreateDirectory(toolRoot);
|
||||
var launchPath = Path.Combine(toolRoot, "SampleTool.cmd");
|
||||
await File.WriteAllTextAsync(launchPath, "@echo off\r\necho YMhut");
|
||||
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-reference-tests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var service = new ExternalToolCatalogService(AppPaths.ForCurrentUser(tempRoot));
|
||||
|
||||
var modules = await service.GetModulesAsync();
|
||||
var module = modules.FirstOrDefault(item => item.Tool.CategoryName == category);
|
||||
|
||||
Assert.IsNotNull(module);
|
||||
Assert.AreEqual("SampleTool", module.Tool.Name);
|
||||
Assert.AreEqual("CMD", module.Tool.Extension);
|
||||
Assert.AreEqual(ReferenceToolRiskLevel.High, module.Tool.RiskLevel);
|
||||
Assert.IsTrue(module.Id.StartsWith(ExternalToolModule.IdPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(categoryRoot))
|
||||
{
|
||||
Directory.Delete(categoryRoot, recursive: true);
|
||||
}
|
||||
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ExternalToolCatalogUsesInstallRootForToolsAndMetadata()
|
||||
{
|
||||
var category = "InstallRootCategory_" + Guid.NewGuid().ToString("N");
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-install-tool-tests", Guid.NewGuid().ToString("N"));
|
||||
var installRoot = Path.Combine(tempRoot, "install");
|
||||
var toolRoot = Path.Combine(installRoot, "Tools", category, "MetaTool");
|
||||
var metadataRoot = Path.Combine(installRoot, "Metadata");
|
||||
var appRoot = Path.Combine(tempRoot, "appdata");
|
||||
var launchPath = Path.Combine(toolRoot, "MetaTool.cmd");
|
||||
|
||||
var oldInstallRoot = Environment.GetEnvironmentVariable(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
var oldArchivedRoot = Environment.GetEnvironmentVariable(InstallLayoutPaths.ArchivedLayoutEnvironmentVariable);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(toolRoot);
|
||||
Directory.CreateDirectory(metadataRoot);
|
||||
await File.WriteAllTextAsync(launchPath, "@echo off\r\necho install-root");
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(metadataRoot, "tools.json"),
|
||||
"""
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"match": "MetaTool",
|
||||
"description": "Install root metadata description",
|
||||
"publisher": "YMhut Tests",
|
||||
"launchTarget": "MetaTool.cmd",
|
||||
"tags": [ "install-root" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Environment.SetEnvironmentVariable(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
Environment.SetEnvironmentVariable(InstallLayoutPaths.ArchivedLayoutEnvironmentVariable, null);
|
||||
|
||||
var service = new ExternalToolCatalogService(AppPaths.ForCurrentUser(appRoot));
|
||||
var modules = await service.GetModulesAsync();
|
||||
var module = modules.FirstOrDefault(item => item.Tool.CategoryName == category);
|
||||
|
||||
Assert.AreEqual(Path.Combine(installRoot, "Tools"), service.ResolveToolsRoot());
|
||||
Assert.IsNotNull(module);
|
||||
Assert.AreEqual("MetaTool", module.Tool.Name);
|
||||
Assert.AreEqual("Install root metadata description", module.Tool.Description);
|
||||
Assert.AreEqual("YMhut Tests", module.Tool.Publisher);
|
||||
Assert.Contains("install-root", module.Tool.Tags);
|
||||
Assert.IsTrue(module.Tool.LaunchPath.StartsWith(installRoot, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(InstallLayoutPaths.InstallRootEnvironmentVariable, oldInstallRoot);
|
||||
Environment.SetEnvironmentVariable(InstallLayoutPaths.ArchivedLayoutEnvironmentVariable, oldArchivedRoot);
|
||||
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task RiskConfirmationStorePersistsAndResetsChoices()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-risk-tests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var paths = AppPaths.ForCurrentUser(tempRoot);
|
||||
var store = new RiskConfirmationStore(paths);
|
||||
|
||||
await store.RememberAsync("builtin:hosts-editor", "execute");
|
||||
|
||||
var secondStore = new RiskConfirmationStore(paths);
|
||||
Assert.IsTrue(await secondStore.IsRememberedAsync("builtin:hosts-editor", "execute"));
|
||||
|
||||
await secondStore.ResetAsync();
|
||||
Assert.IsFalse(await secondStore.IsRememberedAsync("builtin:hosts-editor", "execute"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task OpenSourceReferenceServiceLoadsBundledToolNoticesFromInstallRoot()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-install-reference-tests", Guid.NewGuid().ToString("N"));
|
||||
var installRoot = Path.Combine(tempRoot, "install");
|
||||
var noticePath = Path.Combine(installRoot, "Tools", "Category", "VendorTool", "LICENSE.txt");
|
||||
var appRoot = Path.Combine(tempRoot, "appdata");
|
||||
|
||||
var oldInstallRoot = Environment.GetEnvironmentVariable(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
var oldArchivedRoot = Environment.GetEnvironmentVariable(InstallLayoutPaths.ArchivedLayoutEnvironmentVariable);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(noticePath)!);
|
||||
await File.WriteAllTextAsync(noticePath, "Vendor license text");
|
||||
|
||||
Environment.SetEnvironmentVariable(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
Environment.SetEnvironmentVariable(InstallLayoutPaths.ArchivedLayoutEnvironmentVariable, null);
|
||||
|
||||
var service = new OpenSourceReferenceService(AppPaths.ForCurrentUser(appRoot));
|
||||
var references = await service.GetReferencesAsync();
|
||||
|
||||
Assert.IsTrue(references.Any(item =>
|
||||
item.Kind == "Third-party tool notice" &&
|
||||
string.Equals(item.Path, noticePath, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(InstallLayoutPaths.InstallRootEnvironmentVariable, oldInstallRoot);
|
||||
Environment.SetEnvironmentVariable(InstallLayoutPaths.ArchivedLayoutEnvironmentVariable, oldArchivedRoot);
|
||||
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuiltinReferenceCatalogRegistersExpectedToolsAndRiskLevels()
|
||||
{
|
||||
var catalog = new BuiltinReferenceToolCatalog();
|
||||
var modules = catalog.GetModules();
|
||||
var ids = modules.Select(module => module.Definition.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var id in new[]
|
||||
{
|
||||
"cert-block",
|
||||
"port-viewer",
|
||||
"hosts-editor",
|
||||
"keyboard-test",
|
||||
"junk-cleaner",
|
||||
"bsod-analysis",
|
||||
"winget-installer",
|
||||
"battery-report",
|
||||
"speed-test",
|
||||
"wifi-password",
|
||||
"disk-space-analyzer",
|
||||
"lite-monitor",
|
||||
"windows-activation",
|
||||
"defender-control",
|
||||
"cpu-ranking",
|
||||
"gpu-ranking",
|
||||
"context-menu-mgr"
|
||||
})
|
||||
{
|
||||
Assert.Contains(id, ids);
|
||||
}
|
||||
|
||||
Assert.AreEqual(ReferenceToolRiskLevel.High, catalog.GetByModuleId("builtin:hosts-editor")?.RiskLevel);
|
||||
Assert.AreEqual(ReferenceToolRiskLevel.None, catalog.GetByModuleId("builtin:windows-activation")?.RiskLevel);
|
||||
Assert.HasCount(17, modules);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ResilientLogServiceFallsBackToJsonlWhenPrimaryFails()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-log-fallback-tests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var service = new ResilientLogService(new ThrowingLogService(), AppPaths.ForCurrentUser(tempRoot));
|
||||
|
||||
await service.WriteAsync("Information", "tool-run", "External tool success", "toolId=external:test");
|
||||
var entries = await service.ReadAsync(take: 10);
|
||||
|
||||
Assert.HasCount(1, entries);
|
||||
Assert.AreEqual("tool-run", entries[0].Category);
|
||||
Assert.IsTrue(File.Exists(Path.Combine(tempRoot, "Logs", "app-log-fallback.jsonl")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task OpenSourceReferenceServiceLoadsPackagesAndBundledToolNotices()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-reference-attribution-tests", Guid.NewGuid().ToString("N"));
|
||||
var toolsRoot = Path.Combine(AppContext.BaseDirectory, "Tools");
|
||||
var noticeCategory = "YMhutTestNotice_" + Guid.NewGuid().ToString("N");
|
||||
var noticeCategoryRoot = Path.Combine(toolsRoot, noticeCategory);
|
||||
var noticePath = Path.Combine(noticeCategoryRoot, "SampleVendor", "LICENSE.txt");
|
||||
try
|
||||
{
|
||||
var projectRoot = Path.Combine(tempRoot, "src", "FakeProject");
|
||||
Directory.CreateDirectory(projectRoot);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(projectRoot, "FakeProject.csproj"),
|
||||
"""
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Sample.Reference.Package" Version="1.2.3" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
""");
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(noticePath)!);
|
||||
await File.WriteAllTextAsync(noticePath, "Sample vendor license");
|
||||
|
||||
var service = new OpenSourceReferenceService(AppPaths.ForCurrentUser(tempRoot));
|
||||
var references = await service.GetReferencesAsync();
|
||||
|
||||
Assert.IsTrue(references.Any(item => item.Kind == "Reference project attribution" && item.Name.Contains("tubatool", StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.IsTrue(references.Any(item => item.Kind == "NuGet package" && item.Name == "Sample.Reference.Package" && item.Version == "1.2.3"));
|
||||
Assert.IsTrue(references.Any(item => item.Kind == "Third-party tool notice" && item.Path == noticePath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(noticeCategoryRoot))
|
||||
{
|
||||
Directory.Delete(noticeCategoryRoot, recursive: true);
|
||||
}
|
||||
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingLogService : ILogService
|
||||
{
|
||||
public event EventHandler<LogEntry>? EntryWritten;
|
||||
|
||||
public string LogPath => "throwing";
|
||||
|
||||
public Task WriteAsync(string level, string category, string message, string? detail = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EntryWritten?.Invoke(this, new LogEntry(DateTimeOffset.Now, level, category, message, detail));
|
||||
throw new IOException("primary failed");
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LogEntry>> ReadAsync(string? level = null, string? category = null, int take = 500, CancellationToken cancellationToken = default)
|
||||
=> throw new IOException("primary failed");
|
||||
|
||||
public Task<IReadOnlyList<LogEntry>> ReadByDateAsync(DateOnly date, string? level = null, string? query = null, int take = 0, CancellationToken cancellationToken = default)
|
||||
=> throw new IOException("primary failed");
|
||||
|
||||
public Task<IReadOnlyList<LogEntry>> ReadByDatePageAsync(DateOnly date, string? level = null, string? query = null, int skip = 0, int take = 100, CancellationToken cancellationToken = default)
|
||||
=> throw new IOException("primary failed");
|
||||
|
||||
public Task CleanupAsync(int retentionCount, CancellationToken cancellationToken = default)
|
||||
=> throw new IOException("primary failed");
|
||||
|
||||
public Task ClearAllAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new IOException("primary failed");
|
||||
|
||||
public Task ClearTodayAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new IOException("primary failed");
|
||||
|
||||
public Task ClearFilteredTodayAsync(string? level = null, string? query = null, CancellationToken cancellationToken = default)
|
||||
=> throw new IOException("primary failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using YMhut.Box.Core.Api;
|
||||
using YMhut.Box.Core.App;
|
||||
using YMhut.Box.Core.Media;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class RemoteMediaCatalogTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void ParsesCurrentMediaTypesSnapshot()
|
||||
{
|
||||
var catalog = RemoteMediaCatalogParser.Parse(ReadRepoFile("server", "update", "public", "media-types.json"));
|
||||
|
||||
Assert.AreEqual("1.0.6", catalog.LayoutVersion);
|
||||
Assert.AreEqual("grid", catalog.UiConfig.DefaultView);
|
||||
Assert.IsTrue(catalog.Categories.Count >= 2);
|
||||
|
||||
var image = catalog.Categories.Single(category => category.Id == "image");
|
||||
Assert.IsTrue(image.Enabled);
|
||||
Assert.AreEqual(RemoteMediaKind.Image, image.Kind);
|
||||
Assert.IsTrue(image.Layout.ShowPreview);
|
||||
CollectionAssert.Contains(image.Sources.First(source => source.Id == "xjj").SupportedFormats.ToArray(), "jpg");
|
||||
Assert.AreEqual(30, image.Sources.First(source => source.Id == "xjj").RefreshIntervalSeconds);
|
||||
|
||||
var video = catalog.Categories.Single(category => category.Id == "video");
|
||||
Assert.AreEqual(RemoteMediaKind.Video, video.Kind);
|
||||
Assert.IsFalse(video.Layout.AutoPlay);
|
||||
CollectionAssert.Contains(video.Sources.First().SupportedFormats.ToArray(), "mp4");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsesLegacyMediaTypesSnapshot()
|
||||
{
|
||||
var catalog = RemoteMediaCatalogParser.Parse(ReadRepoFile("box-old", "server", "media-types.json"));
|
||||
|
||||
Assert.AreEqual("1.0.8", catalog.LayoutVersion);
|
||||
Assert.IsTrue(catalog.Categories.Count >= 2);
|
||||
Assert.IsTrue(catalog.Categories.Any(category => category.Id == "image" && category.Sources.Count >= 7));
|
||||
Assert.IsTrue(catalog.Categories.Any(category => category.Id == "video" && category.Sources.Any(source => source.Id == "radom_xjj_mv")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AppliesDefaultsWhenOptionalFieldsAreMissing()
|
||||
{
|
||||
const string minimal = """
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"id": "video",
|
||||
"subcategories": [
|
||||
{
|
||||
"id": "demo",
|
||||
"api_url": "https://example.test/media"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var catalog = RemoteMediaCatalogParser.Parse(minimal);
|
||||
var category = catalog.Categories.Single();
|
||||
var source = category.Sources.Single();
|
||||
|
||||
Assert.IsTrue(category.Enabled);
|
||||
Assert.AreEqual(RemoteMediaKind.Video, category.Kind);
|
||||
Assert.AreEqual(1, category.Layout.Columns);
|
||||
Assert.AreEqual("16:9", category.Layout.AspectRatio);
|
||||
Assert.IsTrue(category.Layout.ShowPreview);
|
||||
Assert.IsFalse(category.Layout.AutoPlay);
|
||||
Assert.IsTrue(source.Downloadable);
|
||||
Assert.AreEqual(60, source.RefreshIntervalSeconds);
|
||||
CollectionAssert.AreEqual(new[] { "mp4", "webm" }, source.SupportedFormats.ToArray());
|
||||
Assert.AreEqual("https://example.test/media", source.ThumbnailUrl);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ServiceWritesReadsFallsBackAndClearsCache()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ymhut-remote-media-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var paths = new AppPaths(root);
|
||||
paths.EnsureCreated();
|
||||
var content = ReadRepoFile("server", "update", "public", "media-types.json");
|
||||
var api = new FakeApiManager(content);
|
||||
var service = new RemoteMediaCatalogService(paths, api);
|
||||
|
||||
var remote = await service.LoadAsync(forceRefresh: true);
|
||||
|
||||
Assert.AreEqual(RemoteMediaCatalogLoadSource.Remote, remote.Source);
|
||||
Assert.IsTrue(api.LastUri?.Query.Contains("_=", StringComparison.Ordinal) == true);
|
||||
Assert.IsTrue(File.Exists(Path.Combine(paths.Cache, "remote-media", "media-types.json")));
|
||||
|
||||
var fallback = new RemoteMediaCatalogService(paths, new FakeApiManager(string.Empty, success: false));
|
||||
var cached = await fallback.LoadAsync();
|
||||
|
||||
Assert.AreEqual(RemoteMediaCatalogLoadSource.Cache, cached.Source);
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(cached.Warning));
|
||||
|
||||
await fallback.ClearCacheAsync();
|
||||
Assert.IsFalse(Directory.Exists(Path.Combine(paths.Cache, "remote-media")));
|
||||
Assert.IsTrue(Directory.Exists(paths.Cache));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadRepoFile(params string[] segments)
|
||||
{
|
||||
var directory = new DirectoryInfo(Directory.GetCurrentDirectory());
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray());
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return File.ReadAllText(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Unable to locate repository sample file.");
|
||||
}
|
||||
|
||||
private sealed class FakeApiManager(string content, bool success = true) : IApiManager
|
||||
{
|
||||
public Uri? LastUri { get; private set; }
|
||||
|
||||
public Task<ApiResponse> FetchAsync(string endpointId, string input = "", CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastUri = RemoteMediaCatalogService.PrimaryConfigUri;
|
||||
return Task.FromResult(new ApiResponse(
|
||||
endpointId,
|
||||
LastUri,
|
||||
success,
|
||||
success ? content : string.Empty,
|
||||
success ? null : "offline",
|
||||
DateTimeOffset.Now,
|
||||
success ? 200 : 0));
|
||||
}
|
||||
|
||||
public Task<ApiResponse> FetchUriAsync(string endpointId, Uri uri, string input = "", CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastUri = uri;
|
||||
return Task.FromResult(new ApiResponse(
|
||||
endpointId,
|
||||
uri,
|
||||
success,
|
||||
success ? content : string.Empty,
|
||||
success ? null : "offline",
|
||||
DateTimeOffset.Now,
|
||||
success ? 200 : 0));
|
||||
}
|
||||
|
||||
public Task<ApiHealthStatus> CheckHealthAsync(string endpointId, string input = "", CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(success ? ApiHealthStatus.Healthy : ApiHealthStatus.Unhealthy);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Net;
|
||||
using YMhut.Box.Core.Media;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class RemoteMediaResolverTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task ResolvesRedirectToDirectMedia()
|
||||
{
|
||||
var resolver = CreateResolver(request =>
|
||||
{
|
||||
if (request.RequestUri?.AbsolutePath == "/api")
|
||||
{
|
||||
return Redirect("/media/demo.mp4");
|
||||
}
|
||||
|
||||
return Text(HttpStatusCode.OK, string.Empty, "video/mp4");
|
||||
});
|
||||
|
||||
var result = await resolver.ResolveMediaAsync("https://example.test/api", RemoteMediaKind.Video, cacheBust: false);
|
||||
|
||||
Assert.AreEqual("https://example.test/media/demo.mp4", result.Uri.AbsoluteUri);
|
||||
Assert.IsTrue(result.IsDirectMedia);
|
||||
Assert.AreEqual("video/mp4", result.ContentType);
|
||||
Assert.AreEqual(".mp4", result.SuggestedExtension);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ExtractsPlainTextUrl()
|
||||
{
|
||||
var resolver = CreateResolver(request =>
|
||||
{
|
||||
if (request.RequestUri?.Host == "cdn.test")
|
||||
{
|
||||
return Text(HttpStatusCode.OK, string.Empty, "video/mp4");
|
||||
}
|
||||
|
||||
return Text(HttpStatusCode.OK, "https://cdn.test/random/clip.mp4", "text/plain");
|
||||
});
|
||||
|
||||
var result = await resolver.ResolveMediaAsync("https://example.test/api", RemoteMediaKind.Video, cacheBust: false);
|
||||
|
||||
Assert.AreEqual("https://cdn.test/random/clip.mp4", result.Uri.AbsoluteUri);
|
||||
Assert.IsTrue(result.IsDirectMedia);
|
||||
Assert.AreEqual(".mp4", result.SuggestedExtension);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ExtractsNestedJsonUrl()
|
||||
{
|
||||
var resolver = CreateResolver(request =>
|
||||
{
|
||||
if (request.RequestUri?.Host == "cdn.test")
|
||||
{
|
||||
return Text(HttpStatusCode.OK, string.Empty, "image/webp");
|
||||
}
|
||||
|
||||
return Text(HttpStatusCode.OK, """
|
||||
{
|
||||
"data": {
|
||||
"url": "https://cdn.test/images/pic.webp"
|
||||
}
|
||||
}
|
||||
""", "application/json");
|
||||
});
|
||||
|
||||
var result = await resolver.ResolveMediaAsync("https://example.test/api", RemoteMediaKind.Image, cacheBust: false);
|
||||
|
||||
Assert.AreEqual("https://cdn.test/images/pic.webp", result.Uri.AbsoluteUri);
|
||||
Assert.IsTrue(result.IsDirectMedia);
|
||||
Assert.AreEqual(".webp", result.SuggestedExtension);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task KeepsHtmlWithoutMediaUrlAsNonDirect()
|
||||
{
|
||||
var resolver = CreateResolver(_ => Text(HttpStatusCode.OK, "<html><body>No media here</body></html>", "text/html"));
|
||||
|
||||
var result = await resolver.ResolveMediaAsync("https://example.test/page", RemoteMediaKind.Video, cacheBust: false);
|
||||
|
||||
Assert.AreEqual("https://example.test/page", result.Uri.AbsoluteUri);
|
||||
Assert.IsFalse(result.IsDirectMedia);
|
||||
Assert.AreEqual("text/html", result.ContentType);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TreatsDirectMediaContentTypeAsPlayable()
|
||||
{
|
||||
var resolver = CreateResolver(_ => Text(HttpStatusCode.OK, string.Empty, "audio/mpeg"));
|
||||
|
||||
var result = await resolver.ResolveMediaAsync("https://example.test/random", RemoteMediaKind.Audio, cacheBust: false);
|
||||
|
||||
Assert.AreEqual("https://example.test/random", result.Uri.AbsoluteUri);
|
||||
Assert.IsTrue(result.IsDirectMedia);
|
||||
Assert.AreEqual(".mp3", result.SuggestedExtension);
|
||||
}
|
||||
|
||||
private static RemoteMediaResolver CreateResolver(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
{
|
||||
return new RemoteMediaResolver(() => new StubHttpHandler(responseFactory));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Redirect(string location)
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.Redirect);
|
||||
response.Headers.Location = new Uri(location, UriKind.RelativeOrAbsolute);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Text(HttpStatusCode statusCode, string content, string contentType)
|
||||
{
|
||||
return new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(content, System.Text.Encoding.UTF8, contentType)
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubHttpHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = responseFactory(request);
|
||||
response.RequestMessage = request;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class SensitiveTextTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void SanitizeRemovesInternalRemoteDetails()
|
||||
{
|
||||
var sanitized = SensitiveText.Sanitize("failed https://update.ymhut.cn/update-info.json?token=abc media-types.json download_url api_url");
|
||||
|
||||
Assert.DoesNotContain("https://", sanitized);
|
||||
Assert.DoesNotContain("update.ymhut.cn", sanitized);
|
||||
Assert.DoesNotContain("update-info.json", sanitized);
|
||||
Assert.DoesNotContain("media-types.json", sanitized);
|
||||
Assert.DoesNotContain("download_url", sanitized);
|
||||
Assert.DoesNotContain("api_url", sanitized);
|
||||
Assert.Contains("远程服务", sanitized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.App;
|
||||
using YMhut.Box.Core.Api;
|
||||
using YMhut.Box.Core.Data;
|
||||
using YMhut.Box.Core.Logging;
|
||||
using YMhut.Box.Core.Settings;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class ServiceLayerTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task SettingsServiceBroadcastsAndPersistsChanges()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
|
||||
var service = new AppSettingsService(new AppSettingsStore(root));
|
||||
var changed = false;
|
||||
service.PropertyChanged += (_, args) => changed |= args.PropertyName == nameof(ISettingsService.Current);
|
||||
|
||||
await service.LoadAsync();
|
||||
await service.UpdateAsync(settings => settings.Theme = "Dark");
|
||||
|
||||
var reloaded = await new AppSettingsStore(root).LoadAsync();
|
||||
Assert.IsTrue(changed);
|
||||
Assert.AreEqual("Dark", reloaded.Theme);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task SettingsStorePersistsAndNormalizesFeedbackDefaults()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
|
||||
var store = new AppSettingsStore(root);
|
||||
await store.SaveAsync(new AppSettings
|
||||
{
|
||||
FeedbackDefaultContact = "dev@example.com",
|
||||
FeedbackDefaultType = "bad-type",
|
||||
FeedbackDefaultSeverity = "bad-severity",
|
||||
FeedbackIncludeTodayLogsByDefault = true,
|
||||
FeedbackIncludeToolStatusByDefault = true,
|
||||
FeedbackIncludeSystemSummaryByDefault = true,
|
||||
FeedbackRememberDefaults = false
|
||||
});
|
||||
|
||||
var settings = await store.LoadAsync();
|
||||
|
||||
Assert.AreEqual("dev@example.com", settings.FeedbackDefaultContact);
|
||||
Assert.AreEqual("issue", settings.FeedbackDefaultType);
|
||||
Assert.AreEqual("normal", settings.FeedbackDefaultSeverity);
|
||||
Assert.IsTrue(settings.FeedbackIncludeTodayLogsByDefault);
|
||||
Assert.IsTrue(settings.FeedbackIncludeToolStatusByDefault);
|
||||
Assert.IsTrue(settings.FeedbackIncludeSystemSummaryByDefault);
|
||||
Assert.IsFalse(settings.FeedbackRememberDefaults);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task SettingsStorePersistsToolboxAndRiskSettings()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
|
||||
var store = new AppSettingsStore(root);
|
||||
await store.SaveAsync(new AppSettings
|
||||
{
|
||||
ToolDisplayMode = "compact",
|
||||
ToolboxDefaultScope = "plugins",
|
||||
ToolboxCompactCards = true,
|
||||
ToolboxShowRecentFirst = false,
|
||||
ShowHardwareBrandLogo = false,
|
||||
RequireRiskConfirmation = false,
|
||||
LogRetentionCount = 90,
|
||||
PluginsEnabled = true
|
||||
});
|
||||
|
||||
var settings = await store.LoadAsync();
|
||||
|
||||
Assert.AreEqual("list", settings.ToolDisplayMode);
|
||||
Assert.AreEqual("plugin", settings.ToolboxDefaultScope);
|
||||
Assert.IsTrue(settings.ToolboxCompactCards);
|
||||
Assert.IsFalse(settings.ToolboxShowRecentFirst);
|
||||
Assert.IsFalse(settings.ShowHardwareBrandLogo);
|
||||
Assert.IsFalse(settings.RequireRiskConfirmation);
|
||||
Assert.AreEqual(90, settings.LogRetentionCount);
|
||||
Assert.IsTrue(settings.PluginsEnabled);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task LogServiceWritesReadsAndCleansUp()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
|
||||
var service = new JsonFileLogService(AppPaths.ForCurrentUser(root));
|
||||
|
||||
await service.WriteAsync("Information", "test", "first");
|
||||
await service.WriteAsync("Error", "test", "second");
|
||||
var errors = await service.ReadAsync("Error");
|
||||
|
||||
Assert.HasCount(1, errors);
|
||||
Assert.AreEqual("second", errors[0].Message);
|
||||
|
||||
await service.CleanupAsync(1);
|
||||
var all = await service.ReadAsync();
|
||||
Assert.HasCount(1, all);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ApiEndpointsResolveWeather()
|
||||
{
|
||||
var ok = ApiEndpoints.TryResolve("weather", "Shanghai", out var endpoint, out var uri);
|
||||
|
||||
Assert.IsTrue(ok);
|
||||
Assert.AreEqual("weather", endpoint.Id);
|
||||
StringAssert.Contains(endpoint.SourceName, "Open-Meteo");
|
||||
StringAssert.Contains(uri.Host, "open-meteo.com");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReferenceDataServiceValidatesRequiredAssets()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
|
||||
var assets = Path.Combine(root, "Assets");
|
||||
Directory.CreateDirectory(Path.Combine(assets, "data", "reference"));
|
||||
File.WriteAllText(Path.Combine(assets, "data", "reference", "ymhut_reference_data.json"), "{}");
|
||||
|
||||
var service = new ReferenceDataService(AppPaths.ForCurrentUser(root, assets));
|
||||
var result = service.ValidateRequiredAssets("data/reference/ymhut_reference_data.json", "data/missing.json");
|
||||
|
||||
Assert.IsFalse(result.IsHealthy);
|
||||
Assert.HasCount(1, result.PresentPaths);
|
||||
Assert.HasCount(1, result.MissingPaths);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Net;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.App;
|
||||
using YMhut.Box.Core.Net;
|
||||
using YMhut.Box.Core.SolarSystem;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class SolarEphemerisServiceTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void MiriadeParserReadsRectangularPxPyPzPayload()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"Date": 0.24611925E+7,
|
||||
"px": -0.32062933285171752E+0,
|
||||
"py": 0.14714396284300654E+0,
|
||||
"pz": 0.41432383444950593E-1
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var parsed = SolarEphemerisService.TryParseMiriadePayload(json, out var vector);
|
||||
|
||||
Assert.IsTrue(parsed);
|
||||
Assert.AreEqual(-0.32062933285171752, vector.X, 1e-12);
|
||||
Assert.AreEqual(0.14714396284300654, vector.Y, 1e-12);
|
||||
Assert.AreEqual(0.041432383445, vector.Z, 1e-12);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task CurrentEphemerisReturnsApproximatePayloadWhenPreciseCacheMissing()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ymhut-solar-tests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var service = new SolarEphemerisService(
|
||||
AppPaths.ForCurrentUser(root),
|
||||
new FakeHttpService(_ => Task.FromException<string>(new InvalidOperationException())));
|
||||
|
||||
var payload = await service.GetCurrentAsync();
|
||||
|
||||
Assert.AreEqual("ephemeris:sync", payload.Type);
|
||||
Assert.AreEqual("China Ephemeris Approx", payload.Source);
|
||||
Assert.HasCount(8, payload.Planets);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task PreciseEphemerisRequestsMiriadePlanetsConcurrently()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ymhut-solar-tests", Guid.NewGuid().ToString("N"));
|
||||
var requestTimes = new List<DateTimeOffset>();
|
||||
try
|
||||
{
|
||||
var service = new SolarEphemerisService(
|
||||
AppPaths.ForCurrentUser(root),
|
||||
new FakeHttpService(async uri =>
|
||||
{
|
||||
lock (requestTimes)
|
||||
{
|
||||
requestTimes.Add(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
await Task.Delay(80);
|
||||
if (uri.Host.Contains("ssp.imcce.fr", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return """
|
||||
{
|
||||
"data": [
|
||||
{ "px": 1.0, "py": 2.0, "pz": 3.0 }
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unexpected endpoint");
|
||||
}));
|
||||
|
||||
var payload = await service.GetPreciseCurrentAsync();
|
||||
|
||||
Assert.IsNotNull(payload);
|
||||
Assert.AreEqual("IMCCE Miriade", payload.Source);
|
||||
Assert.HasCount(8, payload.Planets);
|
||||
Assert.HasCount(8, requestTimes);
|
||||
Assert.IsLessThan(70, (requestTimes.Max() - requestTimes.Min()).TotalMilliseconds);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeHttpService(Func<Uri, Task<string>> handler) : IHttpService
|
||||
{
|
||||
public async Task<HttpServiceResult> GetAsync(Uri uri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var content = await GetStringAsync(uri, cancellationToken);
|
||||
return new HttpServiceResult(HttpStatusCode.OK, content, new Dictionary<string, string[]>(), TimeSpan.FromMilliseconds(1));
|
||||
}
|
||||
|
||||
public Task<string> GetStringAsync(Uri uri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return handler(uri);
|
||||
}
|
||||
|
||||
public Task<HttpServiceResult> SendAsync(
|
||||
Uri uri,
|
||||
string method = "GET",
|
||||
string? body = null,
|
||||
IReadOnlyDictionary<string, string>? headers = null,
|
||||
bool ensureSuccess = true,
|
||||
HttpRequestPolicy? policy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetAsync(uri, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using YMhut.Box.Core.App;
|
||||
using YMhut.Box.Core.Startup;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
[DoNotParallelize]
|
||||
public sealed class StartupCheckTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task FastPreflightPassesForPureLangInstallLayout()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-startup-fast");
|
||||
var installRoot = CreateHealthyInstall(workspace);
|
||||
var appRoot = workspace.CreateDirectory("user");
|
||||
|
||||
using var environment = EnvironmentScope.Capture(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(appRoot);
|
||||
var store = new StartupCheckStore(paths);
|
||||
var service = new InstallIntegrityCheckService(paths, store);
|
||||
|
||||
var report = await service.RunFastPreflightAsync();
|
||||
|
||||
Assert.AreEqual(StartupCheckKind.FastPreflight, report.Kind);
|
||||
Assert.AreEqual(0, report.CriticalIssueCount);
|
||||
Assert.IsTrue(report.Items.Any(item => item.Id == "lang:zh-CN" && item.Status == StartupCheckStatus.Passed));
|
||||
Assert.IsTrue(report.Items.Any(item => item.Id == "lang:en-US" && item.Status == StartupCheckStatus.Passed));
|
||||
Assert.IsFalse(Directory.Exists(Path.Combine(appRoot, "Runtime")));
|
||||
StringAssert.StartsWith(store.DatabasePath, Path.Combine(appRoot, "Logs"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task FastPreflightDoesNotBlockDevelopmentRootCultureLayout()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-startup-dev-layout");
|
||||
var installRoot = CreateDevelopmentInstall(workspace);
|
||||
var appRoot = workspace.CreateDirectory("user");
|
||||
|
||||
using var environment = EnvironmentScope.Capture(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(appRoot);
|
||||
var service = new InstallIntegrityCheckService(paths, new StartupCheckStore(paths));
|
||||
|
||||
var report = await service.RunFastPreflightAsync();
|
||||
var resourcesPri = report.Items.FirstOrDefault(item => item.Id == "file:resources.pri");
|
||||
var zhCn = report.Items.FirstOrDefault(item => item.Id == "lang:zh-CN");
|
||||
var enUs = report.Items.FirstOrDefault(item => item.Id == "lang:en-US");
|
||||
|
||||
Assert.AreEqual(0, report.CriticalIssueCount);
|
||||
Assert.IsNotNull(resourcesPri);
|
||||
Assert.AreEqual(StartupCheckStatus.Missing, resourcesPri.Status);
|
||||
Assert.AreEqual(StartupCheckSeverity.Warning, resourcesPri.Severity);
|
||||
Assert.IsNotNull(zhCn);
|
||||
Assert.AreEqual(StartupCheckStatus.Passed, zhCn.Status);
|
||||
Assert.IsNotNull(enUs);
|
||||
Assert.AreEqual(StartupCheckStatus.Passed, enUs.Status);
|
||||
Assert.IsFalse(report.Items.Any(item => item.Id.StartsWith("lang:root:", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task FastPreflightAcceptsWinUiDevelopmentPriAndRootCultureSatellites()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-startup-dev-pri");
|
||||
var installRoot = CreateDevelopmentInstall(workspace);
|
||||
var appRoot = workspace.CreateDirectory("user");
|
||||
workspace.CreateFile(["install", "YMhutBox.pri"], string.Empty);
|
||||
workspace.CreateFile(["install", "fr-FR", "Microsoft.ui.xaml.dll.mui"], string.Empty);
|
||||
workspace.CreateFile(["install", "ja-JP", "Microsoft.ui.xaml.dll.mui"], string.Empty);
|
||||
workspace.CreateFile(["install", "Tools", "Sample", "tool.exe"], string.Empty);
|
||||
workspace.CreateFile(["install", "Metadata", "tools.json"], """{ "tools": [] }""");
|
||||
|
||||
using var environment = EnvironmentScope.Capture(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(appRoot);
|
||||
var service = new InstallIntegrityCheckService(paths, new StartupCheckStore(paths));
|
||||
|
||||
var report = await service.RunFastPreflightAsync();
|
||||
|
||||
Assert.AreEqual(0, report.VisibleIssueCount);
|
||||
Assert.IsTrue(report.Items.Any(item => item.Id == "manifest" && item.Status == StartupCheckStatus.Skipped));
|
||||
Assert.IsTrue(report.Items.Any(item => item.Id == "file:resources.pri" && item.Status == StartupCheckStatus.Passed));
|
||||
Assert.IsTrue(report.Items.Any(item => item.Id == "lang:root-culture-summary" && item.Status == StartupCheckStatus.Skipped));
|
||||
Assert.IsFalse(report.Items.Any(item => item.Id.StartsWith("lang:root:", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task CurrentHistoryNormalizesLegacyDevelopmentLayoutFalsePositives()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-startup-legacy-false-positive");
|
||||
var installRoot = CreateDevelopmentInstall(workspace);
|
||||
var appRoot = workspace.CreateDirectory("user");
|
||||
workspace.CreateFile(["install", "YMhutBox.pri"], string.Empty);
|
||||
workspace.CreateFile(["install", "fr-FR", "Microsoft.ui.xaml.dll.mui"], string.Empty);
|
||||
workspace.CreateFile(["install", "Tools", "Sample", "tool.exe"], string.Empty);
|
||||
workspace.CreateFile(["install", "Metadata", "tools.json"], """{ "tools": [] }""");
|
||||
|
||||
using var environment = EnvironmentScope.Capture(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(appRoot);
|
||||
var store = new StartupCheckStore(paths);
|
||||
var manifestIdentity = InstallRootContext.BuildManifestIdentity(installRoot);
|
||||
var oldReport = StartupCheckReport.Create(
|
||||
StartupCheckKind.FastPreflight,
|
||||
new DateTimeOffset(2026, 6, 1, 8, 0, 0, TimeSpan.Zero),
|
||||
installRoot,
|
||||
manifestIdentity,
|
||||
InstallRootContext.BuildInstallIdentity(installRoot, manifestIdentity),
|
||||
$"installRoot={installRoot}; manifest={manifestIdentity}",
|
||||
[
|
||||
new StartupCheckItem("manifest", "安装清单", StartupCheckSeverity.Warning, StartupCheckStatus.Missing, "文件缺失", Path.Combine(installRoot, "config", "install-manifest.ini")),
|
||||
new StartupCheckItem("file:resources.pri", "resources.pri", StartupCheckSeverity.Warning, StartupCheckStatus.Missing, "文件缺失", Path.Combine(installRoot, "resources.pri")),
|
||||
new StartupCheckItem("lang:zh-CN", "语言资源 zh-CN", StartupCheckSeverity.Warning, StartupCheckStatus.Missing, "语言目录缺失", Path.Combine(installRoot, "lang", "zh-CN")),
|
||||
new StartupCheckItem("lang:root:fr-FR", "根目录语言资源 fr-FR", StartupCheckSeverity.Warning, StartupCheckStatus.Failed, "旧版误判", Path.Combine(installRoot, "fr-FR"))
|
||||
]);
|
||||
await store.SaveAsync(oldReport);
|
||||
|
||||
var service = new InstallIntegrityCheckService(paths, store);
|
||||
|
||||
var latest = await service.GetLatestCurrentReportAsync(StartupCheckKind.FastPreflight);
|
||||
var history = await service.GetCurrentHistoryAsync(10);
|
||||
|
||||
Assert.IsNotNull(latest);
|
||||
Assert.AreEqual(0, latest.VisibleIssueCount);
|
||||
Assert.AreEqual(StartupCheckStatus.Skipped, latest.Items.First(item => item.Id == "manifest").Status);
|
||||
Assert.AreEqual(StartupCheckStatus.Passed, latest.Items.First(item => item.Id == "file:resources.pri").Status);
|
||||
Assert.AreEqual(StartupCheckStatus.Passed, latest.Items.First(item => item.Id == "lang:zh-CN").Status);
|
||||
Assert.AreEqual(StartupCheckStatus.Skipped, latest.Items.First(item => item.Id == "lang:root:fr-FR").Status);
|
||||
Assert.AreEqual(0, history[0].VisibleIssueCount);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MonthlyIntegrityUsesRecentReportUntilManifestIdentityChanges()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-startup-monthly");
|
||||
var installRoot = CreateHealthyInstall(workspace);
|
||||
var appRoot = workspace.CreateDirectory("user");
|
||||
var now = new DateTimeOffset(2026, 6, 14, 9, 0, 0, TimeSpan.Zero);
|
||||
|
||||
using var environment = EnvironmentScope.Capture(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(appRoot);
|
||||
var store = new StartupCheckStore(paths);
|
||||
var service = new InstallIntegrityCheckService(paths, store, clock: () => now);
|
||||
|
||||
var first = await service.RunMonthlyIntegrityCheckAsync();
|
||||
now = now.AddDays(5);
|
||||
var cached = await service.RunMonthlyIntegrityCheckAsync();
|
||||
|
||||
Assert.AreEqual(first.Id, cached.Id);
|
||||
|
||||
File.AppendAllText(Path.Combine(installRoot, InstallManifest.RelativePath.Replace('/', Path.DirectorySeparatorChar)), Environment.NewLine + "; changed");
|
||||
now = now.AddDays(1);
|
||||
var changed = await service.RunMonthlyIntegrityCheckAsync();
|
||||
|
||||
Assert.AreNotEqual(first.Id, changed.Id);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MonthlyIntegrityDoesNotReuseReportFromDifferentInstallRoot()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-startup-monthly-root");
|
||||
var installRoot = CreateHealthyInstall(workspace);
|
||||
var secondRoot = workspace.CreateDirectory("install2");
|
||||
CopyDirectory(installRoot, secondRoot);
|
||||
var appRoot = workspace.CreateDirectory("user");
|
||||
var now = new DateTimeOffset(2026, 6, 14, 9, 0, 0, TimeSpan.Zero);
|
||||
|
||||
using var environment = EnvironmentScope.Capture(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(appRoot);
|
||||
var store = new StartupCheckStore(paths);
|
||||
var service = new InstallIntegrityCheckService(paths, store, clock: () => now);
|
||||
var first = await service.RunMonthlyIntegrityCheckAsync();
|
||||
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, secondRoot);
|
||||
now = now.AddDays(1);
|
||||
var second = await service.RunMonthlyIntegrityCheckAsync();
|
||||
|
||||
Assert.AreNotEqual(first.Id, second.Id);
|
||||
Assert.AreNotEqual(first.InstallIdentity, second.InstallIdentity);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task IntegrityReportsMissingExternalToolLaunchableAsWarning()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-startup-tool-missing");
|
||||
var installRoot = CreateHealthyInstall(workspace);
|
||||
var appRoot = workspace.CreateDirectory("user");
|
||||
Directory.CreateDirectory(Path.Combine(installRoot, "Tools", "Sample"));
|
||||
File.WriteAllText(Path.Combine(installRoot, "Tools", "Sample", "present.exe"), string.Empty);
|
||||
File.AppendAllText(
|
||||
Path.Combine(installRoot, InstallManifest.RelativePath.Replace('/', Path.DirectorySeparatorChar)),
|
||||
Environment.NewLine + "Tools/Sample/missing.exe");
|
||||
|
||||
using var environment = EnvironmentScope.Capture(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(appRoot);
|
||||
var service = new InstallIntegrityCheckService(paths, new StartupCheckStore(paths));
|
||||
|
||||
var report = await service.RunManualIntegrityCheckAsync();
|
||||
var missing = report.Items.FirstOrDefault(item => item.Id == "file:Tools/Sample/missing.exe");
|
||||
|
||||
Assert.IsNotNull(missing);
|
||||
Assert.AreEqual(StartupCheckStatus.Missing, missing.Status);
|
||||
Assert.AreEqual(StartupCheckSeverity.Warning, missing.Severity);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task UserDataRuntimePayloadIsRemovedBeforePreflight()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-startup-userdata");
|
||||
var installRoot = CreateHealthyInstall(workspace);
|
||||
var appRoot = workspace.CreateDirectory("user");
|
||||
var runtimeRoot = Path.Combine(appRoot, "Runtime");
|
||||
var runtimesRoot = Path.Combine(appRoot, "runtimes");
|
||||
Directory.CreateDirectory(runtimeRoot);
|
||||
Directory.CreateDirectory(runtimesRoot);
|
||||
File.WriteAllText(Path.Combine(runtimeRoot, "YMhutBox.exe"), string.Empty);
|
||||
File.WriteAllText(Path.Combine(runtimesRoot, "Microsoft.ui.xaml.dll"), string.Empty);
|
||||
|
||||
using var environment = EnvironmentScope.Capture(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(appRoot);
|
||||
Assert.IsFalse(Directory.Exists(runtimeRoot));
|
||||
Assert.IsFalse(Directory.Exists(runtimesRoot));
|
||||
|
||||
var service = new InstallIntegrityCheckService(paths, new StartupCheckStore(paths));
|
||||
|
||||
var report = await service.RunFastPreflightAsync();
|
||||
var runtime = report.Items.FirstOrDefault(item => item.Id == "userdata:runtime");
|
||||
|
||||
Assert.IsNotNull(runtime);
|
||||
Assert.AreEqual(StartupCheckStatus.Passed, runtime.Status);
|
||||
Assert.IsFalse(Directory.Exists(runtimeRoot));
|
||||
Assert.IsFalse(Directory.Exists(runtimesRoot));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task UserDataToolsAndMetadataPayloadsAreRemovedBeforePreflight()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-startup-userdata-tools");
|
||||
var installRoot = CreateHealthyInstall(workspace);
|
||||
var appRoot = workspace.CreateDirectory("user");
|
||||
|
||||
using var environment = EnvironmentScope.Capture(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(appRoot);
|
||||
var toolsRoot = Path.Combine(appRoot, "Tools");
|
||||
var metadataRoot = Path.Combine(appRoot, "Metadata");
|
||||
Directory.CreateDirectory(toolsRoot);
|
||||
Directory.CreateDirectory(metadataRoot);
|
||||
File.WriteAllText(Path.Combine(toolsRoot, "YMhutBox.exe"), string.Empty);
|
||||
File.WriteAllText(Path.Combine(metadataRoot, "tools.json"), "{}");
|
||||
|
||||
var service = new InstallIntegrityCheckService(paths, new StartupCheckStore(paths));
|
||||
|
||||
var report = await service.RunFastPreflightAsync();
|
||||
var tools = report.Items.FirstOrDefault(item => item.Id == "userdata:tools");
|
||||
var metadata = report.Items.FirstOrDefault(item => item.Id == "userdata:metadata");
|
||||
|
||||
Assert.IsNotNull(tools);
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(StartupCheckStatus.Passed, tools.Status);
|
||||
Assert.AreEqual(StartupCheckStatus.Passed, metadata.Status);
|
||||
Assert.IsFalse(Directory.Exists(toolsRoot));
|
||||
Assert.IsFalse(Directory.Exists(metadataRoot));
|
||||
Assert.AreEqual(0, report.VisibleIssueCount);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task CurrentHistoryFiltersOutHistoricalInstallReports()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-startup-current-history");
|
||||
var installRoot = CreateHealthyInstall(workspace);
|
||||
var secondRoot = workspace.CreateDirectory("install2");
|
||||
CopyDirectory(installRoot, secondRoot);
|
||||
var appRoot = workspace.CreateDirectory("user");
|
||||
|
||||
using var environment = EnvironmentScope.Capture(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(appRoot);
|
||||
var store = new StartupCheckStore(paths);
|
||||
var service = new InstallIntegrityCheckService(paths, store);
|
||||
var oldReport = await service.RunManualIntegrityCheckAsync();
|
||||
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, secondRoot);
|
||||
var currentReport = await service.RunManualIntegrityCheckAsync();
|
||||
var all = await service.GetHistorySummariesAsync();
|
||||
var current = await service.GetCurrentHistoryAsync();
|
||||
|
||||
Assert.IsTrue(all.Any(summary => summary.Id == oldReport.Id && !summary.IsCurrentInstall));
|
||||
Assert.IsTrue(current.All(summary => summary.IsCurrentInstall));
|
||||
Assert.IsTrue(current.Any(summary => summary.Id == currentReport.Id));
|
||||
Assert.IsFalse(current.Any(summary => summary.Id == oldReport.Id));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task RecheckItemReportsCurrentPathPassesAfterFileRestored()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-startup-recheck");
|
||||
var installRoot = CreateHealthyInstall(workspace);
|
||||
var appRoot = workspace.CreateDirectory("user");
|
||||
var executable = Path.Combine(installRoot, "YMhutBox.exe");
|
||||
File.Delete(executable);
|
||||
|
||||
using var environment = EnvironmentScope.Capture(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(appRoot);
|
||||
var service = new InstallIntegrityCheckService(paths, new StartupCheckStore(paths));
|
||||
var report = await service.RunManualIntegrityCheckAsync();
|
||||
var missing = report.Items.First(item => item.Id == "file:YMhutBox.exe");
|
||||
|
||||
File.WriteAllText(executable, string.Empty);
|
||||
var rechecked = await service.RecheckItemAsync(report.Id, missing.Id);
|
||||
|
||||
Assert.IsNotNull(rechecked);
|
||||
Assert.AreEqual(StartupCheckStatus.Passed, rechecked.Status);
|
||||
StringAssert.Contains(rechecked.Detail, "old record");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task IntegrityDoesNotTreatNormalRootFoldersAsCultureFolders()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-startup-root-folders");
|
||||
var installRoot = CreateHealthyInstall(workspace);
|
||||
var appRoot = workspace.CreateDirectory("user");
|
||||
Directory.CreateDirectory(Path.Combine(installRoot, "Assets"));
|
||||
|
||||
using var environment = EnvironmentScope.Capture(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(appRoot);
|
||||
var service = new InstallIntegrityCheckService(paths, new StartupCheckStore(paths));
|
||||
|
||||
var report = await service.RunManualIntegrityCheckAsync();
|
||||
|
||||
Assert.IsFalse(report.Items.Any(item => string.Equals(item.Id, "lang:root:Assets", StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.IsFalse(report.Items.Any(item => string.Equals(item.Id, "lang:root:Tools", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task StartupCheckStorePersistsHistoryAndLatestReport()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-startup-store");
|
||||
var installRoot = CreateHealthyInstall(workspace);
|
||||
var appRoot = workspace.CreateDirectory("user");
|
||||
|
||||
using var environment = EnvironmentScope.Capture(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(appRoot);
|
||||
var store = new StartupCheckStore(paths);
|
||||
var service = new InstallIntegrityCheckService(paths, store);
|
||||
|
||||
var report = await service.RunManualIntegrityCheckAsync();
|
||||
var latest = await store.GetLatestReportAsync();
|
||||
var history = await store.GetHistoryAsync();
|
||||
var summaries = await store.GetHistorySummariesAsync();
|
||||
var loaded = await store.GetReportAsync(report.Id);
|
||||
|
||||
Assert.IsNotNull(latest);
|
||||
Assert.AreEqual(report.Id, latest.Id);
|
||||
Assert.IsGreaterThanOrEqualTo(history.Count, 1);
|
||||
Assert.IsNotEmpty(history[0].Items);
|
||||
Assert.IsGreaterThanOrEqualTo(summaries.Count, 1);
|
||||
Assert.AreEqual(report.Id, summaries[0].Id);
|
||||
Assert.AreEqual(report.Items.Count, summaries[0].ItemCount);
|
||||
Assert.AreEqual(report.IssueCount, summaries[0].IssueCount);
|
||||
Assert.IsNotNull(loaded);
|
||||
Assert.AreEqual(report.Id, loaded.Id);
|
||||
Assert.IsNotEmpty(loaded.Items);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task StartupCheckStoreMigratesLegacyDatabaseIntoMainSqlite()
|
||||
{
|
||||
using var workspace = TestWorkspace.Create("ymhut-startup-store-migrate");
|
||||
var installRoot = CreateHealthyInstall(workspace);
|
||||
var appRoot = workspace.CreateDirectory("user");
|
||||
|
||||
using var environment = EnvironmentScope.Capture(InstallLayoutPaths.InstallRootEnvironmentVariable);
|
||||
environment.Set(InstallLayoutPaths.InstallRootEnvironmentVariable, installRoot);
|
||||
|
||||
var paths = AppPaths.ForCurrentUser(appRoot);
|
||||
var legacyPath = Path.Combine(paths.Data, "startup-checks.db");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(legacyPath)!);
|
||||
await using (var connection = new SqliteConnection($"Data Source={legacyPath};Pooling=False"))
|
||||
{
|
||||
connection.Open();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
CREATE TABLE startup_check_reports (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
completed_at TEXT NOT NULL,
|
||||
install_root TEXT NOT NULL,
|
||||
manifest_identity TEXT NOT NULL,
|
||||
issue_count INTEGER NOT NULL,
|
||||
critical_issue_count INTEGER NOT NULL,
|
||||
warning_issue_count INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE startup_check_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
report_id TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
path TEXT NULL
|
||||
);
|
||||
INSERT INTO startup_check_reports(
|
||||
id, kind, started_at, completed_at, install_root, manifest_identity,
|
||||
issue_count, critical_issue_count, warning_issue_count)
|
||||
VALUES(
|
||||
'11111111111111111111111111111111',
|
||||
'FastPreflight',
|
||||
'2026-06-01T00:00:00.0000000+00:00',
|
||||
'2026-06-01T00:00:01.0000000+00:00',
|
||||
'C:\Legacy',
|
||||
'legacy',
|
||||
1,
|
||||
0,
|
||||
1);
|
||||
INSERT INTO startup_check_items(report_id, item_id, name, severity, status, detail, path)
|
||||
VALUES(
|
||||
'11111111111111111111111111111111',
|
||||
'legacy:item',
|
||||
'旧记录',
|
||||
'Warning',
|
||||
'Failed',
|
||||
'从旧库导入',
|
||||
NULL);
|
||||
""";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
var store = new StartupCheckStore(paths);
|
||||
var latest = await store.GetLatestReportAsync();
|
||||
|
||||
Assert.IsNotNull(latest);
|
||||
Assert.AreEqual("legacy", latest.ManifestIdentity);
|
||||
Assert.AreEqual(1, latest.Items.Count);
|
||||
Assert.IsFalse(File.Exists(legacyPath));
|
||||
Assert.AreEqual(Path.Combine(paths.Logs, AppDatabasePaths.MainDatabaseFileName), store.DatabasePath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task StartupInitializationPipelineReportsMonotonicWeightedProgress()
|
||||
{
|
||||
var progress = new List<StartupInitializationStageProgress>();
|
||||
var pipeline = new StartupInitializationPipeline([
|
||||
new StartupInitializationStage("first", "First", 1, true, (context, token) =>
|
||||
{
|
||||
context.Report(0.5, "Half");
|
||||
return Task.CompletedTask;
|
||||
}),
|
||||
new StartupInitializationStage("second", "Second", 3, true, (context, token) =>
|
||||
{
|
||||
context.Report(0.25, "Quarter");
|
||||
context.Report(0.75, "Almost");
|
||||
return Task.CompletedTask;
|
||||
})
|
||||
]);
|
||||
|
||||
await pipeline.RunAsync(new InlineProgress<StartupInitializationStageProgress>(progress.Add));
|
||||
|
||||
Assert.IsTrue(progress.Count > 0);
|
||||
Assert.AreEqual(100, progress[^1].Progress);
|
||||
for (var index = 1; index < progress.Count; index++)
|
||||
{
|
||||
Assert.IsTrue(
|
||||
progress[index].Progress >= progress[index - 1].Progress,
|
||||
$"Progress moved backwards at {index}: {progress[index - 1].Progress} -> {progress[index].Progress}");
|
||||
}
|
||||
|
||||
CollectionAssert.AreEqual(new[] { "first", "second" }, pipeline.Stages.Select(stage => stage.Id).ToArray());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StartupSplashMessageSerializesStableContract()
|
||||
{
|
||||
var message = new StartupSplashMessage(
|
||||
"progress",
|
||||
"正在检查",
|
||||
42,
|
||||
"fast-preflight",
|
||||
"快速预检",
|
||||
8,
|
||||
10,
|
||||
"检查关键文件",
|
||||
"warning",
|
||||
IsRepairStep: true,
|
||||
Theme: "dark",
|
||||
ReducedMotion: true);
|
||||
|
||||
var json = message.ToJson();
|
||||
|
||||
StringAssert.Contains(json, "\"type\": \"progress\"");
|
||||
StringAssert.Contains(json, "\"stageId\": \"fast-preflight\"");
|
||||
StringAssert.Contains(json, "\"isRepairStep\": true");
|
||||
StringAssert.Contains(json, "\"reducedMotion\": true");
|
||||
}
|
||||
|
||||
private static string CreateHealthyInstall(TestWorkspace workspace)
|
||||
{
|
||||
var installRoot = workspace.CreateDirectory("install");
|
||||
workspace.CreateFile(["install", "YMhutBox.exe"], string.Empty);
|
||||
workspace.CreateFile(["install", "YMhutBox.dll"], string.Empty);
|
||||
workspace.CreateFile(["install", "resources.pri"], string.Empty);
|
||||
workspace.CreateFile(["install", "lang", "zh-CN", "Microsoft.ui.xaml.dll.mui"], string.Empty);
|
||||
workspace.CreateFile(["install", "lang", "en-US", "Microsoft.ui.xaml.dll.mui"], string.Empty);
|
||||
workspace.CreateFile(["install", "Tools", "Sample", "tool.exe"], string.Empty);
|
||||
workspace.CreateFile(["install", "Metadata", "tools.json"], """{ "tools": [] }""");
|
||||
workspace.CreateFile(["install", "config", "install-manifest.ini"],
|
||||
"""
|
||||
[Release]
|
||||
Version=2.0.6
|
||||
Build=1
|
||||
Channel=stable
|
||||
PackageVersion=2.0.6.1
|
||||
|
||||
[RequiredFiles]
|
||||
YMhutBox.exe
|
||||
YMhutBox.dll
|
||||
resources.pri
|
||||
|
||||
[Files]
|
||||
YMhutBox.exe
|
||||
YMhutBox.dll
|
||||
resources.pri
|
||||
lang/zh-CN/Microsoft.ui.xaml.dll.mui
|
||||
lang/en-US/Microsoft.ui.xaml.dll.mui
|
||||
Tools/Sample/tool.exe
|
||||
Metadata/tools.json
|
||||
""");
|
||||
return installRoot;
|
||||
}
|
||||
|
||||
private static string CreateDevelopmentInstall(TestWorkspace workspace)
|
||||
{
|
||||
var installRoot = workspace.CreateDirectory("install");
|
||||
workspace.CreateFile(["install", "YMhutBox.exe"], string.Empty);
|
||||
workspace.CreateFile(["install", "YMhutBox.dll"], string.Empty);
|
||||
workspace.CreateFile(["install", "zh-CN", "Microsoft.ui.xaml.dll.mui"], string.Empty);
|
||||
workspace.CreateFile(["install", "en-US", "Microsoft.ui.xaml.dll.mui"], string.Empty);
|
||||
return installRoot;
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string source, string destination)
|
||||
{
|
||||
Directory.CreateDirectory(destination);
|
||||
foreach (var directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(destination, Path.GetRelativePath(source, directory)));
|
||||
}
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var target = Path.Combine(destination, Path.GetRelativePath(source, file));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
File.Copy(file, target, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EnvironmentScope : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, string?> _snapshot;
|
||||
|
||||
private EnvironmentScope(IEnumerable<string> names)
|
||||
{
|
||||
_snapshot = names.ToDictionary(name => name, Environment.GetEnvironmentVariable, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public static EnvironmentScope Capture(params string[] names) => new(names);
|
||||
|
||||
public void Set(string name, string? value) => Environment.SetEnvironmentVariable(name, value);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var item in _snapshot)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(item.Key, item.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InlineProgress<T>(Action<T> report) : IProgress<T>
|
||||
{
|
||||
public void Report(T value) => report(value);
|
||||
}
|
||||
|
||||
private sealed class TestWorkspace : IDisposable
|
||||
{
|
||||
private TestWorkspace(string root)
|
||||
{
|
||||
Root = root;
|
||||
}
|
||||
|
||||
public string Root { get; }
|
||||
|
||||
public static TestWorkspace Create(string prefix)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), prefix, Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
return new TestWorkspace(root);
|
||||
}
|
||||
|
||||
public string CreateDirectory(params string[] parts)
|
||||
{
|
||||
var directory = Path.Combine([Root, .. parts]);
|
||||
Directory.CreateDirectory(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
public string CreateFile(string[] parts, string contents)
|
||||
{
|
||||
var path = Path.Combine([Root, .. parts]);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, contents);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Root))
|
||||
{
|
||||
Directory.Delete(Root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.Tools;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class ToolCatalogTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void DefaultCatalogContainsRequiredNativePages()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
|
||||
Assert.IsNotNull(catalog.GetById("safe_browser"));
|
||||
Assert.IsNotNull(catalog.GetById("media_player"));
|
||||
Assert.IsNotNull(catalog.GetById("text_splitter"));
|
||||
Assert.IsNotNull(catalog.GetById("loan_emi_calculator"));
|
||||
Assert.IsNotNull(catalog.GetById("number_base_converter"));
|
||||
Assert.IsNotNull(catalog.GetById("ymhut_uutool_suite"));
|
||||
Assert.IsNotNull(catalog.GetById("html_js_playground"));
|
||||
Assert.IsNotNull(catalog.GetById("timezone_abbr_lookup"));
|
||||
Assert.IsNotNull(catalog.GetById("percentage_change_calculator"));
|
||||
Assert.IsNotNull(catalog.GetById("dev_environment_config"));
|
||||
Assert.IsNull(catalog.GetById("mmd_model_studio"));
|
||||
foreach (var id in new[]
|
||||
{
|
||||
"compression_codec",
|
||||
"totp_generator",
|
||||
"color_contrast_checker",
|
||||
"url_redirect_trace",
|
||||
"image_metadata_inspector",
|
||||
"markdown_preview"
|
||||
})
|
||||
{
|
||||
Assert.IsNotNull(catalog.GetById(id), id);
|
||||
}
|
||||
|
||||
Assert.IsGreaterThanOrEqualTo(160, catalog.Modules.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SearchFiltersByQueryAndCategory()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
|
||||
var results = catalog.Search("browser", ToolCategory.Network).ToList();
|
||||
|
||||
Assert.HasCount(1, results);
|
||||
Assert.AreEqual("safe_browser", results[0].Id);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToolViewModelStartsWithInlinePreviewHidden()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("json_formatter")!;
|
||||
|
||||
var viewModel = (ToolModuleViewModel)module.CreateViewModel();
|
||||
|
||||
Assert.IsFalse(viewModel.InlinePreviewLoaded);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EveryCatalogToolHasNativeWinUiPageSpec()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
var specs = ToolPageSpecCatalog.CreateFor(catalog);
|
||||
|
||||
Assert.HasCount(catalog.Modules.Count, specs);
|
||||
foreach (var module in catalog.Modules)
|
||||
{
|
||||
Assert.IsTrue(specs.ContainsKey(module.Id), module.Id);
|
||||
var spec = specs[module.Id];
|
||||
Assert.AreEqual(module.Id, spec.ToolId);
|
||||
Assert.IsTrue(Enum.IsDefined(spec.Layout), module.Id);
|
||||
Assert.IsNotEmpty(spec.ParameterControls, module.Id);
|
||||
Assert.IsTrue(Enum.IsDefined(spec.AssistPanelMode), module.Id);
|
||||
Assert.IsTrue(Enum.IsDefined(spec.InteractionMode), module.Id);
|
||||
Assert.IsTrue(Enum.IsDefined(spec.PageExperience), module.Id);
|
||||
Assert.IsTrue(Enum.IsDefined(spec.PageDensity), module.Id);
|
||||
Assert.IsTrue(Enum.IsDefined(spec.ResultPriority), module.Id);
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(spec.DefaultFocusTarget), module.Id);
|
||||
if (spec.PageExperience != ToolPageExperienceKind.LivePreview)
|
||||
{
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(spec.PrimaryActionLabel), module.Id);
|
||||
}
|
||||
if (spec.PrimaryInput != ToolPrimaryInputKind.None)
|
||||
{
|
||||
Assert.IsNotEmpty(spec.Parameters, module.Id);
|
||||
}
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(spec.EmptyState), module.Id);
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(spec.ErrorHint), module.Id);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RepresentativeToolSpecsUseGalleryNativeControls()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
|
||||
Assert.AreEqual(ToolPrimaryInputKind.MultilineText, ToolPageSpecCatalog.For(catalog.GetById("json_formatter")!).PrimaryInput);
|
||||
Assert.AreEqual(ToolResultKind.JsonTree, ToolPageSpecCatalog.For(catalog.GetById("json_formatter")!).Result);
|
||||
Assert.AreEqual(ToolPrimaryInputKind.FixedOptions, ToolPageSpecCatalog.For(catalog.GetById("system_tool")!).PrimaryInput);
|
||||
Assert.AreEqual(ToolLayoutKind.SystemLauncher, ToolPageSpecCatalog.For(catalog.GetById("system_tool")!).Layout);
|
||||
Assert.AreEqual(ToolPrimaryInputKind.None, ToolPageSpecCatalog.For(catalog.GetById("baidu_hot")!).PrimaryInput);
|
||||
Assert.IsTrue(ToolPageSpecCatalog.For(catalog.GetById("archive_tool")!).RequiresFilePicker);
|
||||
Assert.IsTrue(ToolPageSpecCatalog.For(catalog.GetById("image_metadata_inspector")!).RequiresFilePicker);
|
||||
Assert.AreEqual(ToolLayoutKind.CodecWorkbench, ToolPageSpecCatalog.For(catalog.GetById("compression_codec")!).Layout);
|
||||
Assert.AreEqual(ToolLayoutKind.SecurityWorkbench, ToolPageSpecCatalog.For(catalog.GetById("totp_generator")!).Layout);
|
||||
Assert.AreEqual(ToolLayoutKind.TextWorkbench, ToolPageSpecCatalog.For(catalog.GetById("color_contrast_checker")!).Layout);
|
||||
Assert.AreEqual(ToolLayoutKind.QueryDashboard, ToolPageSpecCatalog.For(catalog.GetById("url_redirect_trace")!).Layout);
|
||||
Assert.AreEqual(ToolLayoutKind.FormatterWorkbench, ToolPageSpecCatalog.For(catalog.GetById("markdown_preview")!).Layout);
|
||||
Assert.AreEqual(ToolLayoutKind.RandomCinema, ToolPageSpecCatalog.For(catalog.GetById("random_cinema")!).Layout);
|
||||
Assert.AreEqual(ToolCategory.Image, catalog.GetById("random_cinema")!.Metadata.Category);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ModelStudioToolIsRemovedFromCatalogAndSpecs()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
|
||||
Assert.IsNull(catalog.GetById("mmd_model_studio"));
|
||||
Assert.IsFalse(ToolPageSpecCatalog.CreateFor(catalog).ContainsKey("mmd_model_studio"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToolAssistAndInteractionModesMatchRepresentativeExperience()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
|
||||
Assert.AreEqual(ToolAssistPanelMode.Custom, ToolPageSpecCatalog.For(catalog.GetById("color_picker")!).AssistPanelMode);
|
||||
Assert.AreEqual(ToolInteractionMode.LivePreview, ToolPageSpecCatalog.For(catalog.GetById("color_picker")!).InteractionMode);
|
||||
Assert.AreEqual(ToolPageExperienceKind.LivePreview, ToolPageSpecCatalog.For(catalog.GetById("color_picker")!).PageExperience);
|
||||
Assert.AreEqual(ToolAssistPanelMode.Hidden, ToolPageSpecCatalog.For(catalog.GetById("system_info")!).AssistPanelMode);
|
||||
Assert.AreEqual(ToolAssistPanelMode.Hidden, ToolPageSpecCatalog.For(catalog.GetById("baidu_hot")!).AssistPanelMode);
|
||||
Assert.AreEqual(ToolAssistPanelMode.RulesOnly, ToolPageSpecCatalog.For(catalog.GetById("json_formatter")!).AssistPanelMode);
|
||||
Assert.AreEqual(ToolAssistPanelMode.RulesOnly, ToolPageSpecCatalog.For(catalog.GetById("base64_codec")!).AssistPanelMode);
|
||||
Assert.AreEqual(ToolAssistPanelMode.SourceOnly, ToolPageSpecCatalog.For(catalog.GetById("weather")!).AssistPanelMode);
|
||||
Assert.AreEqual(ToolInteractionMode.ReferenceBrowser, ToolPageSpecCatalog.For(catalog.GetById("mime_lookup")!).InteractionMode);
|
||||
Assert.AreEqual(ToolPageExperienceKind.RemoteDashboard, ToolPageSpecCatalog.For(catalog.GetById("baidu_hot")!).PageExperience);
|
||||
Assert.AreEqual(ToolPageExperienceKind.FormCalculator, ToolPageSpecCatalog.For(catalog.GetById("bmi_calculator")!).PageExperience);
|
||||
Assert.AreEqual(ToolPageExperienceKind.FileWorkflow, ToolPageSpecCatalog.For(catalog.GetById("archive_tool")!).PageExperience);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RuleDrivenToolsHaveRuleSpecsAndSourceOnlyCopyIsSanitized()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
var specs = ToolPageSpecCatalog.CreateFor(catalog);
|
||||
|
||||
foreach (var spec in specs.Values.Where(spec => spec.AssistPanelMode == ToolAssistPanelMode.RulesOnly))
|
||||
{
|
||||
Assert.IsTrue(spec.Rules.Count > 0 || spec.RequiresFilePicker || spec.ParameterControls.Contains(ToolParameterControlKind.NumberBox), spec.ToolId);
|
||||
}
|
||||
|
||||
foreach (var spec in specs.Values.Where(spec => spec.AssistPanelMode == ToolAssistPanelMode.SourceOnly))
|
||||
{
|
||||
var uiCopy = $"{spec.EmptyState} {spec.ErrorHint}";
|
||||
Assert.IsFalse(uiCopy.Contains("http://", StringComparison.OrdinalIgnoreCase), spec.ToolId);
|
||||
Assert.IsFalse(uiCopy.Contains("https://", StringComparison.OrdinalIgnoreCase), spec.ToolId);
|
||||
Assert.IsFalse(uiCopy.Contains(".json", StringComparison.OrdinalIgnoreCase), spec.ToolId);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RuleAndParameterSpecsCarryBilingualText()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
var json = ToolPageSpecCatalog.For(catalog.GetById("json_formatter")!);
|
||||
var port = ToolPageSpecCatalog.For(catalog.GetById("port_lookup")!);
|
||||
|
||||
Assert.AreEqual("模式", json.Rules[0].LocalizedLabel?.ForLanguage("zh-CN"));
|
||||
Assert.AreEqual("Mode", json.Rules[0].LocalizedLabel?.ForLanguage("en-US"));
|
||||
Assert.AreEqual("格式化", json.Rules[0].Options[0].LocalizedLabel?.ForLanguage("zh-CN"));
|
||||
Assert.AreEqual("Format", json.Rules[0].Options[0].LocalizedLabel?.ForLanguage("en-US"));
|
||||
Assert.AreEqual("来源", port.Rules[0].LocalizedLabel?.ForLanguage("zh-CN"));
|
||||
Assert.AreEqual("Source", port.Rules[0].LocalizedLabel?.ForLanguage("en-US"));
|
||||
Assert.AreEqual("预设", port.Parameters[0].LocalizedLabel?.ForLanguage("zh-CN"));
|
||||
Assert.AreEqual("Preset", port.Parameters[0].LocalizedLabel?.ForLanguage("en-US"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PageExperienceRulesKeepToolPagesCompact()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
|
||||
var color = ToolPageSpecCatalog.For(catalog.GetById("color_picker")!);
|
||||
Assert.AreEqual(ToolPageExperienceKind.LivePreview, color.PageExperience);
|
||||
Assert.AreEqual(string.Empty, color.PrimaryActionLabel);
|
||||
|
||||
var hot = ToolPageSpecCatalog.For(catalog.GetById("hotboard")!);
|
||||
Assert.AreEqual(ToolPageExperienceKind.RemoteDashboard, hot.PageExperience);
|
||||
Assert.IsFalse(hot.AutoRunOnOpen);
|
||||
Assert.AreEqual("查询", hot.PrimaryActionLabel);
|
||||
Assert.IsTrue(hot.ShowInputHeader);
|
||||
|
||||
var bmi = ToolPageSpecCatalog.For(catalog.GetById("bmi_calculator")!);
|
||||
Assert.AreEqual(ToolPageExperienceKind.FormCalculator, bmi.PageExperience);
|
||||
Assert.AreEqual("计算", bmi.PrimaryActionLabel);
|
||||
Assert.AreNotEqual(ToolPrimaryInputKind.MultilineText, bmi.PrimaryInput);
|
||||
|
||||
var json = ToolPageSpecCatalog.For(catalog.GetById("json_formatter")!);
|
||||
Assert.AreEqual(ToolPageExperienceKind.OneShotTransform, json.PageExperience);
|
||||
Assert.AreEqual("格式化", json.PrimaryActionLabel);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CalculatorToolSpecsUseNativeFormInputsByArity()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
|
||||
Assert.AreEqual(ToolPrimaryInputKind.NumberPair, ToolPageSpecCatalog.For(catalog.GetById("bmi_calculator")!).PrimaryInput);
|
||||
Assert.AreEqual(ToolPrimaryInputKind.NumberTriple, ToolPageSpecCatalog.For(catalog.GetById("loan_emi_calculator")!).PrimaryInput);
|
||||
Assert.AreEqual(ToolPrimaryInputKind.NumberQuad, ToolPageSpecCatalog.For(catalog.GetById("compound_interest_calculator")!).PrimaryInput);
|
||||
Assert.AreEqual(ToolPrimaryInputKind.DateRange, ToolPageSpecCatalog.For(catalog.GetById("shelf_life")!).PrimaryInput);
|
||||
Assert.IsTrue(ToolPageSpecCatalog.For(catalog.GetById("shelf_life")!).UsesNumberBox);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SpecsCoverEveryStructuredResultKind()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
var resultKinds = catalog.Modules
|
||||
.Select(module => ToolPageSpecCatalog.For(module).Result)
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
|
||||
foreach (ToolResultKind kind in Enum.GetValues<ToolResultKind>())
|
||||
{
|
||||
Assert.Contains(kind, resultKinds, kind.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ToolExecutorReturnsStructuredDocuments()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
var samples = new Dictionary<string, string>
|
||||
{
|
||||
["json_formatter"] = "{\"name\":\"YMhut Box\"}",
|
||||
["base64_codec"] = "YMhut Box",
|
||||
["bmi_calculator"] = "70 1.75",
|
||||
["text_diff"] = "WinUI\n---\nWinUI 3",
|
||||
["color_picker"] = "#3399ff"
|
||||
};
|
||||
|
||||
foreach (var sample in samples)
|
||||
{
|
||||
var module = catalog.GetById(sample.Key)!;
|
||||
var result = await ToolExecutor.ExecuteAsync(module, sample.Value);
|
||||
|
||||
Assert.IsTrue(result.Ok, sample.Key);
|
||||
Assert.IsNotNull(result.Document, sample.Key);
|
||||
Assert.AreEqual(module.Id, result.Document.ToolId);
|
||||
Assert.IsNotEmpty(result.Document.Blocks, sample.Key);
|
||||
Assert.AreEqual(result.Output, result.Document.RawText);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EveryResultKindBuildsStructuredDocumentBlocks()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
var samples = new Dictionary<string, string>
|
||||
{
|
||||
["ascii_art"] = "YMhut",
|
||||
["xml_formatter"] = "<root><name>YMhut</name></root>",
|
||||
["json_formatter"] = "{\"name\":\"YMhut\"}",
|
||||
["base64_codec"] = "Encode:\nWU1odXQ=",
|
||||
["regex_tool"] = "match | value\n1 | YMhut",
|
||||
["hotboard"] = "1. YMhut 热点",
|
||||
["cctv_news"] = "1. 新闻标题",
|
||||
["qr_generator"] = "https://example.com/qr.png",
|
||||
["smart_search"] = "Bing - https://www.bing.com/search?q=YMhut",
|
||||
["archive_tool"] = "已创建归档:sample.zip",
|
||||
["text_diff"] = "- old\n+ new",
|
||||
["hash_manifest_verify"] = "OK hello",
|
||||
["color_picker"] = "HEX: #336699\nRGB: 51, 102, 153",
|
||||
["number_base"] = "DEC: 255\nHEX: 0xFF",
|
||||
["system_tool"] = "OS: Windows\nProcessors: 8",
|
||||
["random_cinema"] = "https://example.com/media.mp4",
|
||||
["port_lookup"] = "数据源:YMhut Reference Data / IANA\n80 - HTTP: 网页服务"
|
||||
};
|
||||
|
||||
foreach (ToolResultKind kind in Enum.GetValues<ToolResultKind>())
|
||||
{
|
||||
var module = catalog.Modules.FirstOrDefault(module => ToolPageSpecCatalog.For(module).Result == kind);
|
||||
Assert.IsNotNull(module, kind.ToString());
|
||||
var output = samples.TryGetValue(module.Id, out var sample) ? sample : "Key: Value\nItem: Result";
|
||||
|
||||
var document = ToolResultBuilder.FromOutput(module, output, "zh-CN");
|
||||
|
||||
Assert.AreEqual(module.Id, document.ToolId, kind.ToString());
|
||||
Assert.AreEqual(kind, document.ResultKind, kind.ToString());
|
||||
Assert.IsNotEmpty(document.Blocks, kind.ToString());
|
||||
Assert.AreEqual("ok", document.Status, kind.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using YMhut.Box.Core.App;
|
||||
using YMhut.Box.Core.Api;
|
||||
using YMhut.Box.Core.Data;
|
||||
using YMhut.Box.Core.Net;
|
||||
using YMhut.Box.Core.Tools;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class ToolExecutorTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task JsonFormatterFormatsInput()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("json_formatter")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "{\"name\":\"YMhut\"}");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "\"name\": \"YMhut\"");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task NumberBaseSupportsCatalogId()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("number_base")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "255");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "HEX: 0xFF");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task NumberBaseConverterSupportsPlanId()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("number_base_converter")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "255");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "HEX: 0xFF");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task UuToolSuiteListsNativeTools()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("ymhut_uutool_suite")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "json");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "YMhut UU Tool Suite");
|
||||
StringAssert.Contains(result.Output, "json_formatter");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ApiToolUsesApiManagerEndpoint()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("dns_query")!;
|
||||
var manager = new ApiManager(new FakeHttpService("""
|
||||
{
|
||||
"Status": 0,
|
||||
"Question": [{ "name": "example.com.", "type": 1 }],
|
||||
"Answer": [{ "name": "example.com.", "type": 1, "TTL": 300, "data": "1.2.3.4" }]
|
||||
}
|
||||
"""));
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "example.com", apiManager: manager);
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "Google Public DNS");
|
||||
StringAssert.Contains(result.Output, "1.2.3.4");
|
||||
Assert.IsNotNull(result.Document);
|
||||
Assert.IsTrue(result.Document.Blocks.Any(block => block.Kind == ToolResultBlockKind.Table));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task QqProfileUsesNewUserInfoSourceAndDisplaysReturnedAvatar()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("qq_avatar")!;
|
||||
var manager = new ApiManager(new FakeHttpService("""
|
||||
{
|
||||
"qq": "10001",
|
||||
"nickname": "Xiao Ming",
|
||||
"long_nick": "Good day",
|
||||
"avatar_url": "http://q.qlogo.cn/g?b=qq&nk=10001&s=640",
|
||||
"age": 25,
|
||||
"sex": "male",
|
||||
"qid": "xiaoming2024",
|
||||
"qq_level": 64,
|
||||
"location": "Guangdong Shenzhen",
|
||||
"email": "10001@qq.com",
|
||||
"is_vip": true,
|
||||
"is_years_vip": true,
|
||||
"is_svip": false,
|
||||
"is_big_club": true,
|
||||
"vip_level": 7,
|
||||
"reg_time": "2008-03-15T10:30:00Z",
|
||||
"last_updated": "2024-08-14T15:45:30Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "10001", apiManager: manager, language: "zh-CN");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
Assert.IsNotNull(result.Document);
|
||||
Assert.AreEqual("Uapis QQ User Info", result.Document.SourceName);
|
||||
Assert.IsTrue(ApiEndpoints.TryGet("qq_avatar", out var endpoint));
|
||||
Assert.IsTrue(endpoint.ShouldHideEndpoint);
|
||||
Assert.IsFalse(result.Document.Metadata.ContainsKey("sourceUrl"));
|
||||
var avatar = result.Document.Blocks.FirstOrDefault(block => block.Kind == ToolResultBlockKind.Image);
|
||||
Assert.IsNotNull(avatar);
|
||||
Assert.AreEqual("http://q.qlogo.cn/g?b=qq&nk=10001&s=640", avatar.Uri);
|
||||
var pairs = result.Document.Blocks.SelectMany(block => block.Pairs).ToArray();
|
||||
Assert.IsTrue(pairs.Any(pair => pair.Value == "64"));
|
||||
Assert.IsTrue(pairs.Any(pair => pair.Key.Contains("VIP", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(pair.Value)));
|
||||
Assert.IsFalse(result.Output.Contains("uapis.cn/api/v1/social/qq/userinfo", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task QrGeneratorOutputsSvg()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("qr_generator")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "YMhut Box");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "<svg");
|
||||
Assert.IsNotNull(result.Document);
|
||||
Assert.IsTrue(result.Document.Blocks.Any(block =>
|
||||
block.Kind == ToolResultBlockKind.Image &&
|
||||
block.Metadata is not null &&
|
||||
block.Metadata.TryGetValue("contentType", out var contentType) &&
|
||||
contentType == "image/svg+xml"));
|
||||
Assert.IsTrue(result.Document.Blocks.Any(block =>
|
||||
block.Kind == ToolResultBlockKind.Image &&
|
||||
block.Metadata is not null &&
|
||||
block.Metadata.TryGetValue("pngBase64", out var pngBase64) &&
|
||||
Convert.TryFromBase64String(pngBase64, new byte[pngBase64.Length], out _)));
|
||||
Assert.AreEqual(ToolResultKind.ImagePreview, result.Document.ResultKind);
|
||||
StringAssert.Contains(result.Document.RawText, "<svg");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TextDiffMarksAddedAndRemovedLines()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("text_diff")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "alpha\nbeta\n---\nalpha\ngamma");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "- beta");
|
||||
StringAssert.Contains(result.Output, "+ gamma");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ColorPickerConvertsHexToRgb()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("color_picker")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "#336699");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "RGB: 51, 102, 153");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task AsciiArtRendersBlockText()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("ascii_art")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "YM");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "#");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ProfanitySupportsCustomTerms()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("profanity_check")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "secret\n---\nthis is a secret token");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "Potential matches: secret");
|
||||
StringAssert.Contains(result.Output, "******");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ArchiveCreatesZipForFile()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
try
|
||||
{
|
||||
var file = Path.Combine(tempRoot, "sample.txt");
|
||||
File.WriteAllText(file, "hello");
|
||||
var module = new ToolCatalog().GetById("archive_tool")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, file);
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
Assert.HasCount(1, Directory.GetFiles(tempRoot, "*.zip"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task QqAvatarBuildsStaticUrls()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("qq_avatar")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "10000");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "qlogo.cn");
|
||||
Assert.IsNotNull(result.Document);
|
||||
Assert.IsTrue(result.Document.Blocks.Any(block =>
|
||||
block.Kind == ToolResultBlockKind.Image &&
|
||||
block.Metadata is not null &&
|
||||
block.Metadata.TryGetValue("displayMode", out var displayMode) &&
|
||||
displayMode == "preview"));
|
||||
Assert.IsTrue(result.Document.Blocks.Any(block =>
|
||||
block.Kind is ToolResultBlockKind.KeyValue or ToolResultBlockKind.Metric &&
|
||||
block.Pairs.Any(pair => pair.Key.Equals("QQ", StringComparison.OrdinalIgnoreCase) && pair.Value == "10000")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MarkdownTableNormalizerAlignsColumns()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("markdown_table_normalizer")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "| a | long |\n|---|---|\n| 1 | 2 |");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "| a | long |");
|
||||
StringAssert.Contains(result.Output, "| --- | ---- |");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task CompressionCodecRoundTripsGzipBase64()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("compression_codec")!;
|
||||
|
||||
var compressed = await ToolExecutor.ExecuteAsync(module, "gzip compress\nYMhut Box");
|
||||
var base64 = compressed.Output
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Single(line => line.StartsWith("Base64:", StringComparison.OrdinalIgnoreCase))
|
||||
.Replace("Base64:", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.Trim();
|
||||
var decoded = await ToolExecutor.ExecuteAsync(module, $"gzip decode\n{base64}");
|
||||
|
||||
Assert.IsTrue(compressed.Ok);
|
||||
StringAssert.Contains(compressed.Output, "Algorithm: gzip");
|
||||
Assert.IsTrue(decoded.Ok);
|
||||
Assert.AreEqual("YMhut Box", decoded.Output);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TotpGeneratorMatchesRfc6238Sample()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("totp_generator")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ\ntime=59\ndigits=8");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "Code: 94287082");
|
||||
StringAssert.Contains(result.Output, "Counter: 1");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ColorContrastCheckerReportsWcagPasses()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("color_contrast_checker")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "#000000 #ffffff");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "Contrast: 21.00:1");
|
||||
StringAssert.Contains(result.Output, "AA normal text: Pass");
|
||||
StringAssert.Contains(result.Output, "AAA normal text: Pass");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MarkdownPreviewBuildsSummaryAndNormalizedText()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("markdown_preview")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "# Title\n\n* item\n\n[YMhut](https://example.com)");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "Headings: 1");
|
||||
StringAssert.Contains(result.Output, "Links: 1");
|
||||
StringAssert.Contains(result.Output, "Plain preview:");
|
||||
StringAssert.Contains(result.Output, "Normalized:");
|
||||
StringAssert.Contains(result.Output, "- item");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ImageMetadataInspectorFailsForInvalidPath()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("image_metadata_inspector")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, @"Z:\missing\not-an-image.png");
|
||||
|
||||
Assert.IsFalse(result.Ok);
|
||||
StringAssert.Contains(result.Error, "本地图片路径");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task UrlRedirectTraceRejectsInvalidUrlWithoutNetwork()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("url_redirect_trace")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "not a url", language: "zh-CN");
|
||||
|
||||
Assert.IsFalse(result.Ok);
|
||||
StringAssert.Contains(result.Error, "http 或 https URL");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task JsonPathHelperListsLeafPaths()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("json_path_helper")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "{\"items\":[{\"name\":\"YMhut\"}]}");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "$.items[0].name = \"YMhut\"");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task HashManifestBuilderAndVerifierSupportTextRows()
|
||||
{
|
||||
var builder = new ToolCatalog().GetById("hash_manifest_builder")!;
|
||||
var verifier = new ToolCatalog().GetById("hash_manifest_verify")!;
|
||||
|
||||
var manifest = await ToolExecutor.ExecuteAsync(builder, "hello");
|
||||
var result = await ToolExecutor.ExecuteAsync(verifier, manifest.Output);
|
||||
|
||||
Assert.IsTrue(manifest.Ok);
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "OK hello");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task RenamePreviewSupportsIndexedNames()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("indexed_rename_preview")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "prefix=photo_\na.jpg\nb.png");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "a.jpg -> photo_001.jpg");
|
||||
StringAssert.Contains(result.Output, "b.png -> photo_002.png");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ReferenceLookupUsesBundledAuthoritativeData()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("mime_lookup")!;
|
||||
var reference = new ReferenceDataService(AppPaths.ForCurrentUser(
|
||||
Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N")),
|
||||
FindAssetsRoot()));
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "json", referenceDataService: reference);
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "YMhut Reference Data");
|
||||
StringAssert.Contains(result.Output, "application/json");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task PortLookupLocalizesDescriptionsAndHidesSourceUrls()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("port_lookup")!;
|
||||
var reference = new ReferenceDataService(AppPaths.ForCurrentUser(
|
||||
Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N")),
|
||||
FindAssetsRoot()));
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "443", referenceDataService: reference, language: "zh-CN");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "HTTPS 加密网页服务");
|
||||
StringAssert.Contains(result.Output, "数据源:YMhut Reference Data / IANA");
|
||||
Assert.IsFalse(result.Output.Contains("https://", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task SmartSearchUsesOnlySelectedChannel()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("smart_search")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "bing\nYMhut Box", language: "zh-CN");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "搜索渠道:Bing");
|
||||
StringAssert.Contains(result.Output, "https://www.bing.com/search");
|
||||
Assert.IsFalse(result.Output.Contains("baidu.com", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task WeatherPrefersChinaWeatherForKnownChineseCity()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("weather")!;
|
||||
var manager = new ApiManager(new FakeHttpService("""
|
||||
{"weatherinfo":{"city":"北京","weather":"晴","temp1":"10℃","temp2":"20℃","ptime":"11:00"}}
|
||||
"""));
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "北京", apiManager: manager, language: "en-US");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "Data source: China Weather");
|
||||
StringAssert.Contains(result.Output, "City: 北京");
|
||||
Assert.IsFalse(result.Output.Contains("weather.com.cn", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MarkdownConverterRendersJsonObjectAndLanguageLabels()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("json_formatter")!;
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "{\"name\":\"YMhut\",\"count\":2}");
|
||||
|
||||
var markdown = ToolMarkdownConverter.FromResult(module, result, "en-US");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(markdown.MarkdownText, "## JSON Result");
|
||||
StringAssert.Contains(markdown.MarkdownText, "| Field | Value |");
|
||||
StringAssert.Contains(markdown.MarkdownText, "```json");
|
||||
Assert.IsFalse(markdown.MarkdownText.Contains("字段", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MarkdownConverterRendersJsonArrayAsTable()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("json_formatter")!;
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "[{\"name\":\"YMhut\",\"meta\":{\"kind\":\"tool\"}},{\"name\":\"Box\",\"meta\":{\"kind\":\"app\"}}]");
|
||||
|
||||
var markdown = ToolMarkdownConverter.FromResult(module, result, "zh-CN");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(markdown.MarkdownText, "**项目数**: 2");
|
||||
StringAssert.Contains(markdown.MarkdownText, "| name | meta |");
|
||||
StringAssert.Contains(markdown.MarkdownText, "{\"kind\":\"tool\"}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MarkdownConverterKeepsInvalidJsonInCodeBlock()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("json_formatter")!;
|
||||
var result = ToolExecutionResult.Success("{not-json");
|
||||
|
||||
var markdown = ToolMarkdownConverter.FromResult(module, result, "zh-CN");
|
||||
|
||||
StringAssert.Contains(markdown.MarkdownText, "JSON 解析失败");
|
||||
StringAssert.Contains(markdown.MarkdownText, "```json");
|
||||
StringAssert.Contains(markdown.MarkdownText, "{not-json");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MarkdownConverterRendersStructuredResultKinds()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
|
||||
var diffModule = catalog.GetById("text_diff")!;
|
||||
var diff = await ToolExecutor.ExecuteAsync(diffModule, "alpha\n---\nbeta");
|
||||
var diffMarkdown = ToolMarkdownConverter.FromResult(diffModule, diff, "zh-CN");
|
||||
StringAssert.Contains(diffMarkdown.MarkdownText, "```diff");
|
||||
|
||||
var tableModule = catalog.GetById("markdown_table_normalizer")!;
|
||||
var table = await ToolExecutor.ExecuteAsync(tableModule, "| a | b |\n|---|---|\n| 1 | 2 |");
|
||||
var tableMarkdown = ToolMarkdownConverter.FromResult(tableModule, table, "zh-CN");
|
||||
StringAssert.Contains(tableMarkdown.MarkdownText, "| a | b |");
|
||||
|
||||
var systemModule = catalog.GetById("system_tool")!;
|
||||
var system = await ToolExecutor.ExecuteAsync(systemModule, "status");
|
||||
var systemMarkdown = ToolMarkdownConverter.FromResult(systemModule, system, "zh-CN");
|
||||
StringAssert.Contains(systemMarkdown.MarkdownText, "| 字段 | 值 |");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task MarkdownConverterPreservesSanitizedReferenceSources()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("port_lookup")!;
|
||||
var reference = new ReferenceDataService(AppPaths.ForCurrentUser(
|
||||
Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N")),
|
||||
FindAssetsRoot()));
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "443", referenceDataService: reference, language: "zh-CN");
|
||||
|
||||
var markdown = ToolMarkdownConverter.FromResult(module, result, "zh-CN");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(markdown.MarkdownText, "YMhut Reference Data / IANA");
|
||||
Assert.IsFalse(markdown.MarkdownText.Contains("https://", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.IsNotNull(result.Document);
|
||||
Assert.IsTrue(result.Document.Blocks.Any(block => block.Kind is ToolResultBlockKind.Metric or ToolResultBlockKind.Table));
|
||||
Assert.IsFalse(result.Document.Blocks.All(block => block.Kind == ToolResultBlockKind.Text));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RemoteResultBuilderAddsSanitizedSourceAndCustomBlocks()
|
||||
{
|
||||
Assert.IsTrue(ApiEndpoints.TryGet("baidu_hot", out var endpoint));
|
||||
var response = new ApiResponse(
|
||||
endpoint.Id,
|
||||
new Uri("https://top.baidu.com/api/board?secret=hidden"),
|
||||
true,
|
||||
"{}",
|
||||
null,
|
||||
DateTimeOffset.Parse("2026-05-27T10:00:00+08:00"));
|
||||
var output = """
|
||||
鏁版嵁婧愶細鐧惧害鐑悳锛堝畼鏂?鏉冨▉婧愶級
|
||||
鏉ユ簮璇存槑锛氬凡闅愯棌杩滅▼鍦板潃锛屼粎灞曠ず鑴辨晱鏉ユ簮鍚嶇О銆? 鑾峰彇鏃堕棿锛?026-05-27 10:00:00 +08:00
|
||||
|
||||
1. YMhut Box 鐑偣 鐑害锛?00
|
||||
鎽樿鍐呭
|
||||
""";
|
||||
|
||||
var document = ToolResultBuilder.FromRemote(endpoint, response, output, "zh-CN");
|
||||
|
||||
Assert.AreEqual("PearAPI DailyHot", document.SourceName);
|
||||
Assert.AreEqual(ToolResultBlockKind.RankedList, document.Blocks[0].Kind);
|
||||
Assert.IsTrue(document.Blocks.Any(block => block.Kind == ToolResultBlockKind.KeyValue));
|
||||
Assert.IsTrue(document.Blocks.Any(block => block.Kind == ToolResultBlockKind.RankedList));
|
||||
Assert.AreEqual("source", document.Blocks[^1].Metadata?["displayMode"]);
|
||||
Assert.IsFalse(document.Metadata.ContainsKey("sourceUrl"));
|
||||
Assert.IsFalse(document.Blocks.Any(block =>
|
||||
block.Metadata is not null &&
|
||||
block.Metadata.ContainsKey("sourceUrl")));
|
||||
Assert.IsFalse(document.Blocks.SelectMany(block => block.Pairs).Any(pair =>
|
||||
pair.Key.Contains("鎺ュ彛鍦板潃", StringComparison.OrdinalIgnoreCase) ||
|
||||
pair.Key.Contains("Endpoint", StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.IsFalse(document.Blocks.SelectMany(block => block.Pairs).Any(pair => pair.Value.Contains("secret=hidden", StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.IsFalse(document.RawText.Contains("secret=hidden", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RemoteFailureBuildsRenderableDocumentInsteadOfEmptyFailure()
|
||||
{
|
||||
Assert.IsTrue(ApiEndpoints.TryGet("bili_hot", out var endpoint));
|
||||
var response = new ApiResponse(
|
||||
endpoint.Id,
|
||||
new Uri("https://api.bilibili.com/x/web-interface/popular"),
|
||||
false,
|
||||
"Forbidden",
|
||||
"HTTP 403 Forbidden",
|
||||
DateTimeOffset.Parse("2026-05-27T10:00:00+08:00"),
|
||||
403);
|
||||
|
||||
var result = RemoteToolFormatter.FormatFailure(endpoint, response, string.Empty, "zh-CN");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
Assert.IsNotNull(result.Document);
|
||||
Assert.AreEqual("error", result.Document.Status);
|
||||
Assert.IsTrue(result.Document.Blocks.Count >= 2);
|
||||
StringAssert.Contains(result.Output, "源站拒绝");
|
||||
Assert.IsFalse(result.Output.Contains(endpoint.SourceUrl, StringComparison.OrdinalIgnoreCase));
|
||||
Assert.IsFalse(result.Output.Contains("接口地址", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.IsFalse(result.Output.Contains("Endpoint", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.IsTrue(result.Document.Blocks.Any(block => block.Kind == ToolResultBlockKind.Text));
|
||||
Assert.AreEqual("source", result.Document.Blocks[^1].Metadata?["displayMode"]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RemoteRankingFormatterRecoversCardsFromAlternativeJson()
|
||||
{
|
||||
Assert.IsTrue(ApiEndpoints.TryGet("bili_hot", out var endpoint));
|
||||
var response = new ApiResponse(
|
||||
endpoint.Id,
|
||||
new Uri("https://api.bilibili.com/x/web-interface/popular"),
|
||||
true,
|
||||
"""
|
||||
{
|
||||
"data": {
|
||||
"items": [
|
||||
{ "title": "First video", "owner": { "name": "UP" }, "stat": { "view": 123456 } },
|
||||
{ "title": "Second video", "stat": { "view": 888 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
""",
|
||||
null,
|
||||
DateTimeOffset.Parse("2026-05-27T10:00:00+08:00"),
|
||||
200);
|
||||
|
||||
var result = RemoteToolFormatter.Format(endpoint, response, string.Empty, "zh-CN");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
Assert.IsNotNull(result.Document);
|
||||
var cards = result.Document.Blocks.FirstOrDefault(block => block.Kind == ToolResultBlockKind.RankedList);
|
||||
Assert.IsNotNull(cards);
|
||||
Assert.AreEqual("1", cards.Items[0].Leading);
|
||||
StringAssert.Contains(cards.Items[0].Title, "First video");
|
||||
StringAssert.Contains(cards.Items[0].Title + cards.Items[0].Subtitle, "123,456");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RemoteRankingFormatterRecoversCardsFromEmbeddedHtml()
|
||||
{
|
||||
Assert.IsTrue(ApiEndpoints.TryGet("zhihu_hot", out var endpoint));
|
||||
var response = new ApiResponse(
|
||||
endpoint.Id,
|
||||
new Uri("https://www.zhihu.com/hot"),
|
||||
true,
|
||||
"""
|
||||
<html><body>
|
||||
<script id="js-initialData" type="application/json">
|
||||
{"initialState":{"topstory":{"hotList":[{"target":{"title":"Hot title one"}},{"target":{"title":"Hot title two"}}]}}}
|
||||
</script>
|
||||
</body></html>
|
||||
""",
|
||||
null,
|
||||
DateTimeOffset.Parse("2026-05-27T10:00:00+08:00"),
|
||||
200);
|
||||
|
||||
var result = RemoteToolFormatter.Format(endpoint, response, string.Empty, "zh-CN");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
Assert.IsNotNull(result.Document);
|
||||
var cards = result.Document.Blocks.FirstOrDefault(block => block.Kind == ToolResultBlockKind.RankedList);
|
||||
Assert.IsNotNull(cards);
|
||||
Assert.AreEqual(2, cards.Items.Count);
|
||||
Assert.AreEqual("1", cards.Items[0].Leading);
|
||||
StringAssert.Contains(cards.Items[0].Title, "Hot title one");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RemoteRankingResultIgnoresSanitizedSourceNoticeInCards()
|
||||
{
|
||||
Assert.IsTrue(ApiEndpoints.TryGet("hotboard", out var endpoint));
|
||||
var response = new ApiResponse(
|
||||
endpoint.Id,
|
||||
new Uri("https://top.baidu.com/api/board?secret=hidden"),
|
||||
true,
|
||||
"{}",
|
||||
null,
|
||||
DateTimeOffset.Parse("2026-05-27T10:00:00+08:00"));
|
||||
var output = string.Join(Environment.NewLine, new[]
|
||||
{
|
||||
"\u6570\u636e\u6e90\uff1aBaidu Hot\uff08official\uff09",
|
||||
"\u6765\u6e90\u8bf4\u660e\uff1a\u5df2\u9690\u85cf\u8fdc\u7a0b\u5730\u5740\uff0c\u4ec5\u5c55\u793a\u8131\u654f\u6765\u6e90\u540d\u79f0\u3002",
|
||||
"\u83b7\u53d6\u65f6\u95f4\uff1a2026-05-27 10:00:00 +08:00",
|
||||
"",
|
||||
"1. YMhut Box Hot",
|
||||
" Summary line",
|
||||
"2. WinUI Tools",
|
||||
" Another summary"
|
||||
});
|
||||
|
||||
var document = ToolResultBuilder.FromRemote(endpoint, response, output, "zh-CN");
|
||||
var cards = document.Blocks.FirstOrDefault(block => block.Kind == ToolResultBlockKind.RankedList);
|
||||
|
||||
Assert.IsNotNull(cards);
|
||||
Assert.IsGreaterThanOrEqualTo(cards.Items.Count, 2);
|
||||
Assert.AreEqual("1", cards.Items[0].Leading);
|
||||
StringAssert.Contains(cards.Items[0].Title, "YMhut Box Hot");
|
||||
Assert.IsFalse(cards.Items.Any(item =>
|
||||
item.Title.Contains("\u5df2\u9690\u85cf\u8fdc\u7a0b\u5730\u5740", StringComparison.OrdinalIgnoreCase) ||
|
||||
item.Subtitle.Contains("\u5df2\u9690\u85cf\u8fdc\u7a0b\u5730\u5740", StringComparison.OrdinalIgnoreCase) ||
|
||||
item.Title.Contains("remote address is hidden", StringComparison.OrdinalIgnoreCase) ||
|
||||
item.Subtitle.Contains("remote address is hidden", StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.IsFalse(document.Metadata.ContainsKey("sourceUrl"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GoldPriceRemoteResultBuildsTrendChart()
|
||||
{
|
||||
Assert.IsTrue(ApiEndpoints.TryGet("gold_price", out var endpoint));
|
||||
var response = new ApiResponse(
|
||||
endpoint.Id,
|
||||
new Uri("https://prices.lbma.org.uk/json/gold_pm.json"),
|
||||
true,
|
||||
"{}",
|
||||
null,
|
||||
DateTimeOffset.Parse("2026-06-14T10:00:00+08:00"));
|
||||
var output = string.Join(Environment.NewLine, new[]
|
||||
{
|
||||
"LBMA Gold PM Fixing",
|
||||
"日期:2026-06-12",
|
||||
"USD/oz:3362.15",
|
||||
"GBP/oz:2491.22",
|
||||
"",
|
||||
"Date | USD/oz | GBP/oz",
|
||||
"2026-06-10 | 3320.1 | 2460.4",
|
||||
"2026-06-11 | 3340.8 | 2475.2",
|
||||
"2026-06-12 | 3362.15 | 2491.22"
|
||||
});
|
||||
|
||||
var document = ToolResultBuilder.FromRemote(endpoint, response, output, "zh-CN");
|
||||
|
||||
Assert.IsTrue(document.Blocks.Any(block => block.Kind == ToolResultBlockKind.Metric));
|
||||
Assert.IsTrue(document.Blocks.Any(block => block.Kind == ToolResultBlockKind.Table));
|
||||
var chart = document.Blocks.FirstOrDefault(block => block.Kind == ToolResultBlockKind.LineChart);
|
||||
Assert.IsNotNull(chart);
|
||||
Assert.IsGreaterThanOrEqualTo(chart.Rows.Count, 4);
|
||||
Assert.AreEqual("USD/oz", chart.Metadata?["chartUnit"]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateNoticeJsonKeepsPlainTextAndAddsMarkdown()
|
||||
{
|
||||
var repoRoot = Directory.GetParent(FindAssetsRoot())!.FullName;
|
||||
var noticePath = Path.Combine(repoRoot, "update-notice", "2.0.6.3.json");
|
||||
var totalPath = Path.Combine(repoRoot, "update-notice", "total.json");
|
||||
|
||||
using var notice = JsonDocument.Parse(File.ReadAllText(noticePath));
|
||||
var noticeRoot = notice.RootElement;
|
||||
Assert.AreEqual("2.0.6.3", noticeRoot.GetProperty("app_version").GetString());
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(noticeRoot.GetProperty("message").GetString()));
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(noticeRoot.GetProperty("release_notes").GetString()));
|
||||
StringAssert.Contains(noticeRoot.GetProperty("message_md").GetString(), "YMhut Box 2.0.6.3");
|
||||
StringAssert.Contains(noticeRoot.GetProperty("release_notes_md").GetString(), "QQ 信息");
|
||||
StringAssert.Contains(noticeRoot.GetProperty("release_notes_md").GetString(), "安全浏览器");
|
||||
|
||||
using var total = JsonDocument.Parse(File.ReadAllText(totalPath));
|
||||
var totalRoot = total.RootElement;
|
||||
Assert.AreEqual("2.0.6.3", totalRoot.GetProperty("latest_version").GetString());
|
||||
var latest = totalRoot.GetProperty("latest");
|
||||
Assert.AreEqual("2.0.6.3", latest.GetProperty("version").GetString());
|
||||
StringAssert.Contains(latest.GetProperty("release_notes_md").GetString(), "QQ 信息");
|
||||
Assert.AreEqual("2.0.6.3", totalRoot.GetProperty("versions")[0].GetProperty("version").GetString());
|
||||
StringAssert.Contains(totalRoot.GetProperty("versions")[0].GetProperty("summary").GetString(), "QQ 信息");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void WeatherRemoteResultBuildsSummaryForecastAndSourceMetadata()
|
||||
{
|
||||
Assert.IsTrue(ApiEndpoints.TryGet("weather", out var endpoint));
|
||||
var response = new ApiResponse(endpoint.Id, new Uri("https://api.open-meteo.com/v1/forecast"), true, "{}", null, DateTimeOffset.Now);
|
||||
var output = """
|
||||
Data source: Open-Meteo (public trusted source)
|
||||
Fetched at: 2026-05-28 10:00:00 +08:00
|
||||
|
||||
Location: Beijing China
|
||||
Current temperature: 22.5 掳C
|
||||
Apparent temperature: 23 掳C
|
||||
Relative humidity: 56%
|
||||
Wind speed: 12 km/h
|
||||
|
||||
Next three days:
|
||||
2026-05-28: 18 - 27 掳C
|
||||
2026-05-29: 19 - 28 掳C
|
||||
""";
|
||||
|
||||
var document = ToolResultBuilder.FromRemote(endpoint, response, output, "zh-CN");
|
||||
|
||||
Assert.IsTrue(document.Metadata.TryGetValue("sourceVisibility", out var visibility));
|
||||
Assert.AreEqual(ApiSourceVisibility.PublicTrusted.ToString(), visibility);
|
||||
Assert.IsTrue(document.Blocks.Any(block => block.Kind == ToolResultBlockKind.Metric));
|
||||
Assert.IsTrue(document.Blocks.Any(block => block.Kind == ToolResultBlockKind.Table));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void HistoryRemoteResultBuildsTimelineInsteadOfPlainText()
|
||||
{
|
||||
Assert.IsTrue(ApiEndpoints.TryGet("history_today", out var endpoint));
|
||||
var response = new ApiResponse(endpoint.Id, new Uri("https://api.wikimedia.org/feed/v1/wikipedia/zh/onthisday/all/05/28"), true, "{}", null, DateTimeOffset.Now);
|
||||
var output = """
|
||||
EVENTS
|
||||
- 585: Historical event
|
||||
- 1926: Another event
|
||||
|
||||
BIRTHS
|
||||
- 1908: Person born
|
||||
""";
|
||||
|
||||
var document = ToolResultBuilder.FromRemote(endpoint, response, output, "zh-CN");
|
||||
|
||||
Assert.IsTrue(document.Blocks.Any(block => block.Kind == ToolResultBlockKind.Timeline));
|
||||
Assert.IsFalse(document.Blocks.All(block => block.Kind == ToolResultBlockKind.Text));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task LocalToolFamiliesReturnSemanticDocumentBlocks()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
|
||||
var hash = await ToolExecutor.ExecuteAsync(catalog.GetById("hash_generator")!, "YMhut");
|
||||
Assert.IsTrue(hash.Document!.Blocks.Any(block => block.Kind == ToolResultBlockKind.Table));
|
||||
|
||||
var base64 = await ToolExecutor.ExecuteAsync(catalog.GetById("base64_codec")!, "YMhut");
|
||||
Assert.IsTrue(base64.Document!.Blocks.Any(block => block.Kind == ToolResultBlockKind.KeyValue));
|
||||
|
||||
var calculator = await ToolExecutor.ExecuteAsync(catalog.GetById("number_base")!, "255");
|
||||
Assert.IsTrue(calculator.Document!.Blocks.Any(block => block.Kind == ToolResultBlockKind.Metric));
|
||||
|
||||
var diff = await ToolExecutor.ExecuteAsync(catalog.GetById("text_diff")!, "a\n---\nb");
|
||||
Assert.IsTrue(diff.Document!.Blocks.Any(block => block.Kind == ToolResultBlockKind.Diff));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task DataSetToolsReadBundledAssets()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
var reference = new ReferenceDataService(AppPaths.ForCurrentUser(
|
||||
Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N")),
|
||||
FindAssetsRoot()));
|
||||
|
||||
var car = await ToolExecutor.ExecuteAsync(catalog.GetById("car_info")!, "京A12345", referenceDataService: reference);
|
||||
var skin = await ToolExecutor.ExecuteAsync(catalog.GetById("sanguosha_skin")!, "刘备", referenceDataService: reference);
|
||||
|
||||
Assert.IsTrue(car.Ok);
|
||||
Assert.IsTrue(skin.Ok);
|
||||
StringAssert.Contains(car.Output, "北京");
|
||||
StringAssert.Contains(skin.Output, "刘备");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ReferenceDataServiceReadsDatPackageWhenJsonIsPacked()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "ymhut-box-tests", Guid.NewGuid().ToString("N"));
|
||||
var assetsRoot = Path.Combine(tempRoot, "Assets");
|
||||
var dataRoot = Path.Combine(assetsRoot, "data");
|
||||
Directory.CreateDirectory(dataRoot);
|
||||
var datPath = Path.Combine(dataRoot, "reference-data.dat");
|
||||
|
||||
using (var archive = ZipFile.Open(datPath, ZipArchiveMode.Create))
|
||||
{
|
||||
var entry = archive.CreateEntry("data/reference/ymhut_reference_data.json");
|
||||
await using var stream = entry.Open();
|
||||
await using var writer = new StreamWriter(stream);
|
||||
await writer.WriteAsync("{\"source\":\"packed\"}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var service = new ReferenceDataService(AppPaths.ForCurrentUser(tempRoot, assetsRoot));
|
||||
|
||||
var text = await service.ReadTextAsync("data/reference/ymhut_reference_data.json");
|
||||
|
||||
Assert.IsTrue(service.Exists("data/reference/ymhut_reference_data.json"));
|
||||
Assert.AreEqual("{\"source\":\"packed\"}", text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task SystemToolProvidesNativeStatus()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("system_tool")!;
|
||||
|
||||
var result = await ToolExecutor.ExecuteAsync(module, "status");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, ".NET");
|
||||
StringAssert.Contains(result.Output, "calculator");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void OfficialSourceHidesPublicSourceUrlInDocumentMetadata()
|
||||
{
|
||||
var endpoint = new ApiEndpoint(
|
||||
"dns_query",
|
||||
"DNS",
|
||||
"https://dns.google/resolve?name={value}",
|
||||
"Google Public DNS",
|
||||
"https://developers.google.com/speed/public-dns/docs/doh/json");
|
||||
var response = new ApiResponse(endpoint.Id, new Uri("https://dns.google/resolve?name=example.com"), true, "Status: 0", null, DateTimeOffset.Now);
|
||||
|
||||
var document = ToolResultBuilder.FromRemote(endpoint, response, "Status: 0", "zh-CN");
|
||||
|
||||
Assert.IsFalse(document.Metadata.ContainsKey("sourceUrl"));
|
||||
Assert.IsFalse(document.Blocks.Any(block =>
|
||||
block.Metadata is not null &&
|
||||
block.Metadata.ContainsKey("sourceUrl")));
|
||||
Assert.IsTrue(document.Blocks.Any(block =>
|
||||
block.Metadata is not null &&
|
||||
block.Metadata.TryGetValue("sourceVisibility", out var visibility) &&
|
||||
visibility == ApiSourceVisibility.PublicOfficial.ToString()));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SensitivePrivateSourceHidesEndpointInDocumentMetadata()
|
||||
{
|
||||
var endpoint = new ApiEndpoint(
|
||||
"media_types",
|
||||
"Media config",
|
||||
"https://update.ymhut.cn/media-types.json",
|
||||
"YMhut remote config",
|
||||
"https://update.ymhut.cn/media-types.json",
|
||||
false,
|
||||
ApiSourceVisibility.SensitivePrivate);
|
||||
var response = new ApiResponse(endpoint.Id, new Uri(endpoint.SourceUrl), true, "{}", null, DateTimeOffset.Now);
|
||||
|
||||
var document = ToolResultBuilder.FromRemote(endpoint, response, "{}", "zh-CN");
|
||||
|
||||
Assert.IsFalse(document.Metadata.Values.Any(value => value.Contains("update.ymhut.cn", StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.IsTrue(document.Blocks.Any(block =>
|
||||
block.Metadata is not null &&
|
||||
block.Metadata.TryGetValue("sourceSensitivity", out var sensitivity) &&
|
||||
sensitivity == "sensitive"));
|
||||
Assert.IsFalse(document.Blocks.Any(block =>
|
||||
block.Metadata is not null &&
|
||||
block.Metadata.Values.Any(value => value.Contains("update.ymhut.cn", StringComparison.OrdinalIgnoreCase))));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void HttpDiagnosticHidesDynamicTargetAsEndpoint()
|
||||
{
|
||||
Assert.IsTrue(ApiEndpoints.TryGet("http_diagnostic", out var endpoint));
|
||||
var response = new ApiResponse(endpoint.Id, new Uri("https://example.com/private/path?token=secret"), true, "<title>Example</title>", null, DateTimeOffset.Now);
|
||||
|
||||
var document = ToolResultBuilder.FromRemote(endpoint, response, "Example\n1. Status page", "zh-CN");
|
||||
|
||||
Assert.IsTrue(endpoint.ShouldHideEndpoint);
|
||||
Assert.IsFalse(document.Metadata.ContainsKey("sourceUrl"));
|
||||
Assert.IsFalse(document.Blocks.Any(block =>
|
||||
block.Metadata is not null &&
|
||||
block.Metadata.TryGetValue("sourceUrl", out var sourceUrl) &&
|
||||
sourceUrl.Contains("example.com", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
private sealed class FakeHttpService(string content) : IHttpService
|
||||
{
|
||||
public Task<HttpServiceResult> GetAsync(Uri uri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new HttpServiceResult(System.Net.HttpStatusCode.OK, content, new Dictionary<string, string[]>(), TimeSpan.FromMilliseconds(1)));
|
||||
}
|
||||
|
||||
public Task<string> GetStringAsync(Uri uri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(content);
|
||||
}
|
||||
|
||||
public Task<HttpServiceResult> SendAsync(
|
||||
Uri uri,
|
||||
string method = "GET",
|
||||
string? body = null,
|
||||
IReadOnlyDictionary<string, string>? headers = null,
|
||||
bool ensureSuccess = true,
|
||||
HttpRequestPolicy? policy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetAsync(uri, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindAssetsRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(Directory.GetCurrentDirectory());
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, "assets");
|
||||
if (File.Exists(Path.Combine(candidate, "data", "reference", "ymhut_reference_data.json")))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Unable to locate repository assets directory.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.Api;
|
||||
using YMhut.Box.Core.Tools;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class ToolRemoteRankingTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void HotboardUsesPlatformSelectionBeforeRunning()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("hotboard")!;
|
||||
var spec = ToolPageSpecCatalog.For(module);
|
||||
|
||||
Assert.AreEqual(ToolPrimaryInputKind.FixedOptions, spec.PrimaryInput);
|
||||
Assert.IsFalse(spec.AutoRunOnOpen);
|
||||
Assert.IsTrue(spec.Parameters.Any(parameter =>
|
||||
parameter.Key == "preset" &&
|
||||
parameter.Options.Any(option => option.Value == "百度")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void HotboardPlatformResolvesDailyHotTitleParameter()
|
||||
{
|
||||
var ok = ApiEndpoints.TryResolve("hotboard", "百度", out var endpoint, out var uri);
|
||||
|
||||
Assert.IsTrue(ok);
|
||||
Assert.AreEqual("hotboard", endpoint.Id);
|
||||
StringAssert.Contains(Uri.UnescapeDataString(uri.Query), "title=百度");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DailyHotRankingKeepsPublicUriOutOfVisibleTitle()
|
||||
{
|
||||
Assert.IsTrue(ApiEndpoints.TryGet("hotboard", out var endpoint));
|
||||
var uri = new Uri("https://example.invalid/api/dailyhot?title=test");
|
||||
var response = new ApiResponse(
|
||||
endpoint.Id,
|
||||
uri,
|
||||
Success: true,
|
||||
Content: string.Empty,
|
||||
Error: null,
|
||||
FetchedAt: DateTimeOffset.UnixEpoch);
|
||||
var document = ToolResultBuilder.FromRemote(
|
||||
endpoint,
|
||||
response,
|
||||
"""
|
||||
1. YMhut 发布新版本 / 热度 999 / https://example.com/news?id=1
|
||||
2. 第二条 https://example.com/news?id=2
|
||||
""");
|
||||
|
||||
var ranked = document.Blocks.First(block => block.Kind == ToolResultBlockKind.RankedList);
|
||||
var first = ranked.Items[0];
|
||||
|
||||
Assert.AreEqual("https://example.com/news?id=1", first.Uri);
|
||||
Assert.IsFalse(first.Title.Contains("https://", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.IsTrue(ranked.Items.All(item => !item.Title.Contains("https://", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.Tools;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class ToolResultExperienceCatalogTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void EveryDefaultToolHasUniqueResultExperience()
|
||||
{
|
||||
var catalog = new ToolCatalog(ToolCatalog.DefaultModules());
|
||||
var experiences = new ToolResultExperienceCatalog(catalog);
|
||||
|
||||
Assert.AreEqual(catalog.Modules.Count, experiences.Experiences.Count);
|
||||
|
||||
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var module in catalog.Modules)
|
||||
{
|
||||
var experience = experiences.GetRequired(module.Id);
|
||||
Assert.AreEqual(module.Id, experience.ToolId);
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(experience.ExperienceId), module.Id);
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(experience.Layout), module.Id);
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(experience.PrimaryPrimitive), module.Id);
|
||||
Assert.IsTrue(ids.Add(experience.ExperienceId), $"Duplicate experienceId: {experience.ExperienceId}");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EveryDefaultToolHasUniquePageExperience()
|
||||
{
|
||||
var catalog = new ToolCatalog(ToolCatalog.DefaultModules());
|
||||
var experiences = new ToolResultExperienceCatalog(catalog);
|
||||
|
||||
Assert.AreEqual(catalog.Modules.Count, experiences.PageExperiences.Count);
|
||||
|
||||
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var module in catalog.Modules)
|
||||
{
|
||||
var experience = experiences.GetRequiredPage(module.Id);
|
||||
Assert.AreEqual(module.Id, experience.ToolId);
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(experience.ExperienceId), module.Id);
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(experience.InputLayout), module.Id);
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(experience.ResultLayout), module.Id);
|
||||
Assert.IsTrue(experience.InputPrimitives.Count > 0, module.Id);
|
||||
Assert.IsTrue(experience.ResultPrimitives.Count > 0, module.Id);
|
||||
Assert.IsTrue(ids.Add(experience.ExperienceId), $"Duplicate page experienceId: {experience.ExperienceId}");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PrivacySanitizerOnlyRedactsYmHutAndApiRequestUrls()
|
||||
{
|
||||
const string input =
|
||||
"public https://example.com/path?q=1; ymhut https://update.ymhut.cn/update-info.json; api_url=https://api.example.net/v1/items";
|
||||
|
||||
var sanitized = ToolResultPrivacySanitizer.Redact(input, "en-US");
|
||||
|
||||
StringAssert.Contains(sanitized, "https://example.com/path?q=1");
|
||||
Assert.IsFalse(sanitized.Contains("update.ymhut.cn", StringComparison.OrdinalIgnoreCase), sanitized);
|
||||
Assert.IsFalse(sanitized.Contains("api.example.net", StringComparison.OrdinalIgnoreCase), sanitized);
|
||||
StringAssert.Contains(sanitized, "[YMhut endpoint hidden]");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.Tools;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class ToolWebPayloadSerializationTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = CreateOptions();
|
||||
|
||||
[TestMethod]
|
||||
public void ToolPagePayloadSerializesRulesParametersAndActions()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
var module = catalog.GetById("json_formatter")!;
|
||||
var spec = ToolPageSpecCatalog.For(module);
|
||||
var experiences = new ToolResultExperienceCatalog(catalog);
|
||||
|
||||
Assert.IsNotEmpty(spec.Rules);
|
||||
|
||||
var payload = new ToolPageWebPayload(
|
||||
module.Id,
|
||||
module.Metadata.Name,
|
||||
module.Metadata.Description,
|
||||
spec,
|
||||
experiences.GetRequiredPage(module.Id),
|
||||
new ToolInputState(
|
||||
"""{"name":"YMhut"}""",
|
||||
[new ToolInputField("input", "Input", "textarea", """{"name":"YMhut"}""")],
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["mode"] = "format" },
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["hint"] = "Enter values or choose options, then run the tool." }),
|
||||
"Light",
|
||||
"zh-CN",
|
||||
new ToolResultPrivacyPolicy(["update.ymhut.cn"]),
|
||||
new ToolPageRuntimeMetadata(DateTimeOffset.UnixEpoch, AutoRun: false, OfflineCapable: true, "Dev", "\uE943"),
|
||||
["run", "copyResult", "showRaw"]);
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, Options);
|
||||
var roundTrip = JsonSerializer.Deserialize<ToolPageWebPayload>(json, Options);
|
||||
|
||||
Assert.IsNotNull(roundTrip);
|
||||
Assert.AreEqual(module.Id, roundTrip.ToolId);
|
||||
Assert.IsNotEmpty(roundTrip.Spec.Rules);
|
||||
Assert.IsNotEmpty(roundTrip.Spec.Parameters);
|
||||
Assert.AreEqual("format", roundTrip.Input.Rules["mode"]);
|
||||
CollectionAssert.Contains(roundTrip.AvailableActions.ToList(), "copyResult");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToolResultPayloadSerializesRepresentativeBlockKinds()
|
||||
{
|
||||
var catalog = new ToolCatalog();
|
||||
var module = catalog.GetById("hotboard")!;
|
||||
var experiences = new ToolResultExperienceCatalog(catalog);
|
||||
var blocks = new[]
|
||||
{
|
||||
ToolResultBlock.KeyValue("Details", [new("Key", "Value")]),
|
||||
ToolResultBlock.Table("Table", [new[] { "Name", "Value" }, new[] { "YMhut", "Box" }]),
|
||||
ToolResultBlock.List(ToolResultBlockKind.RankedList, "Ranking", [new("1", "First", "Trend", "ok", "https://example.com")]),
|
||||
ToolResultBlock.List(ToolResultBlockKind.NewsList, "News", [new("1", "Headline", "Source", "info", "https://example.com/news")]),
|
||||
ToolResultBlock.File("File", "report.txt", @"C:\Temp\report.txt"),
|
||||
ToolResultBlock.Link("Link", "YMhut", "https://example.com"),
|
||||
ToolResultBlock.Media(ToolResultBlockKind.Media, "Media", "video", "https://example.com/video.mp4"),
|
||||
ToolResultBlock.Json("JSON", """{"ok":true}"""),
|
||||
ToolResultBlock.List(ToolResultBlockKind.Status, "Status", [new("OK", "Ready", "", "ok", "")])
|
||||
};
|
||||
var document = new ToolResultDocument(
|
||||
module.Id,
|
||||
ToolResultKind.RankedList,
|
||||
"raw output",
|
||||
blocks,
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["displayProfile"] = "cards" },
|
||||
"raw output",
|
||||
"test",
|
||||
"ok");
|
||||
var payload = new ToolResultWebPayload(
|
||||
module.Id,
|
||||
module.Metadata.Name,
|
||||
document,
|
||||
experiences.GetRequired(module.Id).ExperienceId,
|
||||
"Dark",
|
||||
"en-US",
|
||||
new ToolResultPrivacyPolicy(["update.ymhut.cn"]),
|
||||
new ToolResultRuntimeMetadata(42, DateTimeOffset.UnixEpoch, Cached: false, "test", new Dictionary<string, string>()),
|
||||
experiences.GetRequired(module.Id));
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, Options);
|
||||
var roundTrip = JsonSerializer.Deserialize<ToolResultWebPayload>(json, Options);
|
||||
|
||||
Assert.IsNotNull(roundTrip);
|
||||
Assert.HasCount(blocks.Length, roundTrip.ResultDocument.Blocks);
|
||||
CollectionAssert.Contains(roundTrip.ResultDocument.Blocks.Select(block => block.Kind).ToList(), ToolResultBlockKind.RankedList);
|
||||
CollectionAssert.Contains(roundTrip.ResultDocument.Blocks.Select(block => block.Kind).ToList(), ToolResultBlockKind.Media);
|
||||
StringAssert.Contains(json, nameof(ToolResultBlockKind.RankedList));
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
options.Converters.Add(new JsonStringEnumConverter());
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.Tools;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class ToolWorkerTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void WorkerProtocolRoundTripsExecuteMessage()
|
||||
{
|
||||
var message = new ToolWorkerMessage(
|
||||
ToolWorkerProtocol.ExecuteTool,
|
||||
"req-1",
|
||||
ToolId: "json_formatter",
|
||||
Input: "{\"name\":\"YMhut\"}",
|
||||
TimeoutMs: 120000,
|
||||
Language: "en-US");
|
||||
|
||||
var line = ToolWorkerProtocol.Serialize(message);
|
||||
var parsed = ToolWorkerProtocol.Deserialize(line);
|
||||
|
||||
Assert.IsNotNull(parsed);
|
||||
Assert.AreEqual(ToolWorkerProtocol.ExecuteTool, parsed.Type);
|
||||
Assert.AreEqual("req-1", parsed.RequestId);
|
||||
Assert.AreEqual("json_formatter", parsed.ToolId);
|
||||
Assert.AreEqual("{\"name\":\"YMhut\"}", parsed.Input);
|
||||
Assert.AreEqual(120000, parsed.TimeoutMs);
|
||||
Assert.AreEqual("en-US", parsed.Language);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task InProcessWorkerExecutesToolByModule()
|
||||
{
|
||||
var module = new ToolCatalog().GetById("json_formatter")!;
|
||||
var worker = new ToolWorkerService();
|
||||
|
||||
var result = await worker.ExecuteToolAsync(module, "{\"name\":\"YMhut\"}");
|
||||
|
||||
Assert.IsTrue(result.Ok);
|
||||
StringAssert.Contains(result.Output, "\"name\": \"YMhut\"");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task InProcessWorkerHonorsCancellation()
|
||||
{
|
||||
var worker = new ToolWorkerService();
|
||||
using var source = new CancellationTokenSource();
|
||||
await source.CancelAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await worker.RunAsync(
|
||||
async token =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), token);
|
||||
return true;
|
||||
},
|
||||
source.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.Fail("Expected TaskCanceledException.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.Tools;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class ToolboxLayoutTests
|
||||
{
|
||||
[TestMethod]
|
||||
[DataRow(480, 1)]
|
||||
[DataRow(760, 2)]
|
||||
[DataRow(1120, 3)]
|
||||
[DataRow(1440, 4)]
|
||||
[DataRow(1720, 5)]
|
||||
[DataRow(2200, 6)]
|
||||
public void ToolboxLayoutUsesStableBreakpoints(double width, int expectedColumns)
|
||||
{
|
||||
var layout = ToolboxLayoutCalculator.Calculate(width);
|
||||
|
||||
Assert.AreEqual(expectedColumns, layout.Columns);
|
||||
Assert.IsGreaterThanOrEqualTo(ToolboxLayoutCalculator.MinCardWidth, layout.CardWidth);
|
||||
Assert.IsLessThanOrEqualTo(ToolboxLayoutCalculator.MaxCardWidth, layout.CardWidth);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToolboxLayoutKeepsCategoryRailOutOfNarrowWidths()
|
||||
{
|
||||
Assert.IsFalse(ToolboxLayoutCalculator.Calculate(760).ShowCategoryRail);
|
||||
Assert.IsTrue(ToolboxLayoutCalculator.Calculate(1180).ShowCategoryRail);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToolboxLayoutHasSafeFallbackForUnknownWidth()
|
||||
{
|
||||
var layout = ToolboxLayoutCalculator.Calculate(0);
|
||||
|
||||
Assert.AreEqual(3, layout.Columns);
|
||||
Assert.AreEqual(318, layout.CardWidth);
|
||||
Assert.IsTrue(layout.ShowCategoryRail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.Updates;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class UpdateVersionComparerTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void NormalizeVersionDoesNotAppendBuildWhenVersionAlreadyHasRevision()
|
||||
{
|
||||
Assert.AreEqual("2.0.6.2", UpdateVersionComparer.NormalizeVersion("2.0.6.2", "2"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NormalizeVersionAppendsBuildOnlyToShortVersion()
|
||||
{
|
||||
Assert.AreEqual("2.0.6.2", UpdateVersionComparer.NormalizeVersion("2.0.6", "2"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SameVersionWithBuildIsNotNewer()
|
||||
{
|
||||
Assert.IsFalse(UpdateVersionComparer.IsRemoteNewer("2.0.6.2", "2", "2.0.6.2"));
|
||||
Assert.AreEqual(0, UpdateVersionComparer.Compare("2.0.6.2", "2", "2.0.6.2"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void HigherRemoteVersionIsNewer()
|
||||
{
|
||||
Assert.IsTrue(UpdateVersionComparer.IsRemoteNewer("2.0.6.3", "", "2.0.6.2"));
|
||||
Assert.IsTrue(UpdateVersionComparer.IsRemoteNewer("2.0.7", "", "2.0.6.2"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LowerRemoteVersionIsNotNewer()
|
||||
{
|
||||
Assert.IsFalse(UpdateVersionComparer.IsRemoteNewer("2.0.6.1", "", "2.0.6.2"));
|
||||
Assert.IsFalse(UpdateVersionComparer.IsRemoteNewer("2.0.5", "", "2.0.6.2"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="4.0.2" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="4.0.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\YMhut.Box.Core\YMhut.Box.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user