unit test_Import;

{$mode objfpc}{$H+}

/// <summary>
/// test_Import — FPCUnit tests for uHCLImport.
///
/// Exercises the public helpers (HCLArtifactTypeToCategory,
/// HCLCategoryAcceptsImports, HCLSanitizeDirName, HCLDetectDLLTarget,
/// HCLResolveWrapperPrefix), the pure destination resolution performed by
/// IHCLImport.PlanImport, and end-to-end placement of real ZIP archives by
/// IHCLImport.Execute.
///
/// The prefix and DLL-target helpers are tested against literal entry arrays
/// built with MakeEntry/MakeEntries, so each rule can be pinned down without
/// an archive; Execute tests use real ZIPs.
///
/// Each test gets a private temp directory created in SetUp and recursively
/// deleted in TearDown, so no test state leaks between cases.  ZIP fixtures
/// are built at run time with FPC's TZipper rather than checked in as binary
/// blobs, which keeps the fixtures readable and the repository text-only.
/// </summary>

interface

uses
  Classes, SysUtils, FileUtil, zipper,
  fpcunit, testregistry,
  uHCLArchive, uHCLCatalog, uHCLFileOrganization, uHCLImport;

type
  /// <summary>
  /// FPCUnit test suite for <see cref="IHCLImport"/>, its factory function
  /// <see cref="CreateImport"/> and the unit-level helpers in
  /// <c>uHCLImport</c>.
  /// </summary>
  TTestImport = class(TTestCase)
  private
    FTempDir: String;
    FOrg: IHCLFileOrganization;
    FArchive: IHCLArchive;
    FImport: IHCLImport;

    /// <summary>
    /// Builds a minimal <see cref="THCLCatalogFile"/> for a test.
    /// </summary>
    /// <param name="ATitle">Free-text title, used as the fallback directory name.</param>
    /// <param name="AArtifactType">Catalog artifact type tag, e.g. <c>BattleSet</c>.</param>
    /// <param name="ABattleSet">Optional declared BattleSet dependency.</param>
    /// <param name="APlatformDB">Optional declared PlatformDB dependency.</param>
    /// <returns>A populated catalog record; all other fields are default.</returns>
    function MakeFile(const ATitle, AArtifactType: String;
      const ABattleSet: String = '';
      const APlatformDB: String = ''): THCLCatalogFile;

    /// <summary>
    /// Writes a small text file, creating any intermediate directories.
    /// </summary>
    /// <param name="APath">Full path of the file to write.</param>
    /// <param name="AContent">Text to write; may be empty.</param>
    procedure WriteTextFile(const APath, AContent: String);

    /// <summary>
    /// Reads a small text file written by <see cref="WriteTextFile"/>.
    /// </summary>
    /// <param name="APath">Full path of the file to read.</param>
    /// <returns>The file's contents, or an empty string if it is missing.</returns>
    function ReadTextFile(const APath: String): String;

    /// <summary>
    /// Builds a real ZIP fixture containing one small text file per entry.
    /// </summary>
    /// <param name="AZipPath">Full path of the <c>.zip</c> to create.</param>
    /// <param name="AEntries">Archive-relative entry names; a name may contain
    /// <c>/</c> to place the entry inside a directory.</param>
    procedure MakeZip(const AZipPath: String; const AEntries: array of String);

    /// <summary>
    /// Returns a path inside the test's private temp directory.
    /// </summary>
    /// <param name="ALeaf">Path fragment relative to the temp directory.</param>
    /// <returns>The joined absolute path.</returns>
    function TempPath(const ALeaf: String): String;

    /// <summary>
    /// Builds a <see cref="THCLArchiveEntry"/> literal, bypassing any real
    /// archive.
    /// </summary>
    /// <param name="AName">Entry name, already normalised to <c>PathDelim</c>.</param>
    /// <param name="AIsDir">True for a directory entry.</param>
    /// <param name="AIsSafe">Value of the path-safety flag.</param>
    /// <returns>The populated entry.</returns>
    function MakeEntry(const AName: String; AIsDir: Boolean;
      AIsSafe: Boolean = True): THCLArchiveEntry;

    /// <summary>
    /// Builds a safe entry array from names written with <c>/</c>, which is
    /// converted to <c>PathDelim</c>; a trailing <c>/</c> marks a directory.
    /// </summary>
    /// <param name="ANames">Archive-relative names.</param>
    /// <returns>One entry per name, all flagged safe.</returns>
    function MakeEntries(const ANames: array of String): THCLArchiveEntryArray;

  protected
    /// <summary>
    /// Creates a unique temp directory and the file organization, archive
    /// handler and importer under test.
    /// </summary>
    procedure SetUp; override;

    /// <summary>Releases the interfaces and recursively deletes the temp directory.</summary>
    procedure TearDown; override;

  published
    // --- HCLArtifactTypeToCategory ---------------------------------------

    /// <summary>'PlatformDB' maps to hclPlatformDBs.</summary>
    procedure TestArtifactType_PlatformDB;

    /// <summary>'BattleSet' maps to hclBattleSets.</summary>
    procedure TestArtifactType_BattleSet;

    /// <summary>'Scenario' maps to hclBattleSets (PlanImport redirects commondb scenarios).</summary>
    procedure TestArtifactType_Scenario;

    /// <summary>'ExportDLL' maps to hclExportDLLs.</summary>
    procedure TestArtifactType_ExportDLL;

    /// <summary>'Sound' maps to hclSounds.</summary>
    procedure TestArtifactType_Sound;

    /// <summary>'Graphic' maps to hclGraphics.</summary>
    procedure TestArtifactType_Graphic;

    /// <summary>'Map' maps to hclMaps.</summary>
    procedure TestArtifactType_Map;

    /// <summary>'Tool' maps to hclTools.</summary>
    procedure TestArtifactType_Tool;

    /// <summary>Tag comparison is case-insensitive and tolerates surrounding whitespace.</summary>
    procedure TestArtifactType_CaseInsensitive;

    /// <summary>An unrecognised tag returns False.</summary>
    procedure TestArtifactType_Unknown;

    /// <summary>An empty tag returns False.</summary>
    procedure TestArtifactType_Empty;

    // --- HCLSanitizeDirName ------------------------------------------------

    /// <summary>An already-legal name is returned unchanged.</summary>
    procedure TestSanitize_PlainNameUnchanged;

    /// <summary>Each character Windows forbids in a path component becomes an underscore.</summary>
    procedure TestSanitize_ReplacesInvalidChars;

    /// <summary>Runs of underscores produced by adjacent invalid characters collapse to one.</summary>
    procedure TestSanitize_CollapsesUnderscoreRuns;

    /// <summary>Trailing dots and spaces, which Windows silently drops, are removed.</summary>
    procedure TestSanitize_TrimsTrailingDotsAndSpaces;

    /// <summary>Dots inside the name are left alone.</summary>
    procedure TestSanitize_KeepsInternalDots;

    /// <summary>A long name is capped at HCL_MAX_DIRNAME_LEN characters.</summary>
    procedure TestSanitize_CapsLength;

    /// <summary>An empty name yields HCL_UNNAMED_ARTIFACT.</summary>
    procedure TestSanitize_EmptyReturnsUnnamed;

    /// <summary>A whitespace-only name yields HCL_UNNAMED_ARTIFACT.</summary>
    procedure TestSanitize_WhitespaceReturnsUnnamed;

    /// <summary>A name consisting solely of invalid characters yields HCL_UNNAMED_ARTIFACT.</summary>
    procedure TestSanitize_AllInvalidReturnsUnnamed;

    /// <summary>
    /// Reserved device names gain a trailing underscore, case-insensitively;
    /// a name that merely starts with one is untouched.
    /// </summary>
    procedure TestSanitize_ReservedDeviceNames;

    // --- HCLCategoryAcceptsImports -----------------------------------------

    /// <summary>hclLogs does not accept imports.</summary>
    procedure TestAcceptsImports_LogsRejected;

    /// <summary>Every category other than hclLogs accepts imports.</summary>
    procedure TestAcceptsImports_AllOtherCategories;

    /// <summary>
    /// Every catalog artifact tag maps to a category that accepts imports —
    /// no tag reaches hclLogs.
    /// </summary>
    procedure TestAcceptsImports_EveryArtifactTagImportable;

    // --- HCLDetectDLLTarget -----------------------------------------------

    /// <summary>A flat DLL at the archive root says nothing about its target.</summary>
    procedure TestDetectDLLTarget_FlatIsUnspecified;

    /// <summary>A DLL under GE\ is packaged for its target.</summary>
    procedure TestDetectDLLTarget_GEDirIsAsPackaged;

    /// <summary>A DLL under SE\ is packaged for its target.</summary>
    procedure TestDetectDLLTarget_SEDirIsAsPackaged;

    /// <summary>GE\ beneath a release wrapper is still detected.</summary>
    procedure TestDetectDLLTarget_WrappedGEIsAsPackaged;

    /// <summary>GE\ beneath a release wrapper and an ExportDLLs container is detected.</summary>
    procedure TestDetectDLLTarget_WrappedExportDLLsGEIsAsPackaged;

    /// <summary>A common DLL at the root beside a GE\ directory is packaged.</summary>
    procedure TestDetectDLLTarget_MixedRootAndGE;

    /// <summary>
    /// A root file named GE.dll, or a directory whose name merely starts with
    /// GE, is not a GE directory.
    /// </summary>
    procedure TestDetectDLLTarget_LookalikeNamesAreUnspecified;

    /// <summary>An unsafe entry cannot make an archive look packaged.</summary>
    procedure TestDetectDLLTarget_UnsafeEntryIgnored;

    // --- PlanImport --------------------------------------------------------

    /// <summary>A BattleSet uses its declared dependency name in preference to the title.</summary>
    procedure TestPlan_BattleSetDependencyWinsOverTitle;

    /// <summary>A BattleSet with no dependency falls back to the sanitised title.</summary>
    procedure TestPlan_BattleSetFallsBackToTitle;

    /// <summary>A PlatformDB resolves under PlatformDBs\&lt;version&gt;.</summary>
    procedure TestPlan_PlatformDBUsesDependency;

    /// <summary>
    /// Sound and Graphic are flat categories: ikCategoryRoot, no artifact
    /// name, wrapper stripping enabled.  ExportDLL is covered by the
    /// TestPlan_ExportDLL* tests.
    /// </summary>
    procedure TestPlan_FlatCategories;

    /// <summary>
    /// An ExportDLL with no target is blocked: not valid, RequiresDLLTarget,
    /// and a reason for the user.
    /// </summary>
    procedure TestPlan_ExportDLLUnspecifiedRequiresTarget;

    /// <summary>dtCommon resolves to the ExportDLLs root.</summary>
    procedure TestPlan_ExportDLLCommonAtRoot;

    /// <summary>dtAsPackaged resolves to the ExportDLLs root, where the archive's GE\/SE\ land.</summary>
    procedure TestPlan_ExportDLLAsPackagedAtRoot;

    /// <summary>dtGE resolves to ExportDLLs\GE.</summary>
    procedure TestPlan_ExportDLLGETarget;

    /// <summary>dtSE resolves to ExportDLLs\SE.</summary>
    procedure TestPlan_ExportDLLSETarget;

    /// <summary>A Map resolves to a per-artifact directory under Maps\.</summary>
    procedure TestPlan_MapNamedArtifact;

    /// <summary>A Tool resolves to a per-artifact directory under Tools\.</summary>
    procedure TestPlan_ToolNamedArtifact;

    /// <summary>A scenario declaring only a BattleSet lands under that BattleSet.</summary>
    procedure TestPlan_ScenarioUnderBattleSet;

    /// <summary>A scenario declaring a PlatformDB lands under PlatformDBs\ and is flagged commondb.</summary>
    procedure TestPlan_ScenarioUnderPlatformDB;

    /// <summary>With both dependencies declared, the PlatformDB wins.</summary>
    procedure TestPlan_ScenarioPlatformDBBeatsBattleSet;

    /// <summary>A scenario with no dependency and no explicit parent is blocked pending a choice.</summary>
    procedure TestPlan_ScenarioWithoutParentRequiresParent;

    /// <summary>An explicitly supplied BattleSet parent unblocks a dependency-less scenario.</summary>
    procedure TestPlan_ScenarioExplicitBattleSetParent;

    /// <summary>An explicitly supplied PlatformDB parent unblocks a dependency-less scenario.</summary>
    procedure TestPlan_ScenarioExplicitPlatformDBParent;

    /// <summary>An unrecognised artifact type produces an invalid plan with a reason.</summary>
    procedure TestPlan_UnknownArtifactType;

    /// <summary>A title carrying invalid path characters produces a sanitised destination.</summary>
    procedure TestPlan_TitleWithInvalidCharsSanitized;

    // --- Earlier versions ----------------------------------------------------

    /// <summary>HCLVersionedName: current keeps the base; earlier gets its own name.</summary>
    procedure TestVersionedName;
    /// <summary>An earlier PlatformDB version installs beside the current one, named by its label.</summary>
    procedure TestPlan_PreviousPlatformDBVersion;
    /// <summary>An earlier version with a numeric label keeps the base name, suffixed.</summary>
    procedure TestPlan_PreviousToolVersionNumericLabel;
    /// <summary>An earlier scenario version gets its own scenario directory.</summary>
    procedure TestPlan_PreviousScenarioVersion;

    // --- Platform database collections and companion files -------------------

    /// <summary>The hc-timelines-db collection's real layout yields its four databases, named by their manuals.</summary>
    procedure TestFindBundledDBs_TimelinesCollection;
    /// <summary>A single database at the top level, or wrapped, is one bundle; without a dated file it takes the folder name.</summary>
    procedure TestFindBundledDBs_SingleAndNaming;
    /// <summary>Case-insensitive commondb names; rsr detected; unsafe and directory entries ignored.</summary>
    procedure TestFindBundledDBs_CaseRsrAndUnsafe;
    /// <summary>PlanBundledDB targets PlatformDBs\&lt;name&gt; and only its directory.</summary>
    procedure TestPlanBundledDB;
    /// <summary>Executing each bundle's plan extracts only that database's files.</summary>
    procedure TestExecute_CollectionSplitsIntoDatabases;
    /// <summary>HCLPlatformDBNeedsRsr: .res without .rsr or rsr\ needs pictures.</summary>
    procedure TestPlatformDBNeedsRsr;
    /// <summary>HCLPlaceCompanionFile copies commondb.rsr into every directory lacking it, once staged.</summary>
    procedure TestPlaceCompanionFile;
    /// <summary>A companion archive without the file reports it and places nothing.</summary>
    procedure TestPlaceCompanionFile_Missing;

    // --- Scenario databases and download categories ------------------------

    /// <summary>The database name at byte 4 of a versioned scenario, as in PINGER.SCm.</summary>
    procedure TestScenarioDBFlavor;
    /// <summary>Version-0 scenarios, garbage and short files give ''.</summary>
    procedure TestScenarioDBFlavorRefusals;
    /// <summary>.SC plus one character is a scenario file; its .xml sidecar is not.</summary>
    procedure TestIsScenarioFileName;
    /// <summary>The archive's scenario files name one database; disagreement or silence gives ''.</summary>
    procedure TestScenarioDatabase;
    /// <summary>A scenario declaring a platform database caches under PlatformDBs; others as their type.</summary>
    procedure TestDownloadCategory;
    /// <summary>An installed scenario is found under PlatformDBs (with its database folder) or BattleSets.</summary>
    procedure TestFindInstalledScenario;

    // --- HCLResolveWrapperPrefix -------------------------------------------

    /// <summary>A BattleSet's release-named wrapper is stripped.</summary>
    procedure TestPrefix_BattleSetReleaseWrapper;

    /// <summary>A flat BattleSet archive needs no stripping.</summary>
    procedure TestPrefix_BattleSetFlat;

    /// <summary>BattleSets\GIUK\ repeats the destination and is stripped entirely.</summary>
    procedure TestPrefix_BattleSetContainers;

    /// <summary>Scenarios\ is structural for a BattleSet and is kept.</summary>
    procedure TestPrefix_BattleSetScenariosIsStructural;

    /// <summary>res\ is structural for a PlatformDB and is kept.</summary>
    procedure TestPrefix_PlatformDBResIsStructural;

    /// <summary>A PlatformDB's version wrapper is stripped, leaving res\ and rsr\.</summary>
    procedure TestPrefix_PlatformDBNamedWrapper;

    /// <summary>A Sound release wrapper is stripped.</summary>
    procedure TestPrefix_SoundReleaseWrapper;

    /// <summary>A real subdirectory below a release wrapper is not eaten.</summary>
    procedure TestPrefix_SoundSubdirectoryNotEaten;

    /// <summary>The category name below a release wrapper is a container and is stripped.</summary>
    procedure TestPrefix_SoundCategoryNameIsContainer;

    /// <summary>
    /// dtAsPackaged keeps GE\ and strips a wrapper plus ExportDLLs\ above it.
    /// </summary>
    procedure TestPrefix_ExportDLLAsPackaged;

    /// <summary>
    /// With dtGE the archive's own GE\ is a container, so the DLL does not
    /// land in GE\GE\.
    /// </summary>
    procedure TestPrefix_ExportDLLGETargetStripsGE;

    /// <summary>With dtGE, SE\ is structural and is kept.</summary>
    procedure TestPrefix_ExportDLLGETargetKeepsSE;

    /// <summary>Directory entries do not stop a wrapper being recognised.</summary>
    procedure TestPrefix_DirectoryEntriesIgnored;

    /// <summary>A plan with StripWrapper False never strips anything.</summary>
    procedure TestPrefix_StripWrapperFalse;

    // --- Execute -----------------------------------------------------------

    /// <summary>A BattleSet archive is extracted into BattleSets\&lt;name&gt;.</summary>
    procedure TestExecute_BattleSetZip;

    /// <summary>A wrapped archive is stripped so its payload sits at the artifact level.</summary>
    procedure TestExecute_WrapperZipIsStripped;

    /// <summary>Execute on an invalid plan fails with the plan's reason and creates nothing.</summary>
    procedure TestExecute_InvalidPlan;

    /// <summary>Execute with a missing archive fails cleanly without raising.</summary>
    procedure TestExecute_MissingArchive;

    /// <summary>A scenario import scaffolds SavedGames and AutoSaves up front.</summary>
    procedure TestExecute_ScenarioCreatesSavedGamesTree;

    /// <summary>A Sound archive is extracted flat into the Sounds category directory.</summary>
    procedure TestExecute_SoundZipImportsFlat;

    /// <summary>
    /// A Sound archive that wraps its payload in a release folder is still
    /// stripped into the category root, where the GE can find it.
    /// </summary>
    procedure TestExecute_WrappedSoundZipIsStripped;

    /// <summary>
    /// An ExportDLL archive that packages its DLL under <c>GE/</c> keeps it
    /// in <c>ExportDLLs\GE\</c>, and earlier GE-only DLLs stay where they were.
    /// </summary>
    procedure TestExecute_ExportDLLGEStructurePreserved;

    /// <summary>A flat ExportDLL imported with dtGE lands in ExportDLLs\GE\ only.</summary>
    procedure TestExecute_FlatExportDLLWithGETarget;

    /// <summary>
    /// Pack/ExportDLLs/GE/New.dll merges into an existing ExportDLLs\GE\
    /// without disturbing the DLL already there.
    /// </summary>
    procedure TestExecute_ExportDLLMergesIntoExistingGEDir;

    /// <summary>
    /// A wrapped Sound archive is stripped without sweeping up a pre-existing
    /// subdirectory of the same name as the wrapper.
    /// </summary>
    procedure TestExecute_WrappedSoundLeavesExistingSubdirAlone;

    /// <summary>An ExportDLL plan with no target is refused and creates nothing.</summary>
    procedure TestExecute_ExportDLLUnspecifiedTargetRefused;
  end;

