@ -16,13 +16,20 @@
@@ -16,13 +16,20 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.IO ;
using System.Linq ;
using System.Threading.Tasks ;
using Avalonia.Headless.NUnit ;
using Avalonia.Threading ;
using AwesomeAssertions ;
using ICSharpCode.ILSpyX ;
using ICSharpCode.ILSpy ;
using ICSharpCode.ILSpy.AppEnv ;
using ICSharpCode.ILSpy.Commands ;
using ICSharpCode.ILSpy.Views ;
using NUnit.Framework ;
@ -30,9 +37,10 @@ using NUnit.Framework;
@@ -30,9 +37,10 @@ using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Languages ;
/// <summary>
/// The Export Project/Solution dialog's preview computation (extracted as a pure static so it is
/// testable without the window): per-assembly project name, target subdirectory, and the
/// invalid / duplicate-name / PDB-eligible badges.
/// The Export Project/Solution dialog's choices, extracted as pure statics so they are testable
/// without the window: the preview rows (per-assembly project name, target subdirectory, and the
/// invalid / duplicate-name / PDB-eligible badges) and the solution file name the user may type in
/// solution mode.
/// </summary>
[TestFixture]
public class ExportPreviewRowTests
@ -82,4 +90,114 @@ public class ExportPreviewRowTests
@@ -82,4 +90,114 @@ public class ExportPreviewRowTests
rows . Should ( ) . OnlyContain ( r = > r . HasDuplicateShortName ) ;
rows . Should ( ) . OnlyContain ( r = > r . BadgeText . Contains ( "duplicate name" ) ) ;
}
[AvaloniaTest]
public async Task An_Assembly_That_Failed_To_Load_Is_Badged_As_Invalid ( )
{
var ( _ , vm ) = await TestHarness . BootAsync ( ) ;
var good = await vm . OpenFixtureAsync ( "FixtureA" ) ;
var broken = await vm . OpenBrokenFixtureAsync ( ) ;
var rows = ExportProjectDialog . BuildPreviewRows ( [ good , broken ] , solutionMode : true ) ;
rows . Should ( ) . HaveCount ( 2 ) ;
rows . Single ( r = > r . ProjectName . StartsWith ( broken . ShortName ) ) . BadgeText . Should ( ) . Contain ( "not a valid assembly" ,
"the dialog has to say which rows the export will skip" ) ;
rows . Single ( r = > r . ProjectName . StartsWith ( good . ShortName ) ) . IsValidAssembly . Should ( ) . BeTrue ( ) ;
}
[AvaloniaTest]
public async Task The_Export_Flow_Settles_Loads_Before_The_Dialog_Reads_Them ( )
{
var ( _ , vm ) = await TestHarness . BootAsync ( ) ;
// A LoadedAssembly loads lazily -- the tree builds its entries en masse and only the first await
// starts the work -- so a selected-but-never-opened assembly reaches the export flow with its
// load untouched. Its preview-row badges are read off the load result, so the flow has to settle
// the load first; otherwise the dialog blocks the UI thread forcing one as it builds the rows.
var assembly = new LoadedAssembly ( vm . AssemblyTreeModel . AssemblyList ! , FixtureAssembly . Emit ( "FixturePreview" ) ) ;
assembly . IsLoadedAsValidAssembly . Should ( ) . BeFalse ( "nothing has triggered the load yet" ) ;
await ProjectExport . EnsureAssembliesLoadedAsync ( [ assembly ] ) ;
assembly . IsLoadedAsValidAssembly . Should ( ) . BeTrue (
"the export flow settles every selected assembly's load up front" ) ;
ExportProjectDialog . BuildPreviewRows ( [ assembly ] , solutionMode : false )
. Single ( ) . IsValidAssembly . Should ( ) . BeTrue ( "the dialog now badges it from a completed load" ) ;
}
[AvaloniaTest]
public async Task Solution_Name_Field_Is_Offered_Only_In_Solution_Mode ( )
{
var ( _ , vm ) = await TestHarness . BootAsync ( ) ;
var a = await vm . OpenFixtureAsync ( "FixtureA" ) ;
var b = await vm . OpenFixtureAsync ( "FixtureB" ) ;
var settings = AppComposition . Current . GetExport < SettingsService > ( ) ;
var solutionDialog = new ExportProjectDialog ( settings , [ a , b ] , solutionMode : true ) ;
solutionDialog . Show ( ) ;
solutionDialog . SolutionNamePanel . IsVisible . Should ( ) . BeTrue (
"the .sln is the user's to name when several assemblies are exported" ) ;
solutionDialog . Capture ( "solution-mode" ) ;
solutionDialog . Close ( ) ;
var projectDialog = new ExportProjectDialog ( settings , [ a ] , solutionMode : false ) ;
projectDialog . Show ( ) ;
projectDialog . SolutionNamePanel . IsVisible . Should ( ) . BeFalse (
"a single project export writes no solution, so there is no name to ask for" ) ;
projectDialog . Capture ( "project-mode" ) ;
projectDialog . Close ( ) ;
}
[AvaloniaTest]
public async Task Export_Dialog_Fits_Its_Content_Without_Scrolling ( )
{
var ( _ , vm ) = await TestHarness . BootAsync ( ) ;
var a = await vm . OpenFixtureAsync ( "FixtureA" ) ;
var b = await vm . OpenFixtureAsync ( "FixtureB" ) ;
var settings = AppComposition . Current . GetExport < SettingsService > ( ) ;
// Solution mode is the tall case: it carries the solution-name field on top of everything
// project mode shows.
foreach ( var ( mode , assemblies ) in new [ ] {
( "solution" , new [ ] { a , b } ) ,
( "project" , new [ ] { a } ) ,
} )
{
var dialog = new ExportProjectDialog ( settings , assemblies , solutionMode : mode = = "solution" ) ;
dialog . Show ( ) ;
Dispatcher . UIThread . RunJobs ( DispatcherPriority . Loaded ) ;
dialog . OptionsScroll . Extent . Height . Should ( ) . BeLessThanOrEqualTo ( dialog . OptionsScroll . Viewport . Height + 0.5 ,
$"every option must be reachable without scrolling in {mode} mode "
+ $"(content {dialog.OptionsScroll.Extent.Height}, viewport {dialog.OptionsScroll.Viewport.Height})" ) ;
dialog . Close ( ) ;
}
}
[TestCase("MySolution", "MySolution.sln", TestName = "A typed name gains the .sln extension")]
[TestCase("MySolution.sln", "MySolution.sln", TestName = "An already-qualified name is not doubled up")]
[TestCase(" Spaced ", "Spaced.sln", TestName = "Surrounding whitespace is trimmed")]
[TestCase("", null, TestName = "A blank name defers to the folder-derived default")]
[TestCase(" ", null, TestName = "A whitespace-only name defers to the folder-derived default")]
[TestCase(null, null, TestName = "An unset name defers to the folder-derived default")]
// Stripping the extension the user typed leaves nothing behind, which CleanUpFileName would turn
// into "-", exporting "-.sln" for what reads as a request for the default.
[TestCase(".sln", null, TestName = "A bare extension defers to the folder-derived default")]
[TestCase(" .SLN ", null, TestName = "A padded bare extension defers to the folder-derived default")]
public void Typed_Solution_Names_Are_Normalized ( string? typed , string? expected )
{
ExportProjectDialog . NormalizeSolutionFileName ( typed ) . Should ( ) . Be ( expected ) ;
}
[Test]
public void A_Typed_Solution_Name_Cannot_Escape_The_Output_Folder ( )
{
var normalized = ExportProjectDialog . NormalizeSolutionFileName ( "../../etc/passwd" ) ;
normalized . Should ( ) . NotBeNull ( ) ;
normalized . Should ( ) . EndWith ( ".sln" ) ;
Path . GetFileName ( normalized ) . Should ( ) . Be ( normalized ,
"the name is combined with the chosen output directory, so it must stay a bare file name" ) ;
}
}