From 72f127a427830f50bf58a1d267e1f2dbb1fb4cf2 Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 6 Aug 2026 13:02:49 +0300 Subject: [PATCH] feat: restore product-specific legacy project selection --- Launcher.Next.App/MainWindow.xaml.cs | 69 ++++++++++++- .../LegacyProductSelection.cs | 37 +++++++ .../LegacyProductSelectionTests.cs | 99 +++++++++++++++++++ 3 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 Launcher.Next.Application/LegacyProductSelection.cs create mode 100644 tests/Launcher.Next.Tests/LegacyProductSelectionTests.cs diff --git a/Launcher.Next.App/MainWindow.xaml.cs b/Launcher.Next.App/MainWindow.xaml.cs index eca8924..ee69746 100644 --- a/Launcher.Next.App/MainWindow.xaml.cs +++ b/Launcher.Next.App/MainWindow.xaml.cs @@ -15,8 +15,18 @@ public partial class MainWindow : System.Windows.Window private readonly LegacyConfigurationAdapter adapter=new(); private LauncherProduct? selected; private string? projectPath; - public MainWindow() { InitializeComponent(); Initialize(); } - internal MainWindow(LocalParityLaunchProfile profile) { localProfile=profile; InitializeComponent(); Initialize(); } + private string? manualRosterPath; + public MainWindow() { InitializeComponent(); WireSourceVerifiedSelection(); Initialize(); } + internal MainWindow(LocalParityLaunchProfile profile) { localProfile=profile; InitializeComponent(); WireSourceVerifiedSelection(); Initialize(); } + private void WireSourceVerifiedSelection() + { + SelectProjectButton.Click -= SelectProject_Click; + SelectProjectButton.Click += SelectProjectFile_Click; + AutomaticMode.Checked -= LoadMode_Changed; + ListMode.Checked -= LoadMode_Changed; + AutomaticMode.Checked += LoadModeChanged; + ListMode.Checked += LoadModeChanged; + } private void Initialize() { var plant=new PlantProductDiscovery(new WindowsRegistryReader()).DiscoverPlant3D(2026); @@ -34,7 +44,62 @@ public partial class MainWindow : System.Windows.Window var path=Path.GetFullPath(dialog.FolderName); if(!Directory.Exists(path)){Status.Text="Выбранная папка недоступна.";return;} if(selected.Id is "plant3d" or "pnid" && !File.Exists(Path.Combine(path,"Project.xml"))){Status.Text="Выбранная папка не является проектом Plant 3D.";return;} projectPath=path; ProjectPath.Text=path; ResolvePlugins(); } + private void SelectProjectFile_Click(object sender, System.Windows.RoutedEventArgs e) + { + if (selected is null) return; + var product = LegacyProductSelections.For(selected.Id); + var dialog = new Microsoft.Win32.OpenFileDialog { Title = "Select project", Filter = product.Filter, CheckFileExists = true, Multiselect = false }; + if (dialog.ShowDialog() != true) return; + try + { + projectPath = LegacyProductSelections.ValidateSelectedFile(product, dialog.FileName); + manualRosterPath = null; + ProjectPath.Text = Path.GetFileName(projectPath); + ResolvePluginsSourceVerified(); + } + catch (Exception ex) { Status.Text = UserMessage(ex.Message); } + } + + private void LoadModeChanged(object sender, System.Windows.RoutedEventArgs e) + { + if (projectPath is null || selected is null) return; + if (ListMode.IsChecked == true) + { + var dialog = new Microsoft.Win32.OpenFileDialog { Title = "Select download_list.xml", Filter = "download_list.xml (*.xml)|*.xml|All files (*.*)|*.*", CheckFileExists = true, Multiselect = false }; + if (dialog.ShowDialog() != true) { AutomaticMode.IsChecked = true; return; } + manualRosterPath = dialog.FileName; + } + else manualRosterPath = null; + ResolvePluginsSourceVerified(); + } + private void LoadMode_Changed(object sender,System.Windows.RoutedEventArgs e) { if(projectPath is not null)ResolvePlugins(); } + private void ResolvePluginsSourceVerified() + { + if (selected is null || projectPath is null) return; + if (localProfile is not null && selected.Id == "plant3d") { ResolvePlugins(); return; } + try + { + var product = LegacyProductSelections.For(selected.Id); + var plan = LegacyProductSelections.ReadRoster(product, projectPath, manualRosterPath, adapter); + Plugins.ItemsSource = plan.OrderedIds; + Plugins.IsEnabled = false; + PluginStatus.Text = $"List source: {plan.Source}."; + StartButton.IsEnabled = false; + Status.Text = plan.UnmappedIds.Count == 0 + ? "Packages for the selected plugins are not mapped to 2026 packages." + : $"Packages for {product.DisplayName} are not yet available for 2026: {string.Join(", ", plan.UnmappedIds)}"; + } + catch (Exception ex) + { + Plugins.ItemsSource = null; + Plugins.IsEnabled = false; + StartButton.IsEnabled = false; + Status.Text = ex.Message == "LEGACY_DOWNLOAD_LIST_NOT_FOUND" + ? "Для выбранного проекта не найден список плагинов" + : UserMessage(ex.Message); + } + } private void ResolvePlugins() { if(selected is null||projectPath is null)return; diff --git a/Launcher.Next.Application/LegacyProductSelection.cs b/Launcher.Next.Application/LegacyProductSelection.cs new file mode 100644 index 0000000..cd988bc --- /dev/null +++ b/Launcher.Next.Application/LegacyProductSelection.cs @@ -0,0 +1,37 @@ +namespace Launcher.Next.Application; + +public sealed record LegacyProductSelection(string Id, string DisplayName, string Filter, string RequiredFileName, string Pattern, string[] SourceVerifiedLegacyIds, string[] UnmappedLegacyIds, bool SupportsPlantBootstrap); +public sealed record LegacyPluginPlan(string RosterPath, string Source, IReadOnlyList OrderedIds, IReadOnlyList UnmappedIds); + +public static class LegacyProductSelections +{ + public static readonly LegacyProductSelection Plant3D = new("plant3d", "Plant 3D 2026", "Project files |Project.xml|All files (*.*)|*.*", "Project.xml", "Plant 3D_2019", [], [], true); + public static readonly LegacyProductSelection Pnid = new("pnid", "Plant P&ID 2026", "Project files |Project.xml|All files (*.*)|*.*", "Project.xml", "Plant PnID_2019", ["c1b999d6c16029e9ac5b1e1a8afe1500", "5b80379c3ec715e7da0ee414c53aa7bc", "1111"], ["c1b999d6c16029e9ac5b1e1a8afe1500", "5b80379c3ec715e7da0ee414c53aa7bc", "1111"], true); + public static readonly LegacyProductSelection Navisworks = new("navisworks", "Navisworks Manage 2026", "nwd files (*.nwd)|*.nwd|All files (*.*)|*.*", ".nwd", "Navisworks_Manage_2018", ["7dd981169fa14d21aaef3fcdb0e7e970"], ["7dd981169fa14d21aaef3fcdb0e7e970"], false); + + public static LegacyProductSelection For(string id) => id switch { "plant3d" => Plant3D, "pnid" => Pnid, "navisworks" => Navisworks, _ => throw new InvalidOperationException("LEGACY_PRODUCT_UNKNOWN") }; + + public static string ValidateSelectedFile(LegacyProductSelection product, string selected) + { + var file = Path.GetFullPath(selected); + if (!File.Exists(file) || File.GetAttributes(file).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("LEGACY_PROJECT_FILE_INVALID"); + var correctName = product.RequiredFileName.StartsWith('.') ? file.EndsWith(product.RequiredFileName, StringComparison.OrdinalIgnoreCase) : string.Equals(Path.GetFileName(file), product.RequiredFileName, StringComparison.OrdinalIgnoreCase); + if (!correctName) throw new InvalidOperationException("LEGACY_PROJECT_FILE_PRODUCT_MISMATCH"); + return file; + } + + public static string AutomaticRoster(LegacyProductSelection product, string selectedFile) + { + var projectDirectory = Path.GetDirectoryName(ValidateSelectedFile(product, selectedFile))!; + return Path.GetFullPath(Path.Combine(projectDirectory, "..", "_System", product.Pattern, "Applications", "download_list.xml")); + } + + public static LegacyPluginPlan ReadRoster(LegacyProductSelection product, string selectedFile, string? manualXml, LegacyConfigurationAdapter parser) + { + var roster = manualXml is null ? AutomaticRoster(product, selectedFile) : Path.GetFullPath(manualXml); + if (!File.Exists(roster) || File.GetAttributes(roster).HasFlag(FileAttributes.ReparsePoint) || !string.Equals(Path.GetFileName(roster), "download_list.xml", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_DOWNLOAD_LIST_NOT_FOUND"); + var ids = parser.ParseDownloadList(File.ReadAllText(roster)).PluginIds; + if (product.SourceVerifiedLegacyIds.Length > 0 && ids.Any(id => !product.SourceVerifiedLegacyIds.Contains(id, StringComparer.OrdinalIgnoreCase))) throw new InvalidOperationException("LEGACY_PLUGIN_PRODUCT_MISMATCH"); + return new(roster, manualXml is null ? "Automatic" : "Manual", ids, product.UnmappedLegacyIds.Intersect(ids, StringComparer.OrdinalIgnoreCase).ToArray()); + } +} diff --git a/tests/Launcher.Next.Tests/LegacyProductSelectionTests.cs b/tests/Launcher.Next.Tests/LegacyProductSelectionTests.cs new file mode 100644 index 0000000..20a68d9 --- /dev/null +++ b/tests/Launcher.Next.Tests/LegacyProductSelectionTests.cs @@ -0,0 +1,99 @@ +using Launcher.Next.Application; +using Xunit; + +namespace Launcher.Next.Tests; + +public sealed class LegacyProductSelectionTests +{ + [Fact] + public void SourceVerifiedProducts_ExposeExactOpenFileFilters() + { + Assert.Equal("Project files |Project.xml|All files (*.*)|*.*", LegacyProductSelections.Pnid.Filter); + Assert.Equal("nwd files (*.nwd)|*.nwd|All files (*.*)|*.*", LegacyProductSelections.Navisworks.Filter); + Assert.Equal("Project.xml", LegacyProductSelections.Pnid.RequiredFileName); + Assert.Equal(".nwd", LegacyProductSelections.Navisworks.RequiredFileName); + } + + [Fact] + public void AutomaticRoster_UsesOnlyExactLegacyRelativeLocation() + { + using var fixture = new SelectionFixture(); + var project = fixture.ProjectFile("Project.xml"); + var actual = LegacyProductSelections.AutomaticRoster(LegacyProductSelections.Pnid, project); + Assert.Equal(Path.Combine(fixture.Root, "_System", "Plant PnID_2019", "Applications", "download_list.xml"), actual); + Assert.DoesNotContain("scan", actual, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SelectedProjectFile_IsProductSpecificAndRejectsWrongExtension() + { + using var fixture = new SelectionFixture(); + var nwd = fixture.ProjectFile("model.nwd"); + Assert.Equal(nwd, LegacyProductSelections.ValidateSelectedFile(LegacyProductSelections.Navisworks, nwd)); + Assert.Throws(() => LegacyProductSelections.ValidateSelectedFile(LegacyProductSelections.Pnid, nwd)); + } + + [Fact] + public void AutomaticPnidRoster_PreservesOrderAndReportsExactUnmappedIds() + { + using var fixture = new SelectionFixture(); + var project = fixture.ProjectFile("Project.xml"); + fixture.WriteAutomatic("Plant PnID_2019", "c1b999d6c16029e9ac5b1e1a8afe15005b80379c3ec715e7da0ee414c53aa7bc1111"); + var plan = LegacyProductSelections.ReadRoster(LegacyProductSelections.Pnid, project, null, new LegacyConfigurationAdapter()); + Assert.Equal("Automatic", plan.Source); + Assert.Equal(["c1b999d6c16029e9ac5b1e1a8afe1500", "5b80379c3ec715e7da0ee414c53aa7bc", "1111"], plan.OrderedIds); + Assert.Equal(plan.OrderedIds, plan.UnmappedIds); + } + + [Fact] + public void ManualRoster_DoesNotChangeProjectAndUsesExplicitDownloadList() + { + using var fixture = new SelectionFixture(); + var project = fixture.ProjectFile("model.nwd"); + var manual = Path.Combine(fixture.Root, "download_list.xml"); + File.WriteAllText(manual, "7dd981169fa14d21aaef3fcdb0e7e970"); + var plan = LegacyProductSelections.ReadRoster(LegacyProductSelections.Navisworks, project, manual, new LegacyConfigurationAdapter()); + Assert.Equal("Manual", plan.Source); + Assert.Equal(manual, plan.RosterPath); + Assert.Equal(["7dd981169fa14d21aaef3fcdb0e7e970"], plan.UnmappedIds); + Assert.Equal(project, LegacyProductSelections.ValidateSelectedFile(LegacyProductSelections.Navisworks, project)); + } + + [Fact] + public void MissingAutomaticRoster_FailsClosedWithoutSearchingOtherDirectories() + { + using var fixture = new SelectionFixture(); + var project = fixture.ProjectFile("model.nwd"); + File.WriteAllText(Path.Combine(fixture.Root, "download_list.xml"), "7dd981169fa14d21aaef3fcdb0e7e970"); + var ex = Assert.Throws(() => LegacyProductSelections.ReadRoster(LegacyProductSelections.Navisworks, project, null, new LegacyConfigurationAdapter())); + Assert.Equal("LEGACY_DOWNLOAD_LIST_NOT_FOUND", ex.Message); + } + + [Fact] + public void ProductIncompatibleAndDuplicateIds_AreBlocked() + { + using var fixture = new SelectionFixture(); + var project = fixture.ProjectFile("Project.xml"); + fixture.WriteAutomatic("Plant PnID_2019", "unexpected"); + Assert.Equal("LEGACY_PLUGIN_PRODUCT_MISMATCH", Assert.Throws(() => LegacyProductSelections.ReadRoster(LegacyProductSelections.Pnid, project, null, new LegacyConfigurationAdapter())).Message); + fixture.WriteAutomatic("Plant PnID_2019", "11111111"); + Assert.Equal("LEGACY_DOWNLOAD_SELECTION_INVALID", Assert.Throws(() => LegacyProductSelections.ReadRoster(LegacyProductSelections.Pnid, project, null, new LegacyConfigurationAdapter())).Message); + } + + [Fact] + public void Navisworks_DoesNotAdvertisePlantBootstrap() + { + Assert.False(LegacyProductSelections.Navisworks.SupportsPlantBootstrap); + Assert.True(LegacyProductSelections.Pnid.SupportsPlantBootstrap); + Assert.Equal(["7dd981169fa14d21aaef3fcdb0e7e970"], LegacyProductSelections.Navisworks.UnmappedLegacyIds); + } + + private sealed class SelectionFixture : IDisposable + { + public SelectionFixture() { Root = Path.Combine(Path.GetTempPath(), "launcher-next-selection-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path.Combine(Root, "Project")); } + public string Root { get; } + public string ProjectFile(string name) { var path = Path.Combine(Root, "Project", name); File.WriteAllText(path, "fixture"); return path; } + public void WriteAutomatic(string pattern, string xml) { var directory = Path.Combine(Root, "_System", pattern, "Applications"); Directory.CreateDirectory(directory); File.WriteAllText(Path.Combine(directory, "download_list.xml"), xml); } + public void Dispose() { if (Directory.Exists(Root)) Directory.Delete(Root, true); } + } +}