implementation

{ --------------------------------------------------------------------------- }
{ Helpers                                                                      }
{ --------------------------------------------------------------------------- }

function TTestImport.MakeFile(const ATitle, AArtifactType: String;
  const ABattleSet: String = '';
  const APlatformDB: String = ''): THCLCatalogFile;
begin
  Result := Default(THCLCatalogFile);
  Result.Title := ATitle;
  Result.ArtifactType := AArtifactType;
  Result.Dependencies.BattleSet := ABattleSet;
  Result.Dependencies.PlatformDB := APlatformDB;
end;

procedure TTestImport.WriteTextFile(const APath, AContent: String);
var
  FS: TFileStream;
  Dir: String;
begin
  Dir := ExtractFileDir(APath);
  if (Dir <> '') and not DirectoryExists(Dir) then
    ForceDirectories(Dir);

  FS := TFileStream.Create(APath, fmCreate);
  try
    if AContent <> '' then
      FS.Write(AContent[1], Length(AContent));
  finally
    FS.Free;
  end;
end;

function TTestImport.ReadTextFile(const APath: String): String;
var
  FS: TFileStream;
begin
  Result := '';
  if not FileExists(APath) then
    Exit;

  FS := TFileStream.Create(APath, fmOpenRead or fmShareDenyNone);
  try
    SetLength(Result, FS.Size);
    if FS.Size > 0 then
      FS.Read(Result[1], FS.Size);
  finally
    FS.Free;
  end;
end;

procedure TTestImport.MakeZip(const AZipPath: String; const AEntries: array of String);
var
  Zipper: TZipper;
  StageDir, DiskName, Leaf: String;
  I: Integer;
begin
  // Content files are staged flat under a scratch directory; the directory
  // structure the test cares about lives in the ARCHIVE name, not on disk.
  StageDir := TempPath('zipstage');
  ForceDirectories(StageDir);

  Zipper := TZipper.Create;
  try
    for I := Low(AEntries) to High(AEntries) do
    begin
      Leaf := StringReplace(AEntries[I], '/', '_', [rfReplaceAll]);
      DiskName := StageDir + PathDelim + Leaf;
      WriteTextFile(DiskName, 'payload for ' + AEntries[I]);
      Zipper.Entries.AddFileEntry(DiskName, AEntries[I]);
    end;

    Zipper.FileName := AZipPath;
    Zipper.ZipAllFiles;
  finally
    Zipper.Free;
  end;
end;

function TTestImport.TempPath(const ALeaf: String): String;
begin
  Result := FTempDir + PathDelim + ALeaf;
end;

function TTestImport.MakeEntry(const AName: String; AIsDir: Boolean;
  AIsSafe: Boolean = True): THCLArchiveEntry;
begin
  Result := Default(THCLArchiveEntry);
  Result.Name        := AName;
  Result.RawName     := StringReplace(AName, PathDelim, '/', [rfReplaceAll]);
  Result.IsDirectory := AIsDir;
  Result.IsSafe      := AIsSafe;
end;

function TTestImport.MakeEntries(const ANames: array of String): THCLArchiveEntryArray;
var
  I: Integer;
  LName: String;
begin
  Result := nil;
  SetLength(Result, Length(ANames));
  for I := 0 to High(ANames) do
  begin
    LName := StringReplace(ANames[I], '/', PathDelim, [rfReplaceAll]);
    Result[I] := MakeEntry(LName,
      (LName <> '') and (LName[Length(LName)] = PathDelim));
  end;
end;

{ --------------------------------------------------------------------------- }
{ SetUp / TearDown                                                             }
{ --------------------------------------------------------------------------- }

procedure TTestImport.SetUp;
begin
  FTempDir := GetTempDir + 'HCLImportTest_' + IntToStr(GetTickCount64);
  ForceDirectories(FTempDir);

  FOrg := CreateFileOrganization(FTempDir);
  FArchive := CreateArchive;
  FImport := CreateImport(FOrg, FArchive);
end;

procedure TTestImport.TearDown;
begin
  FImport := nil;
  FArchive := nil;
  FOrg := nil;

  if DirectoryExists(FTempDir) then
    DeleteDirectory(FTempDir, False);
end;

{ --------------------------------------------------------------------------- }
{ HCLArtifactTypeToCategory                                                    }
{ --------------------------------------------------------------------------- }

procedure TTestImport.TestArtifactType_PlatformDB;
var
  Cat: THCLCategory;
begin
  AssertTrue('PlatformDB recognised',
    HCLArtifactTypeToCategory(HCL_ARTIFACT_PLATFORMDB, Cat));
  AssertEquals('PlatformDB category', Ord(hclPlatformDBs), Ord(Cat));
end;

procedure TTestImport.TestArtifactType_BattleSet;
var
  Cat: THCLCategory;
begin
  AssertTrue('BattleSet recognised',
    HCLArtifactTypeToCategory(HCL_ARTIFACT_BATTLESET, Cat));
  AssertEquals('BattleSet category', Ord(hclBattleSets), Ord(Cat));
end;

procedure TTestImport.TestArtifactType_Scenario;
var
  Cat: THCLCategory;
begin
  AssertTrue('Scenario recognised',
    HCLArtifactTypeToCategory(HCL_ARTIFACT_SCENARIO, Cat));
  // Scenarios default to the BattleSets branch; PlanImport redirects the
  // commondb ones because only it sees the dependency metadata.
  AssertEquals('Scenario category', Ord(hclBattleSets), Ord(Cat));
end;

procedure TTestImport.TestArtifactType_ExportDLL;
var
  Cat: THCLCategory;
begin
  AssertTrue('ExportDLL recognised',
    HCLArtifactTypeToCategory(HCL_ARTIFACT_EXPORTDLL, Cat));
  AssertEquals('ExportDLL category', Ord(hclExportDLLs), Ord(Cat));
end;

procedure TTestImport.TestArtifactType_Sound;
var
  Cat: THCLCategory;
begin
  AssertTrue('Sound recognised',
    HCLArtifactTypeToCategory(HCL_ARTIFACT_SOUND, Cat));
  AssertTrue('Sound category', Cat = hclSounds);
end;

procedure TTestImport.TestArtifactType_Graphic;
var
  Cat: THCLCategory;
begin
  AssertTrue('Graphic recognised',
    HCLArtifactTypeToCategory(HCL_ARTIFACT_GRAPHIC, Cat));
  AssertEquals('Graphic category', Ord(hclGraphics), Ord(Cat));
end;

procedure TTestImport.TestArtifactType_Map;
var
  Cat: THCLCategory;
begin
  AssertTrue('Map recognised',
    HCLArtifactTypeToCategory(HCL_ARTIFACT_MAP, Cat));
  AssertEquals('Map category', Ord(hclMaps), Ord(Cat));
end;

procedure TTestImport.TestArtifactType_Tool;
var
  Cat: THCLCategory;
begin
  AssertTrue('Tool recognised',
    HCLArtifactTypeToCategory(HCL_ARTIFACT_TOOL, Cat));
  AssertEquals('Tool category', Ord(hclTools), Ord(Cat));
end;

procedure TTestImport.TestArtifactType_CaseInsensitive;
var
  Cat: THCLCategory;
begin
  AssertTrue('lowercase battleset recognised',
    HCLArtifactTypeToCategory('battleset', Cat));
  AssertEquals('lowercase battleset category', Ord(hclBattleSets), Ord(Cat));

  AssertTrue('UPPERCASE SOUND recognised',
    HCLArtifactTypeToCategory('SOUND', Cat));
  AssertEquals('UPPERCASE SOUND category', Ord(hclSounds), Ord(Cat));

  AssertTrue('MiXeD ScEnArIo recognised',
    HCLArtifactTypeToCategory('ScEnArIo', Cat));
  AssertEquals('MiXeD ScEnArIo category', Ord(hclBattleSets), Ord(Cat));

  // Surrounding whitespace is trimmed before comparison.
  AssertTrue('padded tag recognised',
    HCLArtifactTypeToCategory('  Map  ', Cat));
  AssertEquals('padded tag category', Ord(hclMaps), Ord(Cat));
end;

procedure TTestImport.TestArtifactType_Unknown;
var
  Cat: THCLCategory;
begin
  AssertFalse('Unknown tag rejected',
    HCLArtifactTypeToCategory('Widget', Cat));
end;

procedure TTestImport.TestArtifactType_Empty;
var
  Cat: THCLCategory;
begin
  AssertFalse('Empty tag rejected',
    HCLArtifactTypeToCategory('', Cat));
  AssertFalse('Whitespace-only tag rejected',
    HCLArtifactTypeToCategory('   ', Cat));
end;

{ --------------------------------------------------------------------------- }
{ HCLSanitizeDirName                                                           }
{ --------------------------------------------------------------------------- }

procedure TTestImport.TestSanitize_PlainNameUnchanged;
begin
  AssertEquals('Plain name untouched', 'GIUK', HCLSanitizeDirName('GIUK'));
  AssertEquals('Name with spaces untouched',
    'Med Crisis', HCLSanitizeDirName('Med Crisis'));
  AssertEquals('Name with hyphen untouched',
    'HCDB-160414', HCLSanitizeDirName('HCDB-160414'));
end;

procedure TTestImport.TestSanitize_ReplacesInvalidChars;
begin
  AssertEquals('Forward slash replaced',  'A_B', HCLSanitizeDirName('A/B'));
  AssertEquals('Backslash replaced',      'A_B', HCLSanitizeDirName('A\B'));
  AssertEquals('Colon replaced',          'A_B', HCLSanitizeDirName('A:B'));
  AssertEquals('Pipe replaced',           'A_B', HCLSanitizeDirName('A|B'));
  AssertEquals('Question mark replaced',  'A_B', HCLSanitizeDirName('A?B'));
  AssertEquals('Asterisk replaced',       'A_B', HCLSanitizeDirName('A*B'));
  AssertEquals('Quote replaced',          'A_B', HCLSanitizeDirName('A"B'));
  AssertEquals('Angle brackets replaced', 'A_B_C', HCLSanitizeDirName('A<B>C'));
end;

procedure TTestImport.TestSanitize_CollapsesUnderscoreRuns;
begin
  AssertEquals('Adjacent invalid chars collapse',
    'A_B', HCLSanitizeDirName('A<>B'));
  AssertEquals('Long run collapses',
    'A_B', HCLSanitizeDirName('A***B'));
  AssertEquals('Existing underscore run collapses',
    'A_B', HCLSanitizeDirName('A___B'));
end;

procedure TTestImport.TestSanitize_TrimsTrailingDotsAndSpaces;
begin
  AssertEquals('Trailing dots stripped',
    'GIUK', HCLSanitizeDirName('GIUK...'));
  AssertEquals('Trailing spaces stripped',
    'GIUK', HCLSanitizeDirName('GIUK   '));
  AssertEquals('Mixed trailing dots and spaces stripped',
    'GIUK', HCLSanitizeDirName('GIUK . . '));
  AssertEquals('Leading whitespace stripped',
    'GIUK', HCLSanitizeDirName('   GIUK'));
end;

procedure TTestImport.TestSanitize_KeepsInternalDots;
begin
  AssertEquals('Internal dots preserved',
    'HCDB-160414.v2', HCLSanitizeDirName('HCDB-160414.v2'));
  AssertEquals('Version-style dots preserved',
    'GIUK 1.2.3', HCLSanitizeDirName('GIUK 1.2.3'));
end;

procedure TTestImport.TestSanitize_CapsLength;
var
  LongName, Sanitized: String;
begin
  LongName := StringOfChar('A', 200);
  Sanitized := HCLSanitizeDirName(LongName);

  AssertEquals('Length capped at HCL_MAX_DIRNAME_LEN',
    HCL_MAX_DIRNAME_LEN, Length(Sanitized));
  AssertEquals('Capped name keeps its leading characters',
    StringOfChar('A', HCL_MAX_DIRNAME_LEN), Sanitized);

  // A name exactly at the limit is untouched.
  AssertEquals('Name at the limit is unchanged',
    HCL_MAX_DIRNAME_LEN,
    Length(HCLSanitizeDirName(StringOfChar('B', HCL_MAX_DIRNAME_LEN))));
end;

procedure TTestImport.TestSanitize_EmptyReturnsUnnamed;
begin
  AssertEquals('Empty name becomes Unnamed',
    HCL_UNNAMED_ARTIFACT, HCLSanitizeDirName(''));
end;

procedure TTestImport.TestSanitize_WhitespaceReturnsUnnamed;
begin
  AssertEquals('Spaces-only name becomes Unnamed',
    HCL_UNNAMED_ARTIFACT, HCLSanitizeDirName('     '));
  AssertEquals('Dots-only name becomes Unnamed',
    HCL_UNNAMED_ARTIFACT, HCLSanitizeDirName('...'));
end;

procedure TTestImport.TestSanitize_AllInvalidReturnsUnnamed;
begin
  AssertEquals('All-invalid name becomes Unnamed',
    HCL_UNNAMED_ARTIFACT, HCLSanitizeDirName('???'));
  AssertEquals('Mixed invalid characters become Unnamed',
    HCL_UNNAMED_ARTIFACT, HCLSanitizeDirName('<>:"|?*'));
  AssertEquals('Underscores alone carry no information',
    HCL_UNNAMED_ARTIFACT, HCLSanitizeDirName('___'));
end;

procedure TTestImport.TestSanitize_ReservedDeviceNames;
begin
  AssertEquals('CON renamed',  'CON_',  HCLSanitizeDirName('CON'));
  AssertEquals('LPT1 renamed', 'LPT1_', HCLSanitizeDirName('LPT1'));
  AssertEquals('NUL renamed',  'NUL_',  HCLSanitizeDirName('NUL'));
  AssertEquals('COM9 renamed', 'COM9_', HCLSanitizeDirName('COM9'));

  // The reserved-name check is case-insensitive, and preserves the casing
  // the caller supplied.
  AssertEquals('lowercase con renamed', 'con_', HCLSanitizeDirName('con'));
  AssertEquals('MiXeD Aux renamed',     'Aux_', HCLSanitizeDirName('Aux'));

  // A name that merely begins with a reserved word is fine.
  AssertEquals('CONS is not reserved', 'CONS', HCLSanitizeDirName('CONS'));
  AssertEquals('CON2 is not reserved', 'CON2', HCLSanitizeDirName('CON2'));
end;

{ --------------------------------------------------------------------------- }
{ HCLCategoryAcceptsImports                                                    }
{ --------------------------------------------------------------------------- }

procedure TTestImport.TestAcceptsImports_LogsRejected;
begin
  AssertFalse('Logs is not an import destination',
    HCLCategoryAcceptsImports(hclLogs));
end;

procedure TTestImport.TestAcceptsImports_AllOtherCategories;
var
  Cat: THCLCategory;
begin
  for Cat := Low(THCLCategory) to High(THCLCategory) do
    if Cat <> hclLogs then
      AssertTrue(HCL_CATEGORY_NAMES[Cat] + ' accepts imports',
        HCLCategoryAcceptsImports(Cat));
end;

procedure TTestImport.TestAcceptsImports_EveryArtifactTagImportable;
const
  TAGS: array[0..7] of String = (
    HCL_ARTIFACT_PLATFORMDB, HCL_ARTIFACT_SCENARIO, HCL_ARTIFACT_BATTLESET,
    HCL_ARTIFACT_EXPORTDLL, HCL_ARTIFACT_SOUND, HCL_ARTIFACT_GRAPHIC,
    HCL_ARTIFACT_MAP, HCL_ARTIFACT_TOOL);
var
  I: Integer;
  Cat: THCLCategory;
begin
  for I := Low(TAGS) to High(TAGS) do
  begin
    AssertTrue(TAGS[I] + ' recognised', HCLArtifactTypeToCategory(TAGS[I], Cat));
    AssertTrue(TAGS[I] + ' maps to a category that accepts imports',
      HCLCategoryAcceptsImports(Cat));
  end;
end;

{ --------------------------------------------------------------------------- }
{ HCLDetectDLLTarget                                                           }
{ --------------------------------------------------------------------------- }

procedure TTestImport.TestDetectDLLTarget_FlatIsUnspecified;
begin
  AssertTrue('Flat DLL is unspecified',
    HCLDetectDLLTarget(MakeEntries(['MapWindow.dll'])) = dtUnspecified);
end;

procedure TTestImport.TestDetectDLLTarget_GEDirIsAsPackaged;
begin
  AssertTrue('GE\MapWindow.dll is packaged',
    HCLDetectDLLTarget(MakeEntries(['GE/MapWindow.dll'])) = dtAsPackaged);
end;

procedure TTestImport.TestDetectDLLTarget_SEDirIsAsPackaged;
begin
  AssertTrue('SE\x.dll is packaged',
    HCLDetectDLLTarget(MakeEntries(['SE/x.dll'])) = dtAsPackaged);
end;

procedure TTestImport.TestDetectDLLTarget_WrappedGEIsAsPackaged;
begin
  AssertTrue('Pack\GE\x.dll is packaged',
    HCLDetectDLLTarget(MakeEntries(['Pack/GE/x.dll'])) = dtAsPackaged);
end;

procedure TTestImport.TestDetectDLLTarget_WrappedExportDLLsGEIsAsPackaged;
begin
  AssertTrue('Pack\ExportDLLs\GE\x.dll is packaged',
    HCLDetectDLLTarget(MakeEntries(['Pack/ExportDLLs/GE/x.dll'])) = dtAsPackaged);
end;

procedure TTestImport.TestDetectDLLTarget_MixedRootAndGE;
begin
  AssertTrue('Common.dll at root plus GE\x.dll is packaged',
    HCLDetectDLLTarget(MakeEntries(['Common.dll', 'GE/x.dll'])) = dtAsPackaged);
end;

procedure TTestImport.TestDetectDLLTarget_LookalikeNamesAreUnspecified;
begin
  AssertTrue('A root file named GE.dll is not a GE directory',
    HCLDetectDLLTarget(MakeEntries(['GE.dll'])) = dtUnspecified);
  AssertTrue('GEAssets\ is not a GE directory',
    HCLDetectDLLTarget(MakeEntries(['GEAssets/x.bmp'])) = dtUnspecified);
end;

procedure TTestImport.TestDetectDLLTarget_UnsafeEntryIgnored;
var
  Entries: THCLArchiveEntryArray;
begin
  Entries := nil;
  SetLength(Entries, 1);
  Entries[0] := MakeEntry('..' + PathDelim + 'GE' + PathDelim + 'x.dll',
    False, False);

  AssertTrue('An unsafe ..\GE\x.dll does not count as packaged',
    HCLDetectDLLTarget(Entries) = dtUnspecified);
end;

{ --------------------------------------------------------------------------- }
{ PlanImport                                                                   }
{ --------------------------------------------------------------------------- }

procedure TTestImport.TestPlan_BattleSetDependencyWinsOverTitle;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(
    MakeFile('GIUK Gap Remastered Pack', HCL_ARTIFACT_BATTLESET, 'GIUK'));

  AssertTrue('Plan is valid', Plan.Valid);
  AssertEquals('Kind', Ord(ikNamedArtifact), Ord(Plan.Kind));
  AssertEquals('Category', Ord(hclBattleSets), Ord(Plan.Category));
  AssertEquals('Dependency name wins over the free-text title',
    'GIUK', Plan.ArtifactName);
  AssertEquals('Destination', FOrg.GetBattleSetPath('GIUK'), Plan.DestDir);
  AssertTrue('BattleSets strip a wrapper directory', Plan.StripWrapper);
  AssertFalse('No parent needed', Plan.RequiresParent);
end;

procedure TTestImport.TestPlan_BattleSetFallsBackToTitle;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Nordic Storm', HCL_ARTIFACT_BATTLESET));

  AssertTrue('Plan is valid', Plan.Valid);
  AssertEquals('Kind', Ord(ikNamedArtifact), Ord(Plan.Kind));
  AssertEquals('Title used when no dependency is declared',
    'Nordic Storm', Plan.ArtifactName);
  AssertEquals('Destination',
    FOrg.GetBattleSetPath('Nordic Storm'), Plan.DestDir);
end;

procedure TTestImport.TestPlan_PlatformDBUsesDependency;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(
    MakeFile('Platform Database Release', HCL_ARTIFACT_PLATFORMDB,
      '', 'HCDB-160414'));

  AssertTrue('Plan is valid', Plan.Valid);
  AssertEquals('Kind', Ord(ikNamedArtifact), Ord(Plan.Kind));
  AssertEquals('Category', Ord(hclPlatformDBs), Ord(Plan.Category));
  AssertEquals('Artifact name', 'HCDB-160414', Plan.ArtifactName);
  AssertEquals('Destination',
    FOrg.GetPlatformDBPath('HCDB-160414'), Plan.DestDir);
  AssertTrue('PlatformDBs strip a wrapper directory', Plan.StripWrapper);
end;

procedure TTestImport.TestPlan_FlatCategories;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Explosion Pack', HCL_ARTIFACT_SOUND));
  AssertTrue('Sound plan is valid', Plan.Valid);
  AssertEquals('Sound kind', Ord(ikCategoryRoot), Ord(Plan.Kind));
  AssertTrue('Sound category', Plan.Category = hclSounds);
  AssertEquals('Sound has no per-artifact directory', '', Plan.ArtifactName);
  AssertEquals('Sound destination',
    FOrg.GetCategoryPath(hclSounds), Plan.DestDir);
  AssertTrue('Sound strips a wrapper directory', Plan.StripWrapper);

  Plan := FImport.PlanImport(MakeFile('Symbol Set', HCL_ARTIFACT_GRAPHIC));
  AssertTrue('Graphic plan is valid', Plan.Valid);
  AssertEquals('Graphic kind', Ord(ikCategoryRoot), Ord(Plan.Kind));
  AssertEquals('Graphic category', Ord(hclGraphics), Ord(Plan.Category));
  AssertEquals('Graphic has no per-artifact directory', '', Plan.ArtifactName);
  AssertEquals('Graphic destination',
    FOrg.GetCategoryPath(hclGraphics), Plan.DestDir);
  AssertTrue('Graphic strips a wrapper directory', Plan.StripWrapper);
end;

procedure TTestImport.TestPlan_ExportDLLUnspecifiedRequiresTarget;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Logger DLL', HCL_ARTIFACT_EXPORTDLL),
    '', False, dtUnspecified);

  AssertFalse('Plan is not valid', Plan.Valid);
  AssertTrue('Caller must choose a DLL target', Plan.RequiresDLLTarget);
  AssertFalse('Not a missing-parent problem', Plan.RequiresParent);
  AssertTrue('Reason explains what is missing', Plan.Reason <> '');
  AssertEquals('Category', Ord(hclExportDLLs), Ord(Plan.Category));
  AssertTrue('DLLTarget echoes the input', Plan.DLLTarget = dtUnspecified);

  // The default argument is the same as asking with dtUnspecified.
  Plan := FImport.PlanImport(MakeFile('Logger DLL', HCL_ARTIFACT_EXPORTDLL));
  AssertFalse('Default target: plan is not valid', Plan.Valid);
  AssertTrue('Default target: caller must choose', Plan.RequiresDLLTarget);
end;

procedure TTestImport.TestPlan_ExportDLLCommonAtRoot;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Logger DLL', HCL_ARTIFACT_EXPORTDLL),
    '', False, dtCommon);

  AssertTrue('Plan is valid', Plan.Valid);
  AssertFalse('No target prompt needed', Plan.RequiresDLLTarget);
  AssertEquals('Kind', Ord(ikCategoryRoot), Ord(Plan.Kind));
  AssertEquals('Category', Ord(hclExportDLLs), Ord(Plan.Category));
  AssertEquals('No per-artifact directory', '', Plan.ArtifactName);
  AssertEquals('Destination is the ExportDLLs root',
    FOrg.GetCategoryPath(hclExportDLLs), Plan.DestDir);
  AssertTrue('DLLTarget echoes the input', Plan.DLLTarget = dtCommon);
  AssertTrue('ExportDLLs strip a wrapper directory', Plan.StripWrapper);
end;

procedure TTestImport.TestPlan_ExportDLLAsPackagedAtRoot;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Logger DLL', HCL_ARTIFACT_EXPORTDLL),
    '', False, dtAsPackaged);

  AssertTrue('Plan is valid', Plan.Valid);
  AssertEquals('Kind', Ord(ikCategoryRoot), Ord(Plan.Kind));
  AssertEquals('Destination is the ExportDLLs root',
    FOrg.GetCategoryPath(hclExportDLLs), Plan.DestDir);
  AssertTrue('DLLTarget echoes the input', Plan.DLLTarget = dtAsPackaged);
  AssertTrue('ExportDLLs strip a wrapper directory', Plan.StripWrapper);
end;

procedure TTestImport.TestPlan_ExportDLLGETarget;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Logger DLL', HCL_ARTIFACT_EXPORTDLL),
    '', False, dtGE);

  AssertTrue('Plan is valid', Plan.Valid);
  AssertEquals('Kind', Ord(ikCategoryRoot), Ord(Plan.Kind));
  AssertEquals('Destination is ExportDLLs\GE',
    FOrg.GetCategoryPath(hclExportDLLs) + PathDelim + 'GE', Plan.DestDir);
  AssertTrue('DLLTarget echoes the input', Plan.DLLTarget = dtGE);
  AssertTrue('ExportDLLs strip a wrapper directory', Plan.StripWrapper);
end;

procedure TTestImport.TestPlan_ExportDLLSETarget;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Logger DLL', HCL_ARTIFACT_EXPORTDLL),
    '', False, dtSE);

  AssertTrue('Plan is valid', Plan.Valid);
  AssertEquals('Kind', Ord(ikCategoryRoot), Ord(Plan.Kind));
  AssertEquals('Destination is ExportDLLs\SE',
    FOrg.GetCategoryPath(hclExportDLLs) + PathDelim + 'SE', Plan.DestDir);
  AssertTrue('DLLTarget echoes the input', Plan.DLLTarget = dtSE);
  AssertTrue('ExportDLLs strip a wrapper directory', Plan.StripWrapper);
end;

procedure TTestImport.TestPlan_MapNamedArtifact;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('North Atlantic Tiles', HCL_ARTIFACT_MAP));

  AssertTrue('Plan is valid', Plan.Valid);
  AssertEquals('Kind', Ord(ikNamedArtifact), Ord(Plan.Kind));
  AssertEquals('Category', Ord(hclMaps), Ord(Plan.Category));
  AssertEquals('Artifact name', 'North Atlantic Tiles', Plan.ArtifactName);
  AssertEquals('Destination',
    FOrg.GetCategoryPath(hclMaps) + PathDelim + 'North Atlantic Tiles',
    Plan.DestDir);
  AssertTrue('Maps strip a wrapper directory', Plan.StripWrapper);
end;

procedure TTestImport.TestPlan_ToolNamedArtifact;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('BSBuilder', HCL_ARTIFACT_TOOL));

  AssertTrue('Plan is valid', Plan.Valid);
  AssertEquals('Kind', Ord(ikNamedArtifact), Ord(Plan.Kind));
  AssertEquals('Category', Ord(hclTools), Ord(Plan.Category));
  AssertEquals('Artifact name', 'BSBuilder', Plan.ArtifactName);
  AssertEquals('Destination',
    FOrg.GetCategoryPath(hclTools) + PathDelim + 'BSBuilder', Plan.DestDir);
  AssertTrue('Tools strip a wrapper directory', Plan.StripWrapper);
end;

procedure TTestImport.TestPlan_ScenarioUnderBattleSet;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(
    MakeFile('Day One Strike', HCL_ARTIFACT_SCENARIO, 'GIUK'));

  AssertTrue('Plan is valid', Plan.Valid);
  AssertEquals('Kind', Ord(ikScenario), Ord(Plan.Kind));
  AssertEquals('Category', Ord(hclBattleSets), Ord(Plan.Category));
  AssertFalse('Not a commondb scenario', Plan.IsCommonDB);
  AssertFalse('Parent came from the catalog', Plan.RequiresParent);
  AssertEquals('Parent name', 'GIUK', Plan.ParentName);
  AssertEquals('Artifact name', 'Day One Strike', Plan.ArtifactName);
  AssertEquals('Destination',
    FOrg.GetBattleSetScenarioPath('GIUK', 'Day One Strike'), Plan.DestDir);
  AssertTrue('Scenarios strip a wrapper directory', Plan.StripWrapper);
end;

procedure TTestImport.TestPlan_ScenarioUnderPlatformDB;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(
    MakeFile('Taiwan Strait', HCL_ARTIFACT_SCENARIO, '', 'HCDB2-170909'));

  AssertTrue('Plan is valid', Plan.Valid);
  AssertEquals('Kind', Ord(ikScenario), Ord(Plan.Kind));
  AssertTrue('Commondb scenario', Plan.IsCommonDB);
  AssertEquals('Category redirected to PlatformDBs',
    Ord(hclPlatformDBs), Ord(Plan.Category));
  AssertEquals('Parent name', 'HCDB2-170909', Plan.ParentName);
  AssertEquals('Destination',
    FOrg.GetPlatformDBScenarioPath('HCDB2-170909', 'Taiwan Strait'),
    Plan.DestDir);
end;

procedure TTestImport.TestPlan_ScenarioPlatformDBBeatsBattleSet;
var
  Plan: THCLImportPlan;
begin
  // A commondb scenario cannot live under a BattleSet, so the PlatformDB
  // dependency must win when both are declared.
  Plan := FImport.PlanImport(
    MakeFile('Both Deps', HCL_ARTIFACT_SCENARIO, 'GIUK', 'HCDB2-170909'));

  AssertTrue('Plan is valid', Plan.Valid);
  AssertTrue('PlatformDB wins', Plan.IsCommonDB);
  AssertEquals('Category', Ord(hclPlatformDBs), Ord(Plan.Category));
  AssertEquals('Parent name', 'HCDB2-170909', Plan.ParentName);
  AssertEquals('Destination',
    FOrg.GetPlatformDBScenarioPath('HCDB2-170909', 'Both Deps'), Plan.DestDir);
end;

procedure TTestImport.TestPlan_ScenarioWithoutParentRequiresParent;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Orphan Scenario', HCL_ARTIFACT_SCENARIO));

  AssertFalse('Plan is not valid', Plan.Valid);
  AssertTrue('Caller must nominate a parent', Plan.RequiresParent);
  AssertEquals('Kind', Ord(ikScenario), Ord(Plan.Kind));
  AssertEquals('No destination resolved', '', Plan.DestDir);
  AssertTrue('Reason explains what is missing', Plan.Reason <> '');
end;

procedure TTestImport.TestPlan_ScenarioExplicitBattleSetParent;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(
    MakeFile('Orphan Scenario', HCL_ARTIFACT_SCENARIO), 'NORDIC', False);

  AssertTrue('Plan is valid', Plan.Valid);
  AssertFalse('No further prompt needed', Plan.RequiresParent);
  AssertFalse('Not a commondb scenario', Plan.IsCommonDB);
  AssertEquals('Category', Ord(hclBattleSets), Ord(Plan.Category));
  AssertEquals('Parent name', 'NORDIC', Plan.ParentName);
  AssertEquals('Destination',
    FOrg.GetBattleSetScenarioPath('NORDIC', 'Orphan Scenario'), Plan.DestDir);
end;

procedure TTestImport.TestPlan_ScenarioExplicitPlatformDBParent;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(
    MakeFile('Orphan Scenario', HCL_ARTIFACT_SCENARIO), 'HCDB-160414', True);

  AssertTrue('Plan is valid', Plan.Valid);
  AssertTrue('Commondb scenario', Plan.IsCommonDB);
  AssertEquals('Category', Ord(hclPlatformDBs), Ord(Plan.Category));
  AssertEquals('Parent name', 'HCDB-160414', Plan.ParentName);
  AssertEquals('Destination',
    FOrg.GetPlatformDBScenarioPath('HCDB-160414', 'Orphan Scenario'),
    Plan.DestDir);
end;

procedure TTestImport.TestPlan_UnknownArtifactType;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Mystery Upload', 'Widget'));

  AssertFalse('Plan is not valid', Plan.Valid);
  AssertEquals('Kind', Ord(ikUnknown), Ord(Plan.Kind));
  AssertEquals('No destination resolved', '', Plan.DestDir);
  AssertFalse('An unknown type is not a missing-parent problem',
    Plan.RequiresParent);
  AssertTrue('Reason is populated', Plan.Reason <> '');

  // Logs are game-generated; no catalog tag maps to them, so the only way to
  // reach that category is through the unrecognised-type path.
  Plan := FImport.PlanImport(MakeFile('Engine Log', 'Log'));
  AssertFalse('Log is not an importable artifact type', Plan.Valid);
  AssertEquals('Kind', Ord(ikUnknown), Ord(Plan.Kind));
  AssertTrue('Reason is populated', Plan.Reason <> '');
end;

procedure TTestImport.TestPlan_TitleWithInvalidCharsSanitized;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(
    MakeFile('GIUK: North*Atlantic', HCL_ARTIFACT_BATTLESET));

  AssertTrue('Plan is valid', Plan.Valid);
  AssertEquals('Artifact name is sanitised',
    HCLSanitizeDirName('GIUK: North*Atlantic'), Plan.ArtifactName);
  AssertEquals('Destination uses the sanitised name',
    FOrg.GetBattleSetPath(HCLSanitizeDirName('GIUK: North*Atlantic')),
    Plan.DestDir);

  AssertEquals('No colon survives in the directory name',
    0, Pos(':', Plan.ArtifactName));
  AssertEquals('No asterisk survives in the directory name',
    0, Pos('*', Plan.ArtifactName));
end;

{ --------------------------------------------------------------------------- }
{ Earlier versions                                                              }
{ --------------------------------------------------------------------------- }

/// <summary>A copy of AFile standing for an earlier version, as HCLSelectVersion makes one.</summary>
function AsPreviousVersion(const AFile: THCLCatalogFile;
  const ALabel, AFileName: String): THCLCatalogFile;
begin
  Result := AFile;
  Result.VersionLabel := ALabel;
  Result.FileName := AFileName;
  Result.IsPreviousVersion := True;
end;

procedure TTestImport.TestVersionedName;
var
  F: THCLCatalogFile;
begin
  F := MakeFile('Toolbox', HCL_ARTIFACT_TOOL);
  F.VersionLabel := 'ToolBoxV';
  AssertEquals('current version: base unchanged', 'Toolbox', HCLVersionedName('Toolbox', F));

  F.IsPreviousVersion := True;
  AssertEquals('a label with letters stands alone', 'ToolBoxV', HCLVersionedName('Toolbox', F));
  F.VersionLabel := '1.2';
  AssertEquals('a numeric label is appended', 'Toolbox 1.2', HCLVersionedName('Toolbox', F));
  F.VersionLabel := '';
  F.Version := '161204';
  AssertEquals('falls back to the version field', 'Toolbox 161204', HCLVersionedName('Toolbox', F));
  F.Version := '';
  F.FileName := 'hdsu-2.zip';
  AssertEquals('then the file name', 'hdsu-2', HCLVersionedName('Toolbox', F));
  F.FileName := '';
  AssertEquals('never the same as the current one', 'Toolbox (earlier version)',
    HCLVersionedName('Toolbox', F));
end;

procedure TTestImport.TestPlan_PreviousPlatformDBVersion;
var
  Current, Plan: THCLImportPlan;
  F: THCLCatalogFile;
begin
  F := MakeFile('HCDB2-170909 (1980-2025)', HCL_ARTIFACT_PLATFORMDB, '', 'HCDB2-170909');
  Current := FImport.PlanImport(F);
  Plan := FImport.PlanImport(AsPreviousVersion(F, 'HCDB2-170611', 'HCDB2-170611.zip'));

  AssertTrue('Plan is valid', Plan.Valid);
  AssertEquals('Current version keeps its name', 'HCDB2-170909', Current.ArtifactName);
  AssertEquals('Earlier version named by its label', 'HCDB2-170611', Plan.ArtifactName);
  AssertEquals('Destination', FOrg.GetPlatformDBPath('HCDB2-170611'), Plan.DestDir);
  AssertTrue('Beside, not over, the current one', Plan.DestDir <> Current.DestDir);
end;

procedure TTestImport.TestPlan_PreviousToolVersionNumericLabel;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(AsPreviousVersion(
    MakeFile('Toolbox', HCL_ARTIFACT_TOOL), '1.2', 'Toolbox.zip'));
  AssertTrue('Plan is valid', Plan.Valid);
  AssertEquals('Base name plus the version', 'Toolbox 1.2', Plan.ArtifactName);
end;

const
  /// <summary>
  /// hce_databases.zip (harpgamer:605, hc-timelines-db collection) exactly as
  /// its central directory lists it, placeholders included.
  /// </summary>
  TIMELINES_LAYOUT: array[0..16] of String = (
    'hce-databases/',
    'hce-databases/hc-media-file/',
    'hce-databases/hce-stock-db/',
    'hce-databases/hce-timeline-dbs/',
    'hce-databases/hce-timeline-dbs/hc-10toXX-DB/',
    'hce-databases/hce-timeline-dbs/hc-35to50-db/',
    'hce-databases/hce-timeline-dbs/hc-50to65-db/',
    'hce-databases/hce-timeline-dbs/hc-50to65-db/commondb.res',
    'hce-databases/hce-timeline-dbs/hc-50to65-db/HC-50to65-DB_110101.pdf',
    'hce-databases/hce-timeline-dbs/hc-65to80-db/',
    'hce-databases/hce-timeline-dbs/hc-65to80-db/commondb.res',
    'hce-databases/hce-timeline-dbs/hc-65to80-db/HC-65to80-DB_110101.pdf',
    'hce-databases/hce-timeline-dbs/hc-80to95-db/',
    'hce-databases/hce-timeline-dbs/hc-80to95-db/commondb.res',
    'hce-databases/hce-timeline-dbs/hc-80to95-db/HC-80to95-DB_110101.pdf',
    'hce-databases/hce-timeline-dbs/hc-95to10-DB/commondb.res',
    'hce-databases/hce-timeline-dbs/hc-95to10-DB/HC-95to10-DB_110101.pdf');

procedure TTestImport.TestFindBundledDBs_TimelinesCollection;
var
  DBs: THCLBundledDBArray;
begin
  DBs := HCLFindBundledDBs(MakeEntries(TIMELINES_LAYOUT));
  AssertEquals('four databases; the empty placeholders are not', 4, Length(DBs));
  AssertEquals('named by its manual', 'HC-50to65-DB_110101', DBs[0].Name);
  AssertEquals('second', 'HC-65to80-DB_110101', DBs[1].Name);
  AssertEquals('third', 'HC-80to95-DB_110101', DBs[2].Name);
  AssertEquals('fourth', 'HC-95to10-DB_110101', DBs[3].Name);
  AssertEquals('prefix is its directory',
    StringReplace('hce-databases/hce-timeline-dbs/hc-80to95-db', '/', PathDelim, [rfReplaceAll]),
    DBs[2].Prefix);
  AssertFalse('no pictures in the collection', DBs[0].HasRsr);
end;

procedure TTestImport.TestFindBundledDBs_SingleAndNaming;
var
  DBs: THCLBundledDBArray;
begin
  DBs := HCLFindBundledDBs(MakeEntries(['commondb.res']));
  AssertEquals('one at the top level', 1, Length(DBs));
  AssertEquals('top-level prefix', '', DBs[0].Prefix);
  AssertEquals('no name to go on', '', DBs[0].Name);

  DBs := HCLFindBundledDBs(MakeEntries(['HCDB2/commondb.res', 'HCDB2/commondb.rsr', 'HCDB2/readme.txt']));
  AssertEquals('one, wrapped', 1, Length(DBs));
  AssertEquals('undated readme: the folder names it', 'HCDB2', DBs[0].Name);

  DBs := HCLFindBundledDBs(MakeEntries(['x/commondb.res', 'x/notes_2011.txt',
    'x/Zeta-DB_110101.pdf', 'x/Alpha-DB_110101.txt']));
  AssertEquals('first dated name alphabetically, not archive order', 'Alpha-DB_110101', DBs[0].Name);

  AssertEquals('no database at all', 0, Length(HCLFindBundledDBs(MakeEntries(['a/readme.txt', 'b/']))));
  AssertEquals('pictures alone are not a database', 0,
    Length(HCLFindBundledDBs(MakeEntries(['commondb.rsr']))));
end;

procedure TTestImport.TestFindBundledDBs_CaseRsrAndUnsafe;
var
  Entries: THCLArchiveEntryArray;
  DBs: THCLBundledDBArray;
begin
  Entries := MakeEntries(['A/COMMONDB.RES', 'A/CommonDB.RSR', 'B/commondb.res', 'B/commondb.res']);
  SetLength(Entries, Length(Entries) + 1);
  Entries[High(Entries)] := MakeEntry('..' + PathDelim + 'evil' + PathDelim + 'commondb.res', False, False);
  DBs := HCLFindBundledDBs(Entries);
  AssertEquals('A and B once each; the unsafe one ignored', 2, Length(DBs));
  AssertTrue('upper-case pictures found', DBs[0].HasRsr);
  AssertFalse('B has none', DBs[1].HasRsr);
end;

procedure TTestImport.TestPlanBundledDB;
var
  DBs: THCLBundledDBArray;
  Plan: THCLImportPlan;
  F: THCLCatalogFile;
begin
  DBs := HCLFindBundledDBs(MakeEntries(TIMELINES_LAYOUT));
  F := MakeFile('hc-timelines-db collection', HCL_ARTIFACT_PLATFORMDB);
  Plan := FImport.PlanBundledDB(F, DBs[2]);
  AssertTrue('valid', Plan.Valid);
  AssertEquals('category', Ord(hclPlatformDBs), Ord(Plan.Category));
  AssertEquals('name', 'HC-80to95-DB_110101', Plan.ArtifactName);
  AssertEquals('destination', FOrg.GetPlatformDBPath('HC-80to95-DB_110101'), Plan.DestDir);
  AssertTrue('only its own directory', Plan.OnlyUnderPrefix);
  AssertEquals('which is', DBs[2].Prefix, Plan.EntryPrefix);

  DBs[2].Name := '  ';
  AssertFalse('no name: not valid', FImport.PlanBundledDB(F, DBs[2]).Valid);
