Files
AssetUsageAudit/Source/AssetUsageAuditTests/Private/AssetExportManifest.spec.cpp
MagentaDolphin aeb4a1067c fix: correct authorship attribution to MagentaDolphin
The plugin was authored outside studio work but carried NextGenium
attribution in the .uplugin descriptor and in every source header.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 17:35:48 +07:00

267 lines
10 KiB
C++

// MagentaDolphin 2026. Asset Usage Audit.
#include "AssetExportManifest.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Dom/JsonObject.h"
#include "HAL/FileManager.h"
#include "Misc/AutomationTest.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#if WITH_DEV_AUTOMATION_TESTS
BEGIN_DEFINE_SPEC(AssetExportManifestSpec,
"AssetUsageAudit.ExportManifest",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
FString ScratchDir;
void MakeScratchDir(const TCHAR* Suffix)
{
ScratchDir = FPaths::ConvertRelativePathToFull(
FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAuditTests"), Suffix));
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
IFileManager::Get().MakeDirectory(*ScratchDir, true);
}
void RemoveScratchDir()
{
if (!ScratchDir.IsEmpty())
{
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
}
}
static IAssetRegistry& ReadyRegistry()
{
IAssetRegistry& Registry = IAssetRegistry::GetChecked();
if (Registry.IsLoadingAssets())
{
Registry.WaitForCompletion();
}
return Registry;
}
/** Parse the manifest the writer just produced. Null when it is missing or malformed. */
TSharedPtr<FJsonObject> ReadManifest() const
{
FString Text;
if (!FFileHelper::LoadFileToString(Text, *FPaths::Combine(ScratchDir, AssetExportManifest::FileName)))
{
return nullptr;
}
TSharedPtr<FJsonObject> Root;
const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Text);
return FJsonSerializer::Deserialize(Reader, Root) ? Root : nullptr;
}
END_DEFINE_SPEC(AssetExportManifestSpec)
void AssetExportManifestSpec::Define()
{
Describe("Refusing to write a misleading manifest", [this]()
{
AfterEach([this]() { RemoveScratchDir(); });
It("fails when the exporter recorded nothing", [this]()
{
// The usual cause is a caller that forgot bRecordWrittenFiles. An empty manifest beside
// a folder full of assets would be believed by whatever reads it next.
MakeScratchDir(TEXT("ManifestEmpty"));
AddExpectedError(TEXT("the exporter recorded no files"), EAutomationExpectedErrorFlags::Contains, 1);
AssetExportManifest::FOptions Options;
Options.TargetDirectory = ScratchDir;
const AssetExportManifest::FResult Result =
AssetExportManifest::Write(ReadyRegistry(), {}, Options);
TestFalse(TEXT("not successful"), Result.bSuccess);
TestTrue(TEXT("says why"), !Result.ErrorMessage.IsEmpty());
TestFalse(TEXT("wrote no file"), IFileManager::Get().FileExists(
*FPaths::Combine(ScratchDir, AssetExportManifest::FileName)));
});
It("fails when no target directory is given", [this]()
{
AddExpectedError(TEXT("no target directory"), EAutomationExpectedErrorFlags::Contains, 1);
AssetExportManifest::FOptions Options;
TArray<FAssetUsageExporter::FWrittenFile> Written;
Written.AddDefaulted_GetRef().PackageName = FName(TEXT("/Game/Whatever"));
const AssetExportManifest::FResult Result =
AssetExportManifest::Write(ReadyRegistry(), Written, Options);
TestFalse(TEXT("not successful"), Result.bSuccess);
});
It("refuses under the Migrate layout", [this]()
{
// Migrate keeps package paths intact, so there is nothing to restore. A manifest there
// would imply the references had been broken.
MakeScratchDir(TEXT("ManifestMigrate"));
AssetExportManifest::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.Layout = EExportLayout::Migrate;
TArray<FAssetUsageExporter::FWrittenFile> Written;
FAssetUsageExporter::FWrittenFile& Entry = Written.AddDefaulted_GetRef();
Entry.PackageName = FName(TEXT("/Game/Whatever"));
Entry.RelativePath = TEXT("Whatever.uasset");
const AssetExportManifest::FResult Result =
AssetExportManifest::Write(ReadyRegistry(), Written, Options);
TestFalse(TEXT("not successful"), Result.bSuccess);
TestFalse(TEXT("wrote no file"), IFileManager::Get().FileExists(
*FPaths::Combine(ScratchDir, AssetExportManifest::FileName)));
});
});
Describe("The manifest it writes", [this]()
{
AfterEach([this]() { RemoveScratchDir(); });
It("is valid JSON carrying the schema version and the layout", [this]()
{
MakeScratchDir(TEXT("ManifestBasic"));
AssetExportManifest::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.Layout = EExportLayout::FolderPerAsset;
Options.CollisionPolicy = EExportCollisionPolicy::Index;
TArray<FAssetUsageExporter::FWrittenFile> Written;
FAssetUsageExporter::FWrittenFile& Entry = Written.AddDefaulted_GetRef();
Entry.PackageName = FName(TEXT("/Game/SomeAsset_AssetUsageAuditTest"));
Entry.RelativePath = TEXT("SomeAsset/SomeAsset.uasset");
const AssetExportManifest::FResult Result =
AssetExportManifest::Write(ReadyRegistry(), Written, Options);
TestTrue(TEXT("successful"), Result.bSuccess);
const TSharedPtr<FJsonObject> Root = ReadManifest();
TestTrue(TEXT("parses as JSON"), Root.IsValid());
if (!Root.IsValid())
{
return;
}
TestEqual(TEXT("schema version"),
static_cast<int32>(Root->GetNumberField(TEXT("schemaVersion"))),
AssetExportManifest::SchemaVersion);
TestEqual(TEXT("records the layout"), Root->GetStringField(TEXT("layout")), FString(TEXT("FolderPerAsset")));
TestEqual(TEXT("records the naming policy"), Root->GetStringField(TEXT("collisionPolicy")), FString(TEXT("Index")));
TestTrue(TEXT("has an assets array"), Root->HasField(TEXT("assets")));
});
It("records the path that was actually written, not the intended one", [this]()
{
// The case the manifest exists for: the collision policy renamed the file, so a manifest
// built from the package name alone would point at a file that is not there.
MakeScratchDir(TEXT("ManifestRenamed"));
AssetExportManifest::FOptions Options;
Options.TargetDirectory = ScratchDir;
TArray<FAssetUsageExporter::FWrittenFile> Written;
FAssetUsageExporter::FWrittenFile& Entry = Written.AddDefaulted_GetRef();
Entry.PackageName = FName(TEXT("/Game/SM_Rock_AssetUsageAuditTest"));
Entry.RelativePath = TEXT("SM_Rock_AssetUsageAuditTest_2.uasset");
AssetExportManifest::Write(ReadyRegistry(), Written, Options);
const TSharedPtr<FJsonObject> Root = ReadManifest();
TestTrue(TEXT("parses"), Root.IsValid());
if (!Root.IsValid())
{
return;
}
const TArray<TSharedPtr<FJsonValue>>* Assets = nullptr;
TestTrue(TEXT("has assets"), Root->TryGetArrayField(TEXT("assets"), Assets));
if (!Assets || Assets->Num() != 1)
{
AddError(TEXT("expected exactly one asset entry"));
return;
}
const TSharedPtr<FJsonObject> First = (*Assets)[0]->AsObject();
TestEqual(TEXT("original package name"), First->GetStringField(TEXT("package")),
FString(TEXT("/Game/SM_Rock_AssetUsageAuditTest")));
TestEqual(TEXT("the renamed file"), First->GetStringField(TEXT("file")),
FString(TEXT("SM_Rock_AssetUsageAuditTest_2.uasset")));
});
It("separates the assets that were ticked from those pulled in", [this]()
{
// "Restore what I exported" and "restore everything in this folder" are different
// requests, and only the manifest can tell them apart after the fact.
MakeScratchDir(TEXT("ManifestSeeds"));
const FName SeedPackage(TEXT("/Game/Seed_AssetUsageAuditTest"));
const FName DependencyPackage(TEXT("/Game/Dependency_AssetUsageAuditTest"));
AssetExportManifest::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.SeedPackages = { SeedPackage };
TArray<FAssetUsageExporter::FWrittenFile> Written;
FAssetUsageExporter::FWrittenFile& SeedEntry = Written.AddDefaulted_GetRef();
SeedEntry.PackageName = SeedPackage;
SeedEntry.RelativePath = TEXT("Seed.uasset");
FAssetUsageExporter::FWrittenFile& DependencyEntry = Written.AddDefaulted_GetRef();
DependencyEntry.PackageName = DependencyPackage;
DependencyEntry.RelativePath = TEXT("Dependency.uasset");
AssetExportManifest::Write(ReadyRegistry(), Written, Options);
const TSharedPtr<FJsonObject> Root = ReadManifest();
if (!Root.IsValid())
{
AddError(TEXT("manifest did not parse"));
return;
}
const TArray<TSharedPtr<FJsonValue>>* Assets = nullptr;
Root->TryGetArrayField(TEXT("assets"), Assets);
if (!Assets || Assets->Num() != 2)
{
AddError(TEXT("expected two asset entries"));
return;
}
int32 SeedCount = 0;
for (const TSharedPtr<FJsonValue>& Value : *Assets)
{
if (Value->AsObject()->GetBoolField(TEXT("seed")))
{
++SeedCount;
}
}
TestEqual(TEXT("exactly one entry is a seed"), SeedCount, 1);
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS