streamingle-unity-utilities/CameraAI~/Tests/Editor/AICameraTimelinePreviewImporterTests.cs
2026-08-03 04:42:44 +09:00

868 lines
34 KiB
C#

using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using NUnit.Framework;
using Unity.Cinemachine;
using UnityEditor;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
using Object = UnityEngine.Object;
namespace Streamingle.Editor.Tests
{
public sealed class AICameraTimelinePreviewImporterTests
{
[Test]
public void BrainResolutionUsesOnlySceneBrainWithoutCinemachineTrack()
{
var directorObject = new GameObject("Director");
var cameraObject = new GameObject("Render Camera");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
cameraObject.AddComponent<Camera>();
var brain = cameraObject.AddComponent<CinemachineBrain>();
Assert.That(
AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { brain }),
Is.SameAs(brain));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraObject);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionPrefersUniqueMainCamera()
{
var directorObject = new GameObject("Director");
var mainCameraObject = new GameObject("Main Camera");
var secondaryCameraObject = new GameObject("Secondary Camera");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
mainCameraObject.tag = "MainCamera";
mainCameraObject.AddComponent<Camera>();
var mainBrain = mainCameraObject.AddComponent<CinemachineBrain>();
secondaryCameraObject.AddComponent<Camera>();
var secondaryBrain =
secondaryCameraObject.AddComponent<CinemachineBrain>();
Assert.That(
AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { secondaryBrain, mainBrain }),
Is.SameAs(mainBrain));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(mainCameraObject);
Object.DestroyImmediate(secondaryCameraObject);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionRejectsAmbiguousSceneBrains()
{
var directorObject = new GameObject("Director");
var firstCameraObject = new GameObject("First Camera");
var secondCameraObject = new GameObject("Second Camera");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
firstCameraObject.AddComponent<Camera>();
var firstBrain = firstCameraObject.AddComponent<CinemachineBrain>();
secondCameraObject.AddComponent<Camera>();
var secondBrain = secondCameraObject.AddComponent<CinemachineBrain>();
Assert.That(
() => AICameraTimelinePreviewImporter
.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { firstBrain, secondBrain }),
Throws.TypeOf<InvalidOperationException>()
.With.Message.Contains("Multiple enabled CinemachineBrain"));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(firstCameraObject);
Object.DestroyImmediate(secondCameraObject);
Object.DestroyImmediate(timeline);
}
}
[TestCase(false)]
[TestCase(true)]
public void GeneratedDirectorySupportsLegacyAndDeclaredHashes(
bool includeHashes)
{
var directory = CreateGeneratedDirectory(includeHashes);
try
{
Assert.That(
AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Does.Contain("1 frames, 1 shots at 60 FPS"));
}
finally
{
Directory.Delete(directory, true);
}
}
[TestCase("generated_camera_world.f32", "worldCamera")]
[TestCase("time.f64", "time")]
[TestCase("shots.json", "shots")]
public void GeneratedDirectoryRejectsTamperedDeclaredPayloadBeforeParsing(
string fileName,
string payloadName)
{
var directory = CreateGeneratedDirectory(true);
try
{
File.AppendAllText(
Path.Combine(directory, fileName),
"tampered",
new UTF8Encoding(false));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains(
$"{payloadName} payload SHA-256 mismatch"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void GeneratedDirectoryRejectsAbsolutePayloadPath()
{
var directory = CreateGeneratedDirectory(true);
try
{
ReplaceMetadataPayloadPath(
directory,
"worldCameraFile",
Path.Combine(directory, "generated_camera_world.f32"));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains("must be relative"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void GeneratedDirectoryRejectsParentTraversalEvenWhenHashMatches()
{
var directory = CreateGeneratedDirectory(true);
var outsidePath = Path.Combine(
Path.GetDirectoryName(directory) ?? Path.GetTempPath(),
$"cw_ai_outside_{Guid.NewGuid():N}.f32");
try
{
File.Copy(
Path.Combine(directory, "generated_camera_world.f32"),
outsidePath);
ReplaceMetadataPayloadPath(
directory,
"worldCameraFile",
"../" + Path.GetFileName(outsidePath));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains("invalid segment"));
}
finally
{
Directory.Delete(directory, true);
if (File.Exists(outsidePath))
{
File.Delete(outsidePath);
}
}
}
[Test]
public void GeneratedDirectoryRejectsExplicitNullHashObject()
{
var directory = CreateGeneratedDirectory(false);
try
{
var metadataPath = Path.Combine(directory, "metadata.json");
var json = File.ReadAllText(metadataPath);
File.WriteAllText(
metadataPath,
json.Substring(0, json.Length - 1) +
",\"outputSha256\":null}",
new UTF8Encoding(false));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains(
"metadata.outputSha256 must be a JSON object"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void GeneratedDirectoryRejectsIncompleteDeclaredHashes()
{
var directory = CreateGeneratedDirectory(true);
try
{
var metadataPath = Path.Combine(directory, "metadata.json");
var timePath = Path.Combine(directory, "time.f64");
var json = File.ReadAllText(metadataPath).Replace(
$",\"time\":\"{ComputeSha256(timePath)}\"",
string.Empty);
File.WriteAllText(
metadataPath,
json,
new UTF8Encoding(false));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains(
"metadata.outputSha256.time is missing"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void PreviewProvenancePersistsHiddenRelativeAndAbsoluteFallback()
{
var cwAiRoot = Path.Combine(
Path.GetTempPath(),
$"cw_ai_provenance_root_{Guid.NewGuid():N}");
var generatedDirectory = Path.Combine(
cwAiRoot,
"examples",
"UnityGenerated",
"camera_test");
Directory.CreateDirectory(generatedDirectory);
File.WriteAllText(
Path.Combine(generatedDirectory, "metadata.json"),
"{\"generation\":1}",
new UTF8Encoding(false));
var timelinePath =
$"Assets/__CWAI_Provenance_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
var provenance = AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
generatedDirectory,
cwAiRoot,
false);
AssetDatabase.SaveAssets();
AssetDatabase.ImportAsset(
timelinePath,
ImportAssetOptions.ForceSynchronousImport);
var reloaded = AssetDatabase.LoadAssetAtPath<TimelineAsset>(
timelinePath);
Assert.That(reloaded, Is.Not.Null);
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
reloaded,
out var info),
Is.True);
Assert.That(
info.CwAiRelativeGeneratedFolder,
Is.EqualTo("examples/UnityGenerated/camera_test"));
Assert.That(
info.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(generatedDirectory)));
Assert.That(
info.MetadataSha256,
Is.EqualTo(ComputeSha256(
Path.Combine(generatedDirectory, "metadata.json"))));
Assert.That(
provenance.hideFlags,
Is.EqualTo(
HideFlags.HideInHierarchy |
HideFlags.HideInInspector));
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(cwAiRoot, true);
}
}
[Test]
public void PreviewProvenanceUndoRedoRestoresMaterializedBase()
{
var cwAiRoot = Path.Combine(
Path.GetTempPath(),
$"cw_ai_provenance_undo_{Guid.NewGuid():N}");
var first = Path.Combine(cwAiRoot, "outputs", "first");
var second = Path.Combine(cwAiRoot, "outputs", "second");
Directory.CreateDirectory(first);
Directory.CreateDirectory(second);
File.WriteAllText(
Path.Combine(first, "metadata.json"),
"{\"generation\":1}",
new UTF8Encoding(false));
File.WriteAllText(
Path.Combine(second, "metadata.json"),
"{\"generation\":2}",
new UTF8Encoding(false));
var timelinePath =
$"Assets/__CWAI_ProvenanceUndo_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
first,
cwAiRoot,
false);
AssetDatabase.SaveAssets();
Undo.IncrementCurrentGroup();
var undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Test AI Camera Provenance Undo");
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
second,
cwAiRoot,
true);
Undo.CollapseUndoOperations(undoGroup);
Undo.FlushUndoRecordObjects();
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var changed),
Is.True);
Assert.That(
changed.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(second)));
Undo.PerformUndo();
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var undone),
Is.True);
Assert.That(
undone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(first)));
Undo.PerformRedo();
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var redone),
Is.True);
Assert.That(
redone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(second)));
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(cwAiRoot, true);
}
}
[Test]
public void FinalTimelineCleanupRemovesPreviewOnlyProvenanceSubasset()
{
var generatedDirectory = Path.Combine(
Path.GetTempPath(),
$"cw_ai_provenance_final_{Guid.NewGuid():N}");
Directory.CreateDirectory(generatedDirectory);
File.WriteAllText(
Path.Combine(generatedDirectory, "metadata.json"),
"{\"generation\":1}",
new UTF8Encoding(false));
var timelinePath =
$"Assets/__CWAI_ProvenanceFinal_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
generatedDirectory,
string.Empty,
false);
AssetDatabase.SaveAssets();
AICameraTimelinePreviewImporter
.RemovePreviewGenerationProvenanceForTests(timeline);
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out _),
Is.False);
Assert.That(
AssetDatabase.LoadAllAssetsAtPath(timelinePath)
.OfType<AICameraPreviewGenerationProvenance>(),
Is.Empty);
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(generatedDirectory, true);
}
}
[Test]
public void ShotDisplayNameAndProvenanceRestoreInOneUndoGroup()
{
var root = Path.Combine(
Path.GetTempPath(),
$"cw_ai_track_undo_{Guid.NewGuid():N}");
var first = Path.Combine(root, "first");
var second = Path.Combine(root, "second");
Directory.CreateDirectory(first);
Directory.CreateDirectory(second);
File.WriteAllText(
Path.Combine(first, "metadata.json"),
"{\"generation\":1}");
File.WriteAllText(
Path.Combine(second, "metadata.json"),
"{\"generation\":2}");
var timelinePath =
$"Assets/__CWAI_TrackUndo_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
var track = timeline.CreateTrack<AnimationTrack>(null, "Shot");
var shot = track.CreateDefaultClip();
shot.displayName = "Before";
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
first,
root,
false);
EditorUtility.SetDirty(track);
AssetDatabase.SaveAssets();
Undo.IncrementCurrentGroup();
var undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Test Shot and Provenance Undo");
Undo.RegisterCompleteObjectUndo(track, "Test Shot and Provenance Undo");
shot.displayName = "After";
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
second,
root,
true);
Undo.CollapseUndoOperations(undoGroup);
Undo.FlushUndoRecordObjects();
Undo.PerformUndo();
Assert.That(shot.displayName, Is.EqualTo("Before"));
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var undone),
Is.True);
Assert.That(
undone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(first)));
Undo.PerformRedo();
Assert.That(shot.displayName, Is.EqualTo("After"));
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var redone),
Is.True);
Assert.That(
redone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(second)));
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(root, true);
}
}
[Test]
public void GeneratedMotionUsesOneAnimatorAndTrackPerCamera()
{
var directorObject = new GameObject("Director");
var cameraRoot = new GameObject("AI Camera Root");
var firstCamera = new GameObject("Shot_000");
var secondCamera = new GameObject("Shot_001");
firstCamera.transform.SetParent(cameraRoot.transform, false);
secondCamera.transform.SetParent(cameraRoot.transform, false);
firstCamera.transform.SetLocalPositionAndRotation(
new Vector3(1.25f, 1.8f, -3.5f),
Quaternion.Euler(8f, 135f, 0f));
secondCamera.transform.SetLocalPositionAndRotation(
new Vector3(-2f, 0.9f, 6f),
Quaternion.Euler(-4f, 20f, 2f));
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
var firstClip = new AnimationClip();
var secondClip = new AnimationClip();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
var group = timeline.CreateTrack<GroupTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
var firstTrack = AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
firstCamera,
firstClip,
0,
firstCamera.name,
0,
1);
var secondTrack = AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
secondCamera,
secondClip,
1,
secondCamera.name,
1,
2);
var firstAnimator = firstCamera.GetComponent<Animator>();
var secondAnimator = secondCamera.GetComponent<Animator>();
Assert.That(cameraRoot.GetComponent<Animator>(), Is.Null);
Assert.That(firstAnimator, Is.Not.Null);
Assert.That(secondAnimator, Is.Not.Null);
Assert.That(firstAnimator, Is.Not.SameAs(secondAnimator));
Assert.That(
firstAnimator.cullingMode,
Is.EqualTo(AnimatorCullingMode.AlwaysAnimate));
Assert.That(
secondAnimator.cullingMode,
Is.EqualTo(AnimatorCullingMode.AlwaysAnimate));
Assert.That(director.GetGenericBinding(firstTrack), Is.SameAs(firstAnimator));
Assert.That(director.GetGenericBinding(secondTrack), Is.SameAs(secondAnimator));
Assert.That(firstTrack.parent, Is.SameAs(group));
Assert.That(secondTrack.parent, Is.SameAs(group));
Assert.That(
firstTrack.position,
Is.EqualTo(firstCamera.transform.localPosition));
Assert.That(
secondTrack.position,
Is.EqualTo(secondCamera.transform.localPosition));
Assert.That(
Quaternion.Angle(
firstTrack.rotation,
firstCamera.transform.localRotation),
Is.LessThan(0.001f));
Assert.That(
Quaternion.Angle(
secondTrack.rotation,
secondCamera.transform.localRotation),
Is.LessThan(0.001f));
Assert.That(
System.Linq.Enumerable.Count(firstTrack.GetClips()),
Is.EqualTo(1));
Assert.That(
System.Linq.Enumerable.Count(secondTrack.GetClips()),
Is.EqualTo(1));
StringAssert.StartsWith(
AICameraTimelinePreviewImporter.GeneratedAnimationTrackPrefix,
firstTrack.name);
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraRoot);
Object.DestroyImmediate(firstClip);
Object.DestroyImmediate(secondClip);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void ShotSelectionResolvesTimelineClipAssetAndCamera()
{
var directorObject = new GameObject("Director");
var cameraRoot = new GameObject("AI Camera Root");
var firstCamera = new GameObject("Shot_000");
var secondCamera = new GameObject("Shot_001");
firstCamera.transform.SetParent(cameraRoot.transform, false);
secondCamera.transform.SetParent(cameraRoot.transform, false);
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
var firstClip = new AnimationClip();
var secondClip = new AnimationClip();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
var group = timeline.CreateTrack<GroupTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
firstCamera,
firstClip,
0,
firstCamera.name,
0,
1);
var secondTrack = AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
secondCamera,
secondClip,
1,
secondCamera.name,
1,
2);
var selectedTimelineClip = System.Linq.Enumerable.Single(
secondTrack.GetClips());
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
selectedTimelineClip,
null,
null,
out var timelineIndex),
Is.True);
Assert.That(timelineIndex, Is.EqualTo(1));
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
null,
null,
secondClip,
out var assetIndex),
Is.True);
Assert.That(assetIndex, Is.EqualTo(1));
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
null,
firstCamera,
null,
out var cameraIndex),
Is.True);
Assert.That(cameraIndex, Is.EqualTo(0));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraRoot);
Object.DestroyImmediate(firstClip);
Object.DestroyImmediate(secondClip);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void LegacySharedAnimationTrackStillResolvesShots()
{
var cameraRoot = new GameObject("Legacy AI Camera Root");
var firstCamera = new GameObject("Shot_000");
var secondCamera = new GameObject("Shot_001");
firstCamera.transform.SetParent(cameraRoot.transform, false);
secondCamera.transform.SetParent(cameraRoot.transform, false);
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
var firstClip = new AnimationClip();
var secondClip = new AnimationClip();
try
{
var track = timeline.CreateTrack<AnimationTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
var firstTimelineClip = track.CreateClip(firstClip);
firstTimelineClip.start = 0;
firstTimelineClip.duration = 1;
var secondTimelineClip = track.CreateClip(secondClip);
secondTimelineClip.start = 1;
secondTimelineClip.duration = 1;
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
secondTimelineClip,
null,
null,
out var shotIndex),
Is.True);
Assert.That(shotIndex, Is.EqualTo(1));
}
finally
{
Object.DestroyImmediate(cameraRoot);
Object.DestroyImmediate(firstClip);
Object.DestroyImmediate(secondClip);
Object.DestroyImmediate(timeline);
}
}
private static string CreateGeneratedDirectory(bool includeHashes)
{
var directory = Path.Combine(
Path.GetTempPath(),
$"cw_ai_importer_test_{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
var cameraPath = Path.Combine(
directory,
"generated_camera_world.f32");
var timePath = Path.Combine(directory, "time.f64");
var shotsPath = Path.Combine(directory, "shots.json");
using (var writer = new BinaryWriter(File.Create(cameraPath)))
{
writer.Write(0f);
writer.Write(1.5f);
writer.Write(-3f);
writer.Write(0f);
writer.Write(0f);
writer.Write(0f);
writer.Write(1f);
writer.Write(60f);
writer.Write(0f);
}
using (var writer = new BinaryWriter(File.Create(timePath)))
{
writer.Write(0d);
}
File.WriteAllText(
shotsPath,
"{\"shots\":[{\"index\":0,\"cameraName\":\"Shot_000\"," +
"\"shotType\":\"medium\",\"startFrame\":0," +
"\"endFrameExclusive\":1}]}",
new UTF8Encoding(false));
var hashes = includeHashes
? ",\"outputSha256\":{" +
$"\"worldCamera\":\"{ComputeSha256(cameraPath)}\"," +
$"\"time\":\"{ComputeSha256(timePath)}\"," +
$"\"shots\":\"{ComputeSha256(shotsPath)}\"}}"
: string.Empty;
File.WriteAllText(
Path.Combine(directory, "metadata.json"),
"{\"songName\":\"Hash Validation Test\",\"sampleRate\":60," +
"\"frameCount\":1,\"duration\":0.016666666666666666," +
"\"worldCameraFile\":\"generated_camera_world.f32\"," +
"\"timeFile\":\"time.f64\",\"shotsFile\":\"shots.json\"," +
$"\"lookAtUsed\":false{hashes}}}",
new UTF8Encoding(false));
return directory;
}
private static void ReplaceMetadataPayloadPath(
string directory,
string propertyName,
string replacementPath)
{
var metadataPath = Path.Combine(directory, "metadata.json");
var json = File.ReadAllText(metadataPath);
var marker = $"\"{propertyName}\":\"";
var valueStart = json.IndexOf(marker, StringComparison.Ordinal);
Assert.That(valueStart, Is.GreaterThanOrEqualTo(0));
valueStart += marker.Length;
var valueEnd = json.IndexOf('"', valueStart);
Assert.That(valueEnd, Is.GreaterThan(valueStart));
var escapedReplacement = replacementPath
.Replace("\\", "\\\\")
.Replace("\"", "\\\"");
json = json.Substring(0, valueStart) +
escapedReplacement +
json.Substring(valueEnd);
File.WriteAllText(
metadataPath,
json,
new UTF8Encoding(false));
}
private static string ComputeSha256(string path)
{
using var algorithm = SHA256.Create();
return string.Concat(
algorithm.ComputeHash(File.ReadAllBytes(path))
.Select(value => value.ToString("x2")));
}
}
}