end;

procedure TTestImport.TestExecute_CollectionSplitsIntoDatabases;
var
  ZipPath, Dest: String;
  DBs: THCLBundledDBArray;
  F: THCLCatalogFile;
  Res: THCLImportResult;
  I: Integer;
begin
  ZipPath := TempPath('hce_databases.zip');
  MakeZip(ZipPath, [
    'hce-databases/readme.txt',
    'hce-databases/hce-timeline-dbs/hc-50to65-db/commondb.res',
    'hce-databases/hce-timeline-dbs/hc-50to65-db/HC-50to65-DB_110101.pdf',
    'hce-databases/hce-timeline-dbs/hc-80to95-db/commondb.res',
    'hce-databases/hce-timeline-dbs/hc-80to95-db/HC-80to95-DB_110101.pdf']);
  DBs := HCLFindBundledDBs(FArchive.ListEntries(ZipPath));
  AssertEquals('two databases', 2, Length(DBs));
  F := MakeFile('hc-timelines-db collection', HCL_ARTIFACT_PLATFORMDB);

  for I := 0 to High(DBs) do
  begin
    Res := FImport.Execute(FImport.PlanBundledDB(F, DBs[I]), ZipPath, omOverwrite, nil);
    AssertTrue('import ' + DBs[I].Name + ' (' + Res.ErrorMessage + ')', Res.Success);
    AssertEquals('its two files only', 2, Res.FilesExtracted);
  end;

  Dest := FOrg.GetPlatformDBPath('HC-80to95-DB_110101') + PathDelim;
  AssertEquals('its own .res, at the top of its folder',
    'payload for hce-databases/hce-timeline-dbs/hc-80to95-db/commondb.res',
    ReadTextFile(Dest + 'commondb.res'));
  AssertTrue('its manual', FileExists(Dest + 'HC-80to95-DB_110101.pdf'));
  AssertFalse('not the other database''s manual', FileExists(Dest + 'HC-50to65-DB_110101.pdf'));
  AssertFalse('not the collection''s top-level readme', FileExists(Dest + 'readme.txt'));
  AssertFalse('no leftover wrapper directories', DirectoryExists(Dest + 'hce-databases'));
  AssertTrue('the other one beside it',
    FileExists(FOrg.GetPlatformDBPath('HC-50to65-DB_110101') + PathDelim + 'commondb.res'));
end;

procedure TTestImport.TestPlatformDBNeedsRsr;
var
  Dir: String;
begin
  Dir := TempPath('db');
  ForceDirectories(Dir);
  AssertFalse('empty: nothing to supply pictures for', HCLPlatformDBNeedsRsr(Dir));
  WriteTextFile(Dir + PathDelim + 'commondb.res', 'r');
  AssertTrue('.res alone needs pictures', HCLPlatformDBNeedsRsr(Dir));
  ForceDirectories(Dir + PathDelim + 'rsr');
  AssertFalse('an rsr\ directory counts', HCLPlatformDBNeedsRsr(Dir));
  RemoveDir(Dir + PathDelim + 'rsr');
  WriteTextFile(Dir + PathDelim + 'commondb.rsr', 'p');
  AssertFalse('has its pictures', HCLPlatformDBNeedsRsr(Dir));
end;

procedure TTestImport.TestPlaceCompanionFile;
var
  ZipPath, Staging, Err: String;
  Dirs: array[0..2] of String;
  I, Placed: Integer;
begin
  ZipPath := TempPath('rsrforhcxxtoxxdbs.zip');
  MakeZip(ZipPath, ['pics/commondb.rsr', 'pics/readme.txt', 'other.txt']);
  for I := 0 to 2 do
  begin
    Dirs[I] := TempPath('PlatformDBs' + PathDelim + 'DB' + IntToStr(I));
    ForceDirectories(Dirs[I]);
    WriteTextFile(Dirs[I] + PathDelim + 'commondb.res', 'r');
  end;
  // The third already has pictures of its own, which must be kept.
  WriteTextFile(Dirs[2] + PathDelim + 'commondb.rsr', 'its own');
  Staging := TempPath('cache' + PathDelim + 'extracted' + PathDelim + 'rsrforhcxxtoxxdbs');

  Placed := HCLPlaceCompanionFile(FArchive, ZipPath, 'commondb.rsr', Staging, Dirs, Err);
  AssertEquals('no error', '', Err);
  AssertEquals('two needed it', 2, Placed);
  AssertEquals('the companion''s pictures', 'payload for pics/commondb.rsr',
    ReadTextFile(Dirs[0] + PathDelim + 'commondb.rsr'));
  AssertEquals('in each', 'payload for pics/commondb.rsr', ReadTextFile(Dirs[1] + PathDelim + 'commondb.rsr'));
  AssertEquals('existing pictures kept', 'its own', ReadTextFile(Dirs[2] + PathDelim + 'commondb.rsr'));
  AssertFalse('only the one file is placed', FileExists(Dirs[0] + PathDelim + 'readme.txt'));
  AssertTrue('staged for next time', FileExists(Staging + PathDelim + 'commondb.rsr'));
  AssertFalse('only its own directory was extracted', FileExists(Staging + PathDelim + 'other.txt'));
  AssertFalse('no partial copies left', FileExists(Dirs[0] + PathDelim + 'commondb.rsr.part'));

  // Next time the staged copy is used; the archive need not even exist.
  DeleteFile(ZipPath);
  DeleteFile(Dirs[1] + PathDelim + 'commondb.rsr');
  AssertEquals('from the staged copy', 1,
    HCLPlaceCompanionFile(FArchive, ZipPath, 'commondb.rsr', Staging, Dirs, Err));
  AssertEquals('still no error', '', Err);
end;

procedure TTestImport.TestPlaceCompanionFile_Missing;
var
  ZipPath, Err: String;
  Dirs: array[0..0] of String;
begin
  ZipPath := TempPath('wrong.zip');
  MakeZip(ZipPath, ['readme.txt']);
  Dirs[0] := TempPath('DB');
  ForceDirectories(Dirs[0]);
  AssertEquals('nothing placed', 0,
    HCLPlaceCompanionFile(FArchive, ZipPath, 'commondb.rsr', TempPath('stage'), Dirs, Err));
  AssertTrue('says why: ' + Err, Pos('does not contain commondb.rsr', Err) > 0);
  AssertFalse('nothing written', FileExists(Dirs[0] + PathDelim + 'commondb.rsr'));
end;

/// <summary>A versioned scenario header: $FFFF, format version, then the database name.</summary>
function ScenarioHeader(const AFlavor: String; AVersion: Byte = 2): RawByteString;
var
  S: String;
