using System.Linq; using Newtonsoft.Json.Linq; using NUnit.Framework; using Unity.Pipeline.Commands; using Unity.Pipeline.Editor; using Unity.Pipeline.Editor.Commands.ProjectAudit; namespace Unity.Pipeline.Tests.Editor.ProjectAudit { /// /// Unit tests for the audit / audit_status commands. These cover discovery/schema /// and the two pieces of pure logic — CSV escaping and category-name validation — without running /// a real Project Auditor scan (slow, and Project Auditor may be absent from the test project; /// the reflection-driven scan is exercised live against an Editor that has it installed). /// class ProjectAuditCommandsTests { [SetUp] public void SetUp() => CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery()); [Test] public void Audit_And_AuditStatus_AreDiscovered_WithExpectedSchema() { var commands = CommandRegistry.DiscoverCommands().ToList(); var audit = commands.FirstOrDefault(c => c.Name == "audit"); Assert.IsNotNull(audit, "Should discover the audit command"); Assert.IsFalse(audit.MainThreadRequired, "audit must not require the main thread (it only enqueues; the scan runs on a later tick)"); CollectionAssert.AreEquivalent(new[] { "categories", "output" }, audit.Parameters.Select(p => p.Name).ToList()); Assert.IsTrue(audit.Parameters.All(p => !p.Required), "audit parameters should be optional"); var status = commands.FirstOrDefault(c => c.Name == "audit_status"); Assert.IsNotNull(status, "Should discover the audit_status command"); Assert.IsFalse(status.MainThreadRequired, "audit_status must be off-main-thread so it answers while a scan holds the main thread"); Assert.AreEqual(0, status.Parameters.Count, "audit_status takes no parameters"); } [Test] public void AuditStatus_ReturnsValidJsonWithStatusField() { // Returns idle/scanning/completed when Project Auditor is present, or unavailable when not; // in every case a 'status' field must be present. var json = JObject.Parse(ProjectAuditCommands.AuditStatus()); Assert.IsNotNull(json["status"], "audit_status should always include a 'status' field"); } [Test] public void EscapeCsv_LeavesPlainFieldsUnquoted() { Assert.AreEqual("Code", ProjectAuditCommands.EscapeCsv("Code")); Assert.AreEqual("", ProjectAuditCommands.EscapeCsv(null)); } [Test] public void EscapeCsv_QuotesFieldsWithCommasQuotesOrNewlines() { Assert.AreEqual("\"a,b\"", ProjectAuditCommands.EscapeCsv("a,b")); Assert.AreEqual("\"line1\nline2\"", ProjectAuditCommands.EscapeCsv("line1\nline2")); Assert.AreEqual("\"has\r\ncrlf\"", ProjectAuditCommands.EscapeCsv("has\r\ncrlf")); } [Test] public void EscapeCsv_DoublesEmbeddedQuotes() { // Cache the result instead of calling "every frame". -> quoted, with the inner quotes doubled. Assert.AreEqual("\"say \"\"hi\"\"\"", ProjectAuditCommands.EscapeCsv("say \"hi\"")); } [Test] public void ValidateCategories_ReturnsNull_WhenNoneRequested() { var valid = new[] { "Code", "ProjectSetting", "Texture" }; Assert.IsNull(ProjectAuditCommands.ValidateCategories(new string[0], valid)); Assert.IsNull(ProjectAuditCommands.ValidateCategories(null, valid)); } [Test] public void ValidateCategories_ReturnsNull_WhenAllValid_CaseInsensitive() { var valid = new[] { "Code", "ProjectSetting", "Texture" }; Assert.IsNull(ProjectAuditCommands.ValidateCategories(new[] { "Code", "texture" }, valid)); } [Test] public void ValidateCategories_ReportsUnknown_AndListsValid() { var valid = new[] { "Code", "ProjectSetting", "Texture" }; var error = ProjectAuditCommands.ValidateCategories(new[] { "Code", "Bogus" }, valid); Assert.IsNotNull(error); // The unknown name is named before the "Valid categories:" list; the valid one is not. var unknownPart = error.Substring(0, error.IndexOf("Valid categories:", System.StringComparison.Ordinal)); StringAssert.Contains("Bogus", unknownPart); StringAssert.DoesNotContain("Code", unknownPart, "the valid category 'Code' should not be reported as unknown"); StringAssert.Contains("ProjectSetting", error, "the error should list the valid categories"); } /// Stand-in for Project Auditor's IssueCategory. enum FakeCategory { Code, Texture } /// /// Stand-in for the serialization wrapper the built-in editor module stores categories in /// (SerializableEnum<IssueCategory>): a struct constructed from the enum value. /// struct FakeWrappedCategory { public readonly FakeCategory Value; public FakeWrappedCategory(FakeCategory value) => Value = value; } [Test] public void BuildCategoryArray_BuildsBareEnumArray_CaseInsensitively() { var array = ProjectAuditCommands.BuildCategoryArray( typeof(FakeCategory), null, typeof(FakeCategory), new[] { "texture", "Code" }); Assert.AreEqual(typeof(FakeCategory[]), array.GetType()); CollectionAssert.AreEqual(new[] { FakeCategory.Texture, FakeCategory.Code }, array); } [Test] public void BuildCategoryArray_WrapsElements_WhenFieldHoldsAWrapperType() { var wrapperCtor = typeof(FakeWrappedCategory).GetConstructor(new[] { typeof(FakeCategory) }); var array = ProjectAuditCommands.BuildCategoryArray( typeof(FakeWrappedCategory), wrapperCtor, typeof(FakeCategory), new[] { "Texture" }); // Assigning a bare-enum array into a wrapper-typed field throws at runtime, so the element // type must be the wrapper itself. Assert.AreEqual(typeof(FakeWrappedCategory[]), array.GetType()); Assert.AreEqual(FakeCategory.Texture, ((FakeWrappedCategory[])array)[0].Value); } } }