begin
  S := #$FF#$FF + Chr(AVersion) + #0 + AFlavor;
  S := S + StringOfChar(#0, 36 - Length(S)) + 'rest of the scenario';
  SetLength(Result, Length(S));
  Move(S[1], Result[1], Length(S));
  SetCodePage(Result, CP_NONE, False);
end;

procedure TTestImport.TestScenarioDBFlavor;
begin
  AssertEquals('Pinger Charlie', 'HCDB-060103', HCLScenarioDBFlavor(ScenarioHeader('HCDB-060103')));
  AssertEquals('HCDB2', 'HCDB2-170909', HCLScenarioDBFlavor(ScenarioHeader('HCDB2-170909')));
  AssertEquals('other families', 'HC_WW2_DB', HCLScenarioDBFlavor(ScenarioHeader('HC_WW2_DB')));
  AssertEquals('stops at 32 bytes without a NUL', StringOfChar('A', 32),
    HCLScenarioDBFlavor(ScenarioHeader(StringOfChar('A', 40))));
end;

procedure TTestImport.TestScenarioDBFlavorRefusals;
var
  Old: RawByteString;
begin
  Old := ScenarioHeader('HCDB-060103');
  Old[1] := #$12; // a version-0 file starts with its annex count, not $FFFF
  AssertEquals('version-0 scenario records none', '', HCLScenarioDBFlavor(Old));
  AssertEquals('control characters', '', HCLScenarioDBFlavor(ScenarioHeader('HC'#1'DB-060103')));
  AssertEquals('path characters', '', HCLScenarioDBFlavor(ScenarioHeader('..\evil')));
  AssertEquals('must start with a letter', '', HCLScenarioDBFlavor(ScenarioHeader('060103')));
  AssertEquals('too short a name', '', HCLScenarioDBFlavor(ScenarioHeader('HC')));
  AssertEquals('empty name', '', HCLScenarioDBFlavor(ScenarioHeader('')));
  AssertEquals('short file', '', HCLScenarioDBFlavor(#$FF#$FF#2));
  AssertEquals('empty file', '', HCLScenarioDBFlavor(''));
end;

procedure TTestImport.TestIsScenarioFileName;
begin
  AssertTrue('.SCm', HCLIsScenarioFileName('Pinger/PINGER.SCm'));
  AssertTrue('.scq', HCLIsScenarioFileName('WestPac1.0.scq'));
  AssertTrue('.sc1', HCLIsScenarioFileName('x.SC1'));
  AssertFalse('xml sidecar', HCLIsScenarioFileName('WestPac1.0.scq.xml'));
  AssertFalse('orders file', HCLIsScenarioFileName('PINGER.BLm'));
  AssertFalse('.scen', HCLIsScenarioFileName('a.scen'));
  AssertFalse('.sc', HCLIsScenarioFileName('a.sc'));
end;

procedure TTestImport.TestScenarioDatabase;

  procedure ZipWith(const APath: String; const ANames: array of String;
    const AContents: array of RawByteString);
  var
    Zipper: TZipper;
    I: Integer;
    Disk: String;
    FS: TFileStream;
  begin
    Zipper := TZipper.Create;
    try
      for I := 0 to High(ANames) do
      begin
        Disk := TempPath('zc_' + IntToStr(I) + '_' + ExtractFileName(APath));
        FS := TFileStream.Create(Disk, fmCreate);
        try
          if AContents[I] <> '' then
            FS.WriteBuffer(AContents[I][1], Length(AContents[I]));
        finally
          FS.Free;
        end;
        Zipper.Entries.AddFileEntry(Disk, ANames[I]);
      end;
      Zipper.FileName := APath;
      Zipper.ZipAllFiles;
    finally
      Zipper.Free;
    end;
  end;

var
  Readme: RawByteString;
begin
  Readme := 'readme';
  ZipWith(TempPath('pinger.zip'), ['Pinger/PINGER.SCm', 'Pinger/PINGER.BLm', 'Pinger/readme.txt'],
    [ScenarioHeader('HCDB-060103'), ScenarioHeader('HCDB-999999'), Readme]);
  AssertEquals('from the scenario file, not the orders file', 'HCDB-060103',
    HCLScenarioDatabase(FArchive, TempPath('pinger.zip')));

  ZipWith(TempPath('pair.zip'), ['A.SCm', 'B.SCm'],
    [ScenarioHeader('HCDB-060103'), ScenarioHeader('hcdb-060103')]);
  AssertEquals('agreeing scenarios', 'HCDB-060103', HCLScenarioDatabase(FArchive, TempPath('pair.zip')));

  ZipWith(TempPath('mixed.zip'), ['A.SCm', 'B.SCq'],
    [ScenarioHeader('HCDB-060103'), ScenarioHeader('HCDB2-170909')]);
  AssertEquals('disagreement: not ours to pick', '', HCLScenarioDatabase(FArchive, TempPath('mixed.zip')));

  Readme := ScenarioHeader('HCDB-060103');
  Readme[1] := #$05;
  ZipWith(TempPath('old.zip'), ['GIUK.SC1'], [Readme]);
  AssertEquals('version-0 battleset scenario: none', '', HCLScenarioDatabase(FArchive, TempPath('old.zip')));

  AssertEquals('no archive handler', '', HCLScenarioDatabase(nil, TempPath('pinger.zip')));
end;

procedure TTestImport.TestDownloadCategory;
var
  Cat: THCLCategory;
begin
  AssertTrue(HCLDownloadCategory(MakeFile('Pinger Charlie', HCL_ARTIFACT_SCENARIO, '', 'HCDB'), Cat));
  AssertEquals('scenario with a database', Ord(hclPlatformDBs), Ord(Cat));
  AssertTrue(HCLDownloadCategory(MakeFile('Day One', HCL_ARTIFACT_SCENARIO, 'GIUK'), Cat));
  AssertEquals('scenario with only a battleset', Ord(hclBattleSets), Ord(Cat));
  AssertTrue(HCLDownloadCategory(MakeFile('Bare', HCL_ARTIFACT_SCENARIO), Cat));
  AssertEquals('scenario with neither', Ord(hclBattleSets), Ord(Cat));
  AssertTrue(HCLDownloadCategory(MakeFile('HCDB2', HCL_ARTIFACT_PLATFORMDB, '', 'HCDB2-170909'), Cat));
  AssertEquals('a platform database itself', Ord(hclPlatformDBs), Ord(Cat));
  AssertTrue(HCLDownloadCategory(MakeFile('Tool', HCL_ARTIFACT_TOOL, '', 'HCDB'), Cat));
  AssertEquals('only scenarios follow the database', Ord(hclTools), Ord(Cat));
  AssertFalse('unknown type', HCLDownloadCategory(MakeFile('X', 'Nonsense'), Cat));
end;

procedure TTestImport.TestFindInstalledScenario;
var
  F: THCLCatalogFile;
  DBDir, Found: String;
begin
  F := MakeFile('Pinger Charlie', HCL_ARTIFACT_SCENARIO, '', 'HCDB');
  AssertEquals('not installed', '', HCLFindInstalledScenario(FOrg, F, DBDir));

  WriteTextFile(FOrg.GetPlatformDBScenarioPath('HCDB-070414', 'Pinger Charlie') + PathDelim + 'PINGER.BLm', 'orders');
  AssertEquals('orders alone are not a scenario', '', HCLFindInstalledScenario(FOrg, F, DBDir));

  WriteTextFile(FOrg.GetPlatformDBScenarioPath('HCDB-070414', 'Pinger Charlie') + PathDelim + 'PINGER.SCm', 'scenario');
  Found := HCLFindInstalledScenario(FOrg, F, DBDir);
  AssertEquals('the scenario file',
    FOrg.GetPlatformDBScenarioPath('HCDB-070414', 'Pinger Charlie') + PathDelim + 'PINGER.SCm', Found);
  AssertEquals('its database', FOrg.GetPlatformDBPath('HCDB-070414'), DBDir);

  F := MakeFile('Day One Strike', HCL_ARTIFACT_SCENARIO, 'GIUK');
  WriteTextFile(FOrg.GetBattleSetScenarioPath('GIUK', 'Day One Strike') + PathDelim + 'DAYONE.SC1', 'scenario');
  Found := HCLFindInstalledScenario(FOrg, F, DBDir);
  AssertTrue('under a BattleSet', Pos('DAYONE.SC1', Found) > 0);
  AssertEquals('no database folder', '', DBDir);

  AssertEquals('not a scenario', '',
    HCLFindInstalledScenario(FOrg, MakeFile('GIUK Gap', HCL_ARTIFACT_BATTLESET, 'GIUK'), DBDir));
  AssertEquals('no tree', '', HCLFindInstalledScenario(nil, F, DBDir));
end;

procedure TTestImport.TestPlan_PreviousScenarioVersion;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(AsPreviousVersion(
    MakeFile('Day One Strike', HCL_ARTIFACT_SCENARIO, 'GIUK'), 'DayOne-v1', 'DayOne-v1.zip'));
  AssertTrue('Plan is valid', Plan.Valid);
  AssertEquals('Parent unchanged', 'GIUK', Plan.ParentName);
  AssertEquals('Own scenario directory', 'DayOne-v1', Plan.ArtifactName);
  AssertEquals('Destination', FOrg.GetBattleSetScenarioPath('GIUK', 'DayOne-v1'), Plan.DestDir);
end;

{ --------------------------------------------------------------------------- }
{ HCLResolveWrapperPrefix                                                      }
{ --------------------------------------------------------------------------- }

procedure TTestImport.TestPrefix_BattleSetReleaseWrapper;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('GIUK Gap', HCL_ARTIFACT_BATTLESET, 'GIUK'));
  AssertEquals('Release wrapper stripped', 'GIUK-v1.2',
    HCLResolveWrapperPrefix(
      MakeEntries(['GIUK-v1.2/GIUK.res', 'GIUK-v1.2/GIUK.rsr']), Plan));
end;

procedure TTestImport.TestPrefix_BattleSetFlat;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('GIUK Gap', HCL_ARTIFACT_BATTLESET, 'GIUK'));
  AssertEquals('Flat archive: nothing stripped', '',
    HCLResolveWrapperPrefix(MakeEntries(['GIUK.res']), Plan));
end;

procedure TTestImport.TestPrefix_BattleSetContainers;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('GIUK Gap', HCL_ARTIFACT_BATTLESET, 'GIUK'));
  AssertEquals('Category and artifact name both stripped',
    'BattleSets' + PathDelim + 'GIUK',
    HCLResolveWrapperPrefix(MakeEntries(['BattleSets/GIUK/GIUK.res']), Plan));
end;

procedure TTestImport.TestPrefix_BattleSetScenariosIsStructural;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('GIUK Gap', HCL_ARTIFACT_BATTLESET, 'GIUK'));
  AssertEquals('Scenarios\ is kept', '',
    HCLResolveWrapperPrefix(MakeEntries(['Scenarios/X/x.scs']), Plan));
end;

procedure TTestImport.TestPrefix_PlatformDBResIsStructural;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(
    MakeFile('Platform Database Release', HCL_ARTIFACT_PLATFORMDB, '', 'HCDB-160414'));
  AssertEquals('res\ is kept', '',
    HCLResolveWrapperPrefix(MakeEntries(['res/annex.csv']), Plan));
end;

procedure TTestImport.TestPrefix_PlatformDBNamedWrapper;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(
    MakeFile('Platform Database Release', HCL_ARTIFACT_PLATFORMDB, '', 'HCDB-160414'));
  AssertEquals('Version wrapper stripped, res\ and rsr\ kept', 'HCDB-160414',
    HCLResolveWrapperPrefix(
      MakeEntries(['HCDB-160414/res/a.csv', 'HCDB-160414/rsr/b.bmp']), Plan));
end;

procedure TTestImport.TestPrefix_SoundReleaseWrapper;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Explosion Pack', HCL_ARTIFACT_SOUND));
  AssertEquals('Release wrapper stripped', 'Pack',
    HCLResolveWrapperPrefix(MakeEntries(['Pack/boom.wav']), Plan));
end;

procedure TTestImport.TestPrefix_SoundSubdirectoryNotEaten;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Explosion Pack', HCL_ARTIFACT_SOUND));
  AssertEquals('Only the top-level wrapper is stripped', 'Pack',
    HCLResolveWrapperPrefix(MakeEntries(['Pack/Sub/boom.wav']), Plan));
end;

procedure TTestImport.TestPrefix_SoundCategoryNameIsContainer;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Explosion Pack', HCL_ARTIFACT_SOUND));
  AssertEquals('Wrapper and category name stripped', 'Pack' + PathDelim + 'Sounds',
    HCLResolveWrapperPrefix(MakeEntries(['Pack/Sounds/boom.wav']), Plan));
end;

procedure TTestImport.TestPrefix_ExportDLLAsPackaged;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Logger DLL', HCL_ARTIFACT_EXPORTDLL),
    '', False, dtAsPackaged);
  AssertEquals('GE\ at the top is kept', '',
    HCLResolveWrapperPrefix(MakeEntries(['GE/x.dll']), Plan));
  AssertEquals('Wrapper and ExportDLLs\ stripped, GE\ kept',
    'Pack' + PathDelim + 'ExportDLLs',
    HCLResolveWrapperPrefix(MakeEntries(['Pack/ExportDLLs/GE/x.dll']), Plan));
end;

procedure TTestImport.TestPrefix_ExportDLLGETargetStripsGE;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Logger DLL', HCL_ARTIFACT_EXPORTDLL),
    '', False, dtGE);
  AssertEquals('GE\ stripped when GE is the target', 'GE',
    HCLResolveWrapperPrefix(MakeEntries(['GE/x.dll']), Plan));
  AssertEquals('ExportDLLs\GE\ stripped when GE is the target',
    'ExportDLLs' + PathDelim + 'GE',
    HCLResolveWrapperPrefix(MakeEntries(['ExportDLLs/GE/x.dll']), Plan));
end;

procedure TTestImport.TestPrefix_ExportDLLGETargetKeepsSE;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Logger DLL', HCL_ARTIFACT_EXPORTDLL),
    '', False, dtGE);
  AssertEquals('SE\ is structural when GE is the target', '',
    HCLResolveWrapperPrefix(MakeEntries(['SE/x.dll']), Plan));
end;

procedure TTestImport.TestPrefix_DirectoryEntriesIgnored;
var
  Plan: THCLImportPlan;
begin
  Plan := FImport.PlanImport(MakeFile('Explosion Pack', HCL_ARTIFACT_SOUND));
  AssertEquals('Directory entries do not block the wrapper', 'Pack',
    HCLResolveWrapperPrefix(
      MakeEntries(['Pack/', 'Pack/Empty/', 'Pack/a.wav']), Plan));
end;

procedure TTestImport.TestPrefix_StripWrapperFalse;
var
  Plan: THCLImportPlan;
begin
  Plan := Default(THCLImportPlan);
  Plan.Valid := True;
  Plan.Category := hclSounds;
  Plan.Kind := ikCategoryRoot;
  Plan.StripWrapper := False;

  AssertEquals('StripWrapper False: nothing stripped', '',
    HCLResolveWrapperPrefix(MakeEntries(['Pack/a.wav']), Plan));
  AssertEquals('StripWrapper False: containers not stripped either', '',
    HCLResolveWrapperPrefix(MakeEntries(['Sounds/a.wav']), Plan));
end;

{ --------------------------------------------------------------------------- }
{ Execute                                                                      }
{ --------------------------------------------------------------------------- }

procedure TTestImport.TestExecute_BattleSetZip;
var
  Plan: THCLImportPlan;
  Res: THCLImportResult;
  ZipPath, Dest: String;
begin
  ZipPath := TempPath('giuk.zip');
  MakeZip(ZipPath, ['GIUK.res', 'GIUK.rsr']);

  Plan := FImport.PlanImport(
    MakeFile('GIUK Gap', HCL_ARTIFACT_BATTLESET, 'GIUK'));
  Res := FImport.Execute(Plan, ZipPath, omOverwrite, nil);

  AssertTrue('Import succeeded (' + Res.ErrorMessage + ')', Res.Success);
  AssertEquals('Error message empty on success', '', Res.ErrorMessage);
  AssertEquals('Both files extracted', 2, Res.FilesExtracted);
  AssertEquals('No files skipped', 0, Res.FilesSkipped);
  AssertEquals('No unsafe entries', 0, Length(Res.UnsafeEntries));
  AssertTrue('Bytes were written', Res.BytesExtracted > 0);

  Dest := FOrg.GetBattleSetPath('GIUK');
  AssertEquals('Result reports the destination', Dest, Res.DestDir);
  AssertTrue('Destination created', DirectoryExists(Dest));
  AssertTrue('.res landed', FileExists(Dest + PathDelim + 'GIUK.res'));
  AssertTrue('.rsr landed', FileExists(Dest + PathDelim + 'GIUK.rsr'));
  AssertFalse('Flat archive needed no stripping', Res.WrapperStripped);
end;

procedure TTestImport.TestExecute_WrapperZipIsStripped;
var
  Plan: THCLImportPlan;
  Res: THCLImportResult;
  ZipPath, Dest: String;
begin
  ZipPath := TempPath('giuk-wrapped.zip');
  MakeZip(ZipPath, ['GIUK-v1.2/GIUK.res', 'GIUK-v1.2/GIUK.rsr']);

  Plan := FImport.PlanImport(
    MakeFile('GIUK Gap', HCL_ARTIFACT_BATTLESET, 'GIUK'));
  Res := FImport.Execute(Plan, ZipPath, omOverwrite, nil);

  AssertTrue('Import succeeded (' + Res.ErrorMessage + ')', Res.Success);
  AssertEquals('Both files extracted', 2, Res.FilesExtracted);
  AssertTrue('Wrapper directory was stripped away', Res.WrapperStripped);

  Dest := FOrg.GetBattleSetPath('GIUK');
  AssertTrue('.res sits at the artifact level',
    FileExists(Dest + PathDelim + 'GIUK.res'));
  AssertTrue('.rsr sits at the artifact level',
    FileExists(Dest + PathDelim + 'GIUK.rsr'));
  AssertFalse('Redundant wrapper directory is gone',
    DirectoryExists(Dest + PathDelim + 'GIUK-v1.2'));
end;

procedure TTestImport.TestExecute_InvalidPlan;
var
  Plan: THCLImportPlan;
  Res: THCLImportResult;
  ZipPath: String;
begin
  ZipPath := TempPath('mystery.zip');
  MakeZip(ZipPath, ['thing.dat']);

  Plan := FImport.PlanImport(MakeFile('Mystery Upload', 'Widget'));
  AssertFalse('Precondition: plan is invalid', Plan.Valid);

  Res := FImport.Execute(Plan, ZipPath, omOverwrite, nil);

  AssertFalse('Execute refuses an invalid plan', Res.Success);
  AssertEquals('Error message repeats the plan reason',
    Plan.Reason, Res.ErrorMessage);
  AssertEquals('Nothing extracted', 0, Res.FilesExtracted);
  AssertFalse('No managed tree was created',
    DirectoryExists(FOrg.GetManagedRoot));
end;

procedure TTestImport.TestExecute_MissingArchive;
var
  Plan: THCLImportPlan;
  Res: THCLImportResult;
begin
  Plan := FImport.PlanImport(
    MakeFile('GIUK Gap', HCL_ARTIFACT_BATTLESET, 'GIUK'));
  AssertTrue('Precondition: plan is valid', Plan.Valid);

  Res := FImport.Execute(Plan, TempPath('does_not_exist.zip'), omOverwrite, nil);

  AssertFalse('Execute fails on a missing archive', Res.Success);
  AssertTrue('Error message is populated', Res.ErrorMessage <> '');
  AssertEquals('Nothing extracted', 0, Res.FilesExtracted);
  AssertFalse('Destination was not created',
    DirectoryExists(FOrg.GetBattleSetPath('GIUK')));
end;

procedure TTestImport.TestExecute_ScenarioCreatesSavedGamesTree;
var
  Plan: THCLImportPlan;
  Res: THCLImportResult;
  ZipPath, Dest: String;
begin
  ZipPath := TempPath('scenario.zip');
  MakeZip(ZipPath, ['DayOne.scs', 'DayOne.xml']);

  Plan := FImport.PlanImport(
    MakeFile('Day One Strike', HCL_ARTIFACT_SCENARIO, 'GIUK'));
  Res := FImport.Execute(Plan, ZipPath, omOverwrite, nil);

  AssertTrue('Import succeeded (' + Res.ErrorMessage + ')', Res.Success);
  AssertEquals('Both files extracted', 2, Res.FilesExtracted);

  Dest := FOrg.GetBattleSetScenarioPath('GIUK', 'Day One Strike');
  AssertTrue('Scenario directory created', DirectoryExists(Dest));
  AssertTrue('Scenario file landed', FileExists(Dest + PathDelim + 'DayOne.scs'));

  AssertTrue('SavedGames scaffolded',
    DirectoryExists(FOrg.GetBattleSetSavedGamesPath('GIUK', 'Day One Strike')));
  AssertTrue('AutoSaves scaffolded',
    DirectoryExists(FOrg.GetBattleSetAutoSavesPath('GIUK', 'Day One Strike')));
end;

procedure TTestImport.TestExecute_SoundZipImportsFlat;
var
  Plan: THCLImportPlan;
  Res: THCLImportResult;
  ZipPath, Dest: String;
begin
  ZipPath := TempPath('sounds.zip');
  MakeZip(ZipPath, ['boom.wav', 'ping.wav']);

  Plan := FImport.PlanImport(MakeFile('Explosion Pack', HCL_ARTIFACT_SOUND));
  Res := FImport.Execute(Plan, ZipPath, omOverwrite, nil);

  AssertTrue('Import succeeded (' + Res.ErrorMessage + ')', Res.Success);
  AssertEquals('Both files extracted', 2, Res.FilesExtracted);

  Dest := FOrg.GetCategoryPath(hclSounds);
  AssertEquals('Destination is the category root', Dest, Res.DestDir);
  AssertTrue('First sound landed in the category directory',
    FileExists(Dest + PathDelim + 'boom.wav'));
  AssertTrue('Second sound landed in the category directory',
    FileExists(Dest + PathDelim + 'ping.wav'));
  AssertFalse('No per-artifact subdirectory was created',
    DirectoryExists(Dest + PathDelim + 'Explosion Pack'));
  AssertFalse('A flat archive has no wrapper to strip', Res.WrapperStripped);
end;

procedure TTestImport.TestExecute_WrappedSoundZipIsStripped;
var
  Plan: THCLImportPlan;
  Res: THCLImportResult;
  ZipPath, Dest: String;
begin
  // The category directory normally already holds cache\ (and often files
  // from earlier imports), which is exactly why the wrapper must be located
  // from the archive rather than by looking for a lone subdirectory.
  FOrg.EnsureCategoryExists(hclSounds);

  ZipPath := TempPath('wrapped_sounds.zip');
  MakeZip(ZipPath, ['SoundPack-v2/boom.wav', 'SoundPack-v2/ping.wav']);

  Plan := FImport.PlanImport(MakeFile('Explosion Pack', HCL_ARTIFACT_SOUND));
  Res := FImport.Execute(Plan, ZipPath, omOverwrite, nil);

  AssertTrue('Import succeeded (' + Res.ErrorMessage + ')', Res.Success);
  AssertTrue('Wrapper directory was stripped', Res.WrapperStripped);

  Dest := FOrg.GetCategoryPath(hclSounds);
  AssertTrue('First sound reached the category root',
    FileExists(Dest + PathDelim + 'boom.wav'));
  AssertTrue('Second sound reached the category root',
    FileExists(Dest + PathDelim + 'ping.wav'));
  AssertFalse('Wrapper directory is gone',
    DirectoryExists(Dest + PathDelim + 'SoundPack-v2'));
  AssertTrue('The category cache directory was left alone',
    DirectoryExists(FOrg.GetCachePath(hclSounds)));
end;

procedure TTestImport.TestExecute_ExportDLLGEStructurePreserved;
var
  Plan: THCLImportPlan;
  Res: THCLImportResult;
  ZipPath, Root: String;
  Target: THCLDLLTarget;
begin
  Root := FOrg.GetCategoryPath(hclExportDLLs);
  // A GE-only DLL from an earlier import.
  WriteTextFile(Root + PathDelim + 'GE' + PathDelim + 'Older.dll', 'older');

  ZipPath := TempPath('mapwindow.zip');
  MakeZip(ZipPath, ['GE/MapWindow.dll']);

  // Mirror the UI: ask the archive first, and only prompt when it cannot say.
  Target := HCLDetectDLLTarget(FArchive.ListEntries(ZipPath));
  AssertTrue('Archive declares its own GE layout', Target = dtAsPackaged);

  Plan := FImport.PlanImport(MakeFile('MapWindow', HCL_ARTIFACT_EXPORTDLL),
    '', False, Target);
  Res := FImport.Execute(Plan, ZipPath, omOverwrite, nil);

  AssertTrue('Import succeeded (' + Res.ErrorMessage + ')', Res.Success);
  AssertTrue('New DLL stays GE-only',
    FileExists(Root + PathDelim + 'GE' + PathDelim + 'MapWindow.dll'));
  AssertTrue('Earlier GE-only DLL was not swept up',
    FileExists(Root + PathDelim + 'GE' + PathDelim + 'Older.dll'));
  AssertFalse('New DLL did not leak into the common directory',
    FileExists(Root + PathDelim + 'MapWindow.dll'));
  AssertFalse('Earlier DLL did not leak into the common directory',
    FileExists(Root + PathDelim + 'Older.dll'));
end;

procedure TTestImport.TestExecute_FlatExportDLLWithGETarget;
var
  Plan: THCLImportPlan;
  Res: THCLImportResult;
  ZipPath, Root: String;
begin
  Root := FOrg.GetCategoryPath(hclExportDLLs);
  ZipPath := TempPath('flatdll.zip');
  MakeZip(ZipPath, ['MapWindow.dll']);

  Plan := FImport.PlanImport(MakeFile('MapWindow', HCL_ARTIFACT_EXPORTDLL),
    '', False, dtGE);
  Res := FImport.Execute(Plan, ZipPath, omOverwrite, nil);

  AssertTrue('Import succeeded (' + Res.ErrorMessage + ')', Res.Success);
  AssertTrue('DLL landed in GE\',
    FileExists(Root + PathDelim + 'GE' + PathDelim + 'MapWindow.dll'));
  AssertFalse('Nothing at the common root',
    FileExists(Root + PathDelim + 'MapWindow.dll'));
  AssertFalse('Flat archive needed no stripping', Res.WrapperStripped);
end;

procedure TTestImport.TestExecute_ExportDLLMergesIntoExistingGEDir;
var
  Plan: THCLImportPlan;
  Res: THCLImportResult;
  ZipPath, Root: String;
begin
  Root := FOrg.GetCategoryPath(hclExportDLLs);
  WriteTextFile(Root + PathDelim + 'GE' + PathDelim + 'Older.dll', 'older');

  ZipPath := TempPath('packdll.zip');
  MakeZip(ZipPath, ['Pack/ExportDLLs/GE/New.dll']);

  Plan := FImport.PlanImport(MakeFile('New DLL', HCL_ARTIFACT_EXPORTDLL),
    '', False, dtAsPackaged);
  Res := FImport.Execute(Plan, ZipPath, omOverwrite, nil);

  AssertTrue('Import succeeded (' + Res.ErrorMessage + ')', Res.Success);
  AssertTrue('New DLL merged into GE\',
    FileExists(Root + PathDelim + 'GE' + PathDelim + 'New.dll'));
  AssertEquals('Older DLL untouched', 'older',
    ReadTextFile(Root + PathDelim + 'GE' + PathDelim + 'Older.dll'));
  AssertTrue('Wrapper was stripped', Res.WrapperStripped);
  AssertEquals('Stripped prefix', 'Pack' + PathDelim + 'ExportDLLs',
    Res.StrippedPrefix);
  AssertFalse('No Pack directory created',
    DirectoryExists(Root + PathDelim + 'Pack'));
end;

procedure TTestImport.TestExecute_WrappedSoundLeavesExistingSubdirAlone;
var
  Plan: THCLImportPlan;
  Res: THCLImportResult;
  ZipPath, Dest: String;
begin
  Dest := FOrg.GetCategoryPath(hclSounds);
  // A subdirectory that happens to share the wrapper's name, from before.
  WriteTextFile(Dest + PathDelim + 'Pack' + PathDelim + 'old.wav', 'old');

  ZipPath := TempPath('packsound.zip');
  MakeZip(ZipPath, ['Pack/boom.wav']);

  Plan := FImport.PlanImport(MakeFile('Boom', HCL_ARTIFACT_SOUND));
  Res := FImport.Execute(Plan, ZipPath, omOverwrite, nil);

  AssertTrue('Import succeeded (' + Res.ErrorMessage + ')', Res.Success);
  AssertTrue('boom.wav reached the category root',
    FileExists(Dest + PathDelim + 'boom.wav'));
  AssertEquals('Pre-existing Pack\old.wav untouched', 'old',
    ReadTextFile(Dest + PathDelim + 'Pack' + PathDelim + 'old.wav'));
  AssertFalse('old.wav was not swept up to the root',
    FileExists(Dest + PathDelim + 'old.wav'));
  AssertFalse('boom.wav not written under Pack\',
    FileExists(Dest + PathDelim + 'Pack' + PathDelim + 'boom.wav'));
end;

procedure TTestImport.TestExecute_ExportDLLUnspecifiedTargetRefused;
var
  Plan: THCLImportPlan;
  Res: THCLImportResult;
  ZipPath: String;
begin
  ZipPath := TempPath('unspecified.zip');
  MakeZip(ZipPath, ['MapWindow.dll']);

  Plan := FImport.PlanImport(MakeFile('MapWindow', HCL_ARTIFACT_EXPORTDLL),
    '', False, dtUnspecified);
  AssertFalse('Precondition: plan is invalid', Plan.Valid);

  Res := FImport.Execute(Plan, ZipPath, omOverwrite, nil);

  AssertFalse('Execute refuses a plan with no DLL target', Res.Success);
  AssertEquals('Error message repeats the plan reason',
    Plan.Reason, Res.ErrorMessage);
  AssertEquals('Nothing extracted', 0, Res.FilesExtracted);
  AssertFalse('No managed tree was created',
    DirectoryExists(FOrg.GetManagedRoot));
end;

initialization
  RegisterTest(TTestImport);

end.
