Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+139
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Runtime.Commands;
|
||||
using Unity.Pipeline.Telemetry;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Telemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// End-to-end tests for the eval telemetry loop (AUTHAPI-29): running an <c>eval</c> writes a
|
||||
/// telemetry record, and the <c>report_evals</c> command (<see cref="EvalTelemetryCommand"/>) reads
|
||||
/// it back into an aggregated report. Telemetry is redirected to a temp directory so the real
|
||||
/// project's Library/Pipeline is never touched. Recording is fire-and-forget (a background task),
|
||||
/// so every eval is followed by <see cref="EvalUsageTelemetry.WaitForPendingRecords"/> before
|
||||
/// asserting on the log.
|
||||
/// </summary>
|
||||
class EvalTelemetryCommandTests
|
||||
{
|
||||
private string m_Dir;
|
||||
private bool m_SavedEnabled;
|
||||
private bool m_SavedStoreSource;
|
||||
private string m_SavedOverrideDir;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
m_Dir = Path.Combine(Path.GetTempPath(), "EvalTelemetryCmdTest_" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(m_Dir);
|
||||
|
||||
m_SavedEnabled = EvalUsageTelemetry.Enabled;
|
||||
m_SavedStoreSource = EvalUsageTelemetry.StoreSource;
|
||||
m_SavedOverrideDir = EvalUsageTelemetry.OverrideDirectory;
|
||||
|
||||
EvalUsageTelemetry.Enabled = true;
|
||||
EvalUsageTelemetry.StoreSource = false;
|
||||
EvalUsageTelemetry.OverrideDirectory = m_Dir;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// Drain any in-flight background record before the redirect target disappears.
|
||||
EvalUsageTelemetry.WaitForPendingRecords();
|
||||
|
||||
EvalUsageTelemetry.Enabled = m_SavedEnabled;
|
||||
EvalUsageTelemetry.StoreSource = m_SavedStoreSource;
|
||||
EvalUsageTelemetry.OverrideDirectory = m_SavedOverrideDir;
|
||||
|
||||
if (Directory.Exists(m_Dir))
|
||||
{
|
||||
try { Directory.Delete(m_Dir, true); }
|
||||
catch { /* ignore cleanup failures */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RunningEval_WritesTelemetryRecord_WithExpectedShape()
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateCode("return Application.unityVersion;");
|
||||
Assert.IsTrue(r.Success, r.Error);
|
||||
Assert.IsTrue(EvalUsageTelemetry.WaitForPendingRecords(), "Background telemetry record should flush promptly");
|
||||
|
||||
var records = EvalUsageTelemetry.ReadRecords(m_Dir);
|
||||
Assert.AreEqual(1, records.Count, "The eval should have produced exactly one telemetry record");
|
||||
|
||||
var record = records.Single();
|
||||
Assert.AreEqual("eval", record.Command);
|
||||
Assert.IsTrue(record.Success);
|
||||
Assert.AreEqual(EvalClassification.SingleExpression, record.Classification);
|
||||
Assert.Greater(record.ExecutionTimeMs, 0);
|
||||
CollectionAssert.Contains(record.Fingerprints, "Application.unityVersion");
|
||||
Assert.IsNull(record.Source, "Raw source must not be stored by default");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EvalFile_RecordsWithEvalFileCommandName()
|
||||
{
|
||||
var path = Path.Combine(m_Dir, "snippet.cs");
|
||||
File.WriteAllText(path, "return Application.platform.ToString();");
|
||||
|
||||
var r = CodeEvalCommand.EvaluateFile(path);
|
||||
Assert.IsTrue(r.Success, r.Error);
|
||||
Assert.IsTrue(EvalUsageTelemetry.WaitForPendingRecords(), "Background telemetry record should flush promptly");
|
||||
|
||||
var record = EvalUsageTelemetry.ReadRecords(m_Dir).Single();
|
||||
Assert.AreEqual("eval_file", record.Command);
|
||||
CollectionAssert.Contains(record.Fingerprints, "Application.platform.ToString");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FailingEval_IsRecordedAsError()
|
||||
{
|
||||
var r = CodeEvalCommand.EvaluateCode("return 2 +;");
|
||||
Assert.IsFalse(r.Success);
|
||||
Assert.IsTrue(EvalUsageTelemetry.WaitForPendingRecords(), "Background telemetry record should flush promptly");
|
||||
|
||||
var record = EvalUsageTelemetry.ReadRecords(m_Dir).Single();
|
||||
Assert.IsFalse(record.Success);
|
||||
Assert.AreEqual("Compilation Failed", record.Error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReportEvals_AggregatesRecordedEvals()
|
||||
{
|
||||
CodeEvalCommand.EvaluateCode("return Application.unityVersion;"); // one-liner, success
|
||||
CodeEvalCommand.EvaluateCode("Debug.Log(\"x\"); return 1;"); // statements, success
|
||||
CodeEvalCommand.EvaluateCode("return 2 +;"); // error
|
||||
Assert.IsTrue(EvalUsageTelemetry.WaitForPendingRecords(), "Background telemetry records should flush promptly");
|
||||
|
||||
var report = EvalTelemetryCommand.ReportEvals();
|
||||
|
||||
Assert.AreEqual(3, report.TotalCount);
|
||||
Assert.AreEqual(1, report.ErrorCount);
|
||||
Assert.AreEqual(2, report.SuccessCount);
|
||||
Assert.AreEqual(1, report.StatementsCount);
|
||||
Assert.AreEqual(2, report.SingleExpressionCount);
|
||||
CollectionAssert.Contains(report.Fingerprints.Select(f => f.Member).ToList(), "Application.unityVersion");
|
||||
Assert.IsNotNull(report.CoverageSuggestions);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReportEvals_NegativeTop_ClampsToDefault()
|
||||
{
|
||||
CodeEvalCommand.EvaluateCode("return Application.unityVersion;");
|
||||
CodeEvalCommand.EvaluateCode("AssetDatabase.Refresh();");
|
||||
Assert.IsTrue(EvalUsageTelemetry.WaitForPendingRecords(), "Background telemetry records should flush promptly");
|
||||
|
||||
var report = EvalTelemetryCommand.ReportEvals(top: -5);
|
||||
|
||||
// A negative cap falls back to the default (50) — it must neither empty the ranked lists
|
||||
// nor be treated as "unlimited".
|
||||
Assert.AreEqual(2, report.TotalCount);
|
||||
Assert.IsNotEmpty(report.Fingerprints);
|
||||
Assert.AreEqual(report.DistinctFingerprints, report.Fingerprints.Count,
|
||||
"Well under the default cap, nothing is truncated");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 09227f37ecdd4229a9226ea42fa527b3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Telemetry;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Telemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for <see cref="EvalUsageFingerprinter"/>: the syntax-tree fingerprint extractor and the
|
||||
/// one-liner vs multi-statement classifier (AUTHAPI-29). Pure string in / string out, so they run
|
||||
/// hermetically with no file or Editor state.
|
||||
/// </summary>
|
||||
class EvalUsageFingerprinterTests
|
||||
{
|
||||
private static List<string> Fingerprints(string code) =>
|
||||
EvalUsageFingerprinter.ExtractFingerprints(code).ToList();
|
||||
|
||||
[TestCase("AssetDatabase.Refresh();", "AssetDatabase.Refresh",
|
||||
TestName = "StaticMethodCall_ProducesMemberPath")]
|
||||
[TestCase("TestBot.Enabled = true;", "TestBot.Enabled",
|
||||
TestName = "StaticPropertySet_ProducesLeftHandMemberPath")]
|
||||
[TestCase("Object.FindFirstObjectByType<Player>();", "Object.FindFirstObjectByType<T>",
|
||||
TestName = "GenericMethod_NormalizesTypeArgumentToPlaceholder")]
|
||||
[TestCase("PlayerSettings.SetScriptingBackend(BuildTargetGroup.Standalone, ScriptingImplementation.IL2CPP);", "PlayerSettings.SetScriptingBackend",
|
||||
TestName = "PlayerSettingsSetter_IsCapturedAsMethodCall")]
|
||||
public void Fingerprint_ProducesExpectedMemberPath(string code, string expected)
|
||||
{
|
||||
CollectionAssert.Contains(Fingerprints(code), expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InstanceChain_ProducesFullChainPath()
|
||||
{
|
||||
var fps = Fingerprints("return MatchManager.Instance.State;");
|
||||
CollectionAssert.Contains(fps, "MatchManager.Instance.State");
|
||||
// The full outermost chain is captured once; the inner "MatchManager.Instance" prefix is
|
||||
// not double-counted as its own fingerprint.
|
||||
CollectionAssert.DoesNotContain(fps, "MatchManager.Instance");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenericMethod_DifferentTypeArgs_ShareOneFingerprint()
|
||||
{
|
||||
// Normalising <Foo>/<Bar> to <T> is what lets the report rank a generic API as one line.
|
||||
Assert.AreEqual(
|
||||
Fingerprints("Object.FindFirstObjectByType<Player>();").Single(f => f.StartsWith("Object.FindFirstObjectByType")),
|
||||
Fingerprints("Object.FindFirstObjectByType<Enemy>();").Single(f => f.StartsWith("Object.FindFirstObjectByType")));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MultiStatementBody_ProducesMultipleFingerprints()
|
||||
{
|
||||
var fps = Fingerprints("Debug.Log(\"x\"); AssetDatabase.Refresh(); return MatchManager.Instance.State;");
|
||||
CollectionAssert.Contains(fps, "Debug.Log");
|
||||
CollectionAssert.Contains(fps, "AssetDatabase.Refresh");
|
||||
CollectionAssert.Contains(fps, "MatchManager.Instance.State");
|
||||
Assert.GreaterOrEqual(fps.Count, 3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ArgumentReads_AreCapturedAsTheirOwnSites()
|
||||
{
|
||||
var fps = Fingerprints("Debug.Log(MatchManager.Instance.State);");
|
||||
CollectionAssert.Contains(fps, "Debug.Log");
|
||||
CollectionAssert.Contains(fps, "MatchManager.Instance.State");
|
||||
}
|
||||
|
||||
// ---- Privacy: strict whitelist rendering (no raw source can leak into a fingerprint) ----
|
||||
|
||||
[TestCase("return \"secret-key\".Length;", ".Length", "secret-key",
|
||||
TestName = "StringLiteralReceiver_RendersAsPlaceholder_LeaksNoLiteral")]
|
||||
[TestCase("return (1 + 2).ToString();", ".ToString", "1 + 2",
|
||||
TestName = "ArithmeticReceiver_RendersAsPlaceholder")]
|
||||
public void NonWhitelistedReceiver_RendersAsPlaceholder(string code, string memberSuffix, string leaked)
|
||||
{
|
||||
var fps = Fingerprints(code);
|
||||
CollectionAssert.Contains(fps, EvalUsageFingerprinter.Placeholder + memberSuffix);
|
||||
AssertNoSourceLeak(fps, leaked);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InterpolatedStringReceiver_RendersAsPlaceholder_LeaksNoFragment()
|
||||
{
|
||||
var fps = Fingerprints("$\"{a}b\".Trim();");
|
||||
CollectionAssert.AreEquivalent(new[] { EvalUsageFingerprinter.Placeholder + ".Trim" }, fps);
|
||||
AssertNoSourceLeak(fps, "{a}b");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ObjectCreationChain_RendersAsNewTypeName_LeaksNoConstructorArgument()
|
||||
{
|
||||
var fps = Fingerprints("new GameObject(\"Name\").AddComponent<Rigidbody>();");
|
||||
CollectionAssert.AreEquivalent(new[] { "new GameObject.AddComponent<T>" }, fps);
|
||||
AssertNoSourceLeak(fps, "Name");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CastReceiver_UnwrapsToInnerExpression()
|
||||
{
|
||||
var fps = Fingerprints("((Foo)o).Bar = 1;");
|
||||
CollectionAssert.Contains(fps, "o.Bar");
|
||||
Assert.IsFalse(fps.Any(f => f.Contains("Foo")), $"The cast's type name must be unwrapped away: [{string.Join(", ", fps)}]");
|
||||
AssertNoSourceLeak(fps);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No fingerprint may carry raw source fragments: literal quoting/interpolation characters are
|
||||
/// banned outright, and any caller-supplied literal text must be absent. Identifiers, member
|
||||
/// names, and the structural tokens the renderer itself emits (".", "()", "[]", "<T>",
|
||||
/// "new ", "<expr>") are the only permitted content.
|
||||
/// </summary>
|
||||
private static void AssertNoSourceLeak(List<string> fps, params string[] literalFragments)
|
||||
{
|
||||
foreach (var fp in fps)
|
||||
{
|
||||
StringAssert.DoesNotContain("\"", fp, "Fingerprints must never contain string-literal quotes");
|
||||
StringAssert.DoesNotContain("{", fp, "Fingerprints must never contain interpolation braces");
|
||||
foreach (var fragment in literalFragments)
|
||||
StringAssert.DoesNotContain(fragment, fp, "Fingerprints must never contain literal/source fragments");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Review regression: a call/parenthesized receiver was previously dropped entirely
|
||||
// (IsMemberOrName rejected it, so Add() was never reached — not even the placeholder) ----
|
||||
|
||||
[TestCase("GetHandler()();", "GetHandler()",
|
||||
TestName = "InvocationCallee_RendersAsCalleeInvocation")]
|
||||
// Not "(A.B)();": a parenthesized type-looking name directly followed by '(' is a cast
|
||||
// grammar ambiguity Roslyn resolves in favor of a (here invalid) cast, never reaching
|
||||
// InvocationExpressionSyntax at all. An indexer receiver isn't type-syntax, so it stays
|
||||
// unambiguously a parenthesized invocation callee — and still exercises the cast-unwrap
|
||||
// rendering path via a realistic delegate-cast-and-invoke shape.
|
||||
[TestCase("(handlers[0])();", "handlers[]",
|
||||
TestName = "ParenthesizedElementAccessCallee_UnwrapsToInnerExpression")]
|
||||
[TestCase("((Action)handler)();", "handler",
|
||||
TestName = "ParenthesizedCastCallee_UnwrapsToInnerExpression")]
|
||||
public void CallOrParenthesizedCallee_OnInvocation_ProducesFingerprint(string code, string expected)
|
||||
{
|
||||
CollectionAssert.Contains(Fingerprints(code), expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CallReceiver_OnElementAccess_ProducesFingerprint()
|
||||
{
|
||||
var fps = Fingerprints("GetList()[0];");
|
||||
CollectionAssert.Contains(fps, "GetList()[]");
|
||||
}
|
||||
|
||||
// ---- Bounds: fingerprint cap and depth guard ----
|
||||
|
||||
[Test]
|
||||
public void ManyAccessSites_AreCappedAtMaxFingerprints_KeepingTreeOrder()
|
||||
{
|
||||
// 40 access sites (> MaxFingerprints = 32). Truncation keeps the FIRST N in tree (source)
|
||||
// order — not the most frequent — so the earliest sites survive and the tail is dropped.
|
||||
var body = string.Concat(Enumerable.Range(0, 40).Select(i => $"Type{i}.Member{i}();\n"));
|
||||
|
||||
var fps = Fingerprints(body);
|
||||
|
||||
Assert.AreEqual(EvalUsageFingerprinter.MaxFingerprints, fps.Count);
|
||||
Assert.AreEqual("Type0.Member0", fps.First(), "First-in-source access site survives truncation");
|
||||
CollectionAssert.DoesNotContain(fps, "Type39.Member39");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PathologicallyDeepNesting_IsAbandonedWithoutThrowing()
|
||||
{
|
||||
// 100 nested invocations-in-arguments — several syntax levels per nesting, far past the
|
||||
// walker's 64-level depth guard. The walk must terminate cleanly (best-effort telemetry),
|
||||
// capturing the shallow sites and abandoning the deep ones.
|
||||
var code = "A99.M()";
|
||||
for (var i = 98; i >= 0; i--)
|
||||
code = $"A{i}.M({code})";
|
||||
code += ";";
|
||||
|
||||
List<string> fps = null;
|
||||
Assert.DoesNotThrow(() => fps = Fingerprints(code));
|
||||
CollectionAssert.Contains(fps, "A0.M");
|
||||
CollectionAssert.DoesNotContain(fps, "A99.M");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PathologicallyDeepChainBelowHead_RendersWithoutThrowing()
|
||||
{
|
||||
// A deeply parenthesised receiver hangs BELOW the chain head, so it is bounded by the
|
||||
// renderer's own depth cap (not the walker's): the tail degrades to the placeholder.
|
||||
var receiver = "x";
|
||||
for (var i = 0; i < 100; i++)
|
||||
receiver = "(" + receiver + ")";
|
||||
|
||||
List<string> fps = null;
|
||||
Assert.DoesNotThrow(() => fps = Fingerprints($"{receiver}.Foo();"));
|
||||
CollectionAssert.Contains(fps, EvalUsageFingerprinter.Placeholder + ".Foo");
|
||||
}
|
||||
|
||||
[TestCase("AssetDatabase.Refresh();", EvalClassification.SingleExpression,
|
||||
TestName = "Classify_SingleExpressionStatement_IsSingleExpression")]
|
||||
[TestCase("return MatchManager.Instance.State;", EvalClassification.SingleExpression,
|
||||
TestName = "Classify_SingleReturn_IsSingleExpression")]
|
||||
[TestCase("Debug.Log(\"x\"); return 1;", EvalClassification.Statements,
|
||||
TestName = "Classify_MultipleStatements_IsStatements")]
|
||||
// A single for/while statement is a statement body, not a one-liner (the polling-loop case).
|
||||
[TestCase("for (int i = 0; i < 10; i++) { Debug.Log(i); }", EvalClassification.Statements,
|
||||
TestName = "Classify_LonePollingLoop_IsStatements")]
|
||||
public void Classify_ProducesExpectedClassification(string code, string expected)
|
||||
{
|
||||
Assert.AreEqual(expected, EvalUsageFingerprinter.Classify(code));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ConditionalAccessChain_RendersWholeChain_NormalizedToDots()
|
||||
{
|
||||
// Review regression: the old rendering dropped the receiver of a ?. chain and produced
|
||||
// a colliding "State.Value" entry — the whole chain must rank as one normalized path.
|
||||
var fps = Fingerprints("return MatchManager.Instance?.State.Value;");
|
||||
CollectionAssert.Contains(fps, "MatchManager.Instance.State.Value");
|
||||
CollectionAssert.DoesNotContain(fps, "State.Value");
|
||||
CollectionAssert.DoesNotContain(fps, "MatchManager.Instance");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ConditionalAccessCall_RanksWithItsUnconditionalForm()
|
||||
{
|
||||
// a?.B() must fingerprint identically to a.B() (callee convention, ?. normalized).
|
||||
var conditional = Fingerprints("go?.GetComponent<Rigidbody>();");
|
||||
var plain = Fingerprints("go.GetComponent<Rigidbody>();");
|
||||
CollectionAssert.Contains(conditional, "go.GetComponent<T>");
|
||||
CollectionAssert.AreEquivalent(plain, conditional);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NestedConditionalAccess_NormalizesEveryHop()
|
||||
{
|
||||
var fps = Fingerprints("return a?.b?.c;");
|
||||
CollectionAssert.Contains(fps, "a.b.c");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ConditionalElementAccess_RanksWithItsUnconditionalForm()
|
||||
{
|
||||
// cache?[key] must fingerprint identically to cache[key] ("cache[]") — the
|
||||
// element-access sibling of the a?.b.c collision fixed earlier.
|
||||
var conditional = Fingerprints("return cache?[key];");
|
||||
var plain = Fingerprints("return cache[key];");
|
||||
CollectionAssert.Contains(conditional, "cache[]");
|
||||
CollectionAssert.AreEquivalent(plain, conditional);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ConditionalAccess_ReceiverCallArguments_AreNotWalked()
|
||||
{
|
||||
// Review regression: the argument-list sweep previously started from the whole
|
||||
// conditional node, including the RECEIVER before '?.' — so a call there (unlike the
|
||||
// unconditional form, which never walks its callee chain's arguments) got its nested
|
||||
// calls captured anyway. An equivalent eval body must not get a more complete
|
||||
// fingerprint set purely because it happened to use '?.'.
|
||||
var conditional = Fingerprints("Configure(Settings.Load())?.Apply();");
|
||||
var plain = Fingerprints("Configure(Settings.Load()).Apply();");
|
||||
CollectionAssert.AreEquivalent(plain, conditional);
|
||||
CollectionAssert.DoesNotContain(conditional, "Settings.Load");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ConditionalAccessBracketedArgument_CapturesNestedCall()
|
||||
{
|
||||
// Review regression: the argument sweep matched only ArgumentListSyntax, so an
|
||||
// indexer's BracketedArgumentListSyntax inside a '?.' chain hid a nested call
|
||||
// (SomeCall) from the walker entirely.
|
||||
var fps = Fingerprints("return a?.b[SomeCall()];");
|
||||
CollectionAssert.Contains(fps, "a.b[]");
|
||||
CollectionAssert.Contains(fps, "SomeCall");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Analyze_EmptyOrWhitespace_IsSafe()
|
||||
{
|
||||
var analysis = EvalUsageFingerprinter.Analyze(" ");
|
||||
Assert.AreEqual(EvalClassification.Statements, analysis.Classification);
|
||||
Assert.IsEmpty(analysis.Fingerprints);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a5f0606881f4458fa410b43c56b47d56
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor;
|
||||
using Unity.Pipeline.Telemetry;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Telemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for <see cref="EvalUsageAggregator"/>: ranked fingerprint frequency, one-liner vs
|
||||
/// multi-statement percentages, error rate, and the command-coverage cross-reference (AUTHAPI-29).
|
||||
/// Records are built in-memory and the available-command set / coverage map are injected, so the
|
||||
/// tests are hermetic and independent of the live catalog.
|
||||
/// </summary>
|
||||
class EvalUsageReportTests
|
||||
{
|
||||
private static EvalUsageRecord Rec(string classification, bool success, params string[] fingerprints) =>
|
||||
new EvalUsageRecord
|
||||
{
|
||||
Classification = classification,
|
||||
Success = success,
|
||||
Fingerprints = fingerprints.ToList()
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void Build_RanksFingerprintsByDescendingCount()
|
||||
{
|
||||
var records = new List<EvalUsageRecord>
|
||||
{
|
||||
Rec(EvalClassification.SingleExpression, true, "AssetDatabase.Refresh"),
|
||||
Rec(EvalClassification.SingleExpression, true, "AssetDatabase.Refresh"),
|
||||
Rec(EvalClassification.SingleExpression, true, "AssetDatabase.Refresh"),
|
||||
Rec(EvalClassification.SingleExpression, true, "MatchManager.Instance.State"),
|
||||
};
|
||||
|
||||
var report = EvalUsageAggregator.Build(records, new HashSet<string>());
|
||||
|
||||
Assert.AreEqual("AssetDatabase.Refresh", report.Fingerprints[0].Member);
|
||||
Assert.AreEqual(3, report.Fingerprints[0].Count);
|
||||
Assert.AreEqual("MatchManager.Instance.State", report.Fingerprints[1].Member);
|
||||
Assert.AreEqual(1, report.Fingerprints[1].Count);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_ComputesOneLinerPercentageAndErrorRate()
|
||||
{
|
||||
var records = new List<EvalUsageRecord>
|
||||
{
|
||||
Rec(EvalClassification.SingleExpression, true, "A.B"),
|
||||
Rec(EvalClassification.SingleExpression, true, "A.B"),
|
||||
Rec(EvalClassification.SingleExpression, false, "A.B"),
|
||||
Rec(EvalClassification.Statements, true, "C.D"),
|
||||
};
|
||||
|
||||
var report = EvalUsageAggregator.Build(records, new HashSet<string>());
|
||||
|
||||
Assert.AreEqual(4, report.TotalCount);
|
||||
Assert.AreEqual(3, report.SingleExpressionCount);
|
||||
Assert.AreEqual(1, report.StatementsCount);
|
||||
Assert.AreEqual(75.0, report.OneLinerPercentage, 1e-9);
|
||||
Assert.AreEqual(1, report.ErrorCount);
|
||||
Assert.AreEqual(0.25, report.ErrorRate, 1e-9);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_CoverageSuggestions_FlagsFingerprintCoveredByExistingCommand()
|
||||
{
|
||||
// AUTHAPI-29 AC: AssetDatabase.Refresh maps to a hypothetical refresh_assets; when that
|
||||
// command exists in the catalog, the pattern is flagged as already coverable.
|
||||
var records = new List<EvalUsageRecord>
|
||||
{
|
||||
Rec(EvalClassification.SingleExpression, true, "AssetDatabase.Refresh"),
|
||||
};
|
||||
var catalog = new HashSet<string> { "refresh_assets" };
|
||||
|
||||
var report = EvalUsageAggregator.Build(records, catalog);
|
||||
|
||||
var hit = report.CoverageSuggestions.Covered.SingleOrDefault(c => c.Member == "AssetDatabase.Refresh");
|
||||
Assert.IsNotNull(hit, "AssetDatabase.Refresh should be flagged as covered by refresh_assets");
|
||||
Assert.AreEqual("refresh_assets", hit.Command);
|
||||
Assert.IsFalse(report.CoverageSuggestions.TopUncovered.Any(g => g.Member == "AssetDatabase.Refresh"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_CoverageSuggestions_MappedButAbsentCommand_IsUncoveredWithSuggestion()
|
||||
{
|
||||
// Same fingerprint, but the mapped command does NOT exist yet -> concrete gap/proposal.
|
||||
var records = new List<EvalUsageRecord>
|
||||
{
|
||||
Rec(EvalClassification.SingleExpression, true, "AssetDatabase.Refresh"),
|
||||
};
|
||||
|
||||
var report = EvalUsageAggregator.Build(records, new HashSet<string>());
|
||||
|
||||
var gap = report.CoverageSuggestions.TopUncovered.SingleOrDefault(g => g.Member == "AssetDatabase.Refresh");
|
||||
Assert.IsNotNull(gap, "With no covering command, the pattern is an uncovered gap");
|
||||
Assert.AreEqual("refresh_assets", gap.SuggestedCommand, "The mapped-but-absent command is surfaced as a proposal");
|
||||
Assert.IsEmpty(report.CoverageSuggestions.Covered);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_CoverageSuggestions_UnmappedFingerprint_IsUncoveredWithoutSuggestion()
|
||||
{
|
||||
var records = new List<EvalUsageRecord>
|
||||
{
|
||||
Rec(EvalClassification.SingleExpression, true, "SomeType.SomeMember"),
|
||||
};
|
||||
|
||||
var report = EvalUsageAggregator.Build(records, new HashSet<string>());
|
||||
|
||||
var gap = report.CoverageSuggestions.TopUncovered.SingleOrDefault(g => g.Member == "SomeType.SomeMember");
|
||||
Assert.IsNotNull(gap);
|
||||
Assert.IsNull(gap.SuggestedCommand, "An unmapped pattern has no proposed command name");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_TopUncovered_RespectsLimit()
|
||||
{
|
||||
var records = new List<EvalUsageRecord>();
|
||||
for (var i = 0; i < 10; i++)
|
||||
records.Add(Rec(EvalClassification.SingleExpression, true, $"Type{i}.Member"));
|
||||
|
||||
var report = EvalUsageAggregator.Build(records, new HashSet<string>(), topUncoveredLimit: 3);
|
||||
|
||||
Assert.AreEqual(3, report.CoverageSuggestions.TopUncovered.Count);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_CapsRankedFingerprints_AndReportsDistinctTotal()
|
||||
{
|
||||
// An accumulated log can hold thousands of distinct fingerprints; the ranked list is
|
||||
// capped and distinctFingerprints preserves the uncapped total so truncation is visible.
|
||||
var records = new List<EvalUsageRecord>();
|
||||
for (var i = 0; i < 10; i++)
|
||||
records.Add(Rec(EvalClassification.SingleExpression, true, $"Type{i}.Member"));
|
||||
records.Add(Rec(EvalClassification.SingleExpression, true, "Type0.Member")); // Type0 leads with 2.
|
||||
|
||||
var report = EvalUsageAggregator.Build(records, new HashSet<string>(), fingerprintLimit: 3);
|
||||
|
||||
Assert.AreEqual(3, report.Fingerprints.Count, "Ranked list is capped at fingerprintLimit");
|
||||
Assert.AreEqual(10, report.DistinctFingerprints, "Uncapped distinct total stays visible");
|
||||
Assert.AreEqual("Type0.Member", report.Fingerprints[0].Member, "Cap keeps the TOP of the ranking");
|
||||
Assert.AreEqual(10, report.CoverageSuggestions.TopUncovered.Count,
|
||||
"Coverage is computed from the full ranking, not the capped fingerprint list");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_NegativeLimits_FallBackToDefaults()
|
||||
{
|
||||
// Accepted range is 0.. — negatives clamp to the defaults instead of meaning "unlimited"
|
||||
// (or worse, emptying the lists).
|
||||
var records = new List<EvalUsageRecord>();
|
||||
for (var i = 0; i < 5; i++)
|
||||
records.Add(Rec(EvalClassification.SingleExpression, true, $"Type{i}.Member"));
|
||||
|
||||
var report = EvalUsageAggregator.Build(
|
||||
records, new HashSet<string>(), topUncoveredLimit: -1, fingerprintLimit: -7);
|
||||
|
||||
Assert.AreEqual(5, report.Fingerprints.Count, "5 distinct < default cap of 50, so nothing is cut");
|
||||
Assert.AreEqual(5, report.DistinctFingerprints);
|
||||
Assert.AreEqual(5, report.CoverageSuggestions.TopUncovered.Count);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_EmptyRecords_ProducesZeroedReport()
|
||||
{
|
||||
var report = EvalUsageAggregator.Build(new List<EvalUsageRecord>(), new HashSet<string>());
|
||||
|
||||
Assert.AreEqual(0, report.TotalCount);
|
||||
Assert.AreEqual(0.0, report.ErrorRate);
|
||||
Assert.AreEqual(0.0, report.OneLinerPercentage);
|
||||
Assert.IsEmpty(report.Fingerprints);
|
||||
}
|
||||
[Test]
|
||||
public void CoverageMap_DoesNotClaimSaveAllCoversAssetDatabaseSaveAssets()
|
||||
{
|
||||
// Review regression: save_all saves open scenes (EditorSceneManager.SaveOpenScenes +
|
||||
// AssetDatabase.Refresh) and never calls AssetDatabase.SaveAssets — steering an agent
|
||||
// off that eval to save_all would silently stop persisting dirty non-scene assets.
|
||||
Assert.IsTrue(EvalCoverageMap.Default.TryGetValue("AssetDatabase.SaveAssets", out var mapped));
|
||||
Assert.AreNotEqual("save_all", mapped, "save_all is not a cover for AssetDatabase.SaveAssets");
|
||||
Assert.AreEqual("save_assets", mapped, "SaveAssets maps to a proposed gap command instead");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CoverageMap_HabitEntries_ResolveInTheLiveCatalog()
|
||||
{
|
||||
// The map's "already covered by command X" claims are only true while X exists: a
|
||||
// rename would silently reclassify the fingerprint as an uncovered gap with nothing
|
||||
// failing. Pin the habit entries against the live catalog so drift breaks a test.
|
||||
CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery());
|
||||
var available = new HashSet<string>(CommandRegistry.DiscoverCommands().Select(c => c.Name));
|
||||
foreach (var name in new[] { "set_player_settings", "get_player_settings" })
|
||||
Assert.IsTrue(available.Contains(name),
|
||||
$"coverage-map habit entry '{name}' no longer exists in the catalog — update EvalCoverageMap");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3b72dab231aa4bc2b9e318671d65a916
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Telemetry;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor.Telemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for <see cref="EvalUsageTelemetry"/>: the local JSONL store, the raw-source opt-in, and
|
||||
/// the size-cap rotation (AUTHAPI-29). All I/O goes to an isolated temp directory via
|
||||
/// <see cref="EvalUsageTelemetry.OverrideDirectory"/> / the directory seam, and the mutated statics
|
||||
/// are snapshotted and restored, so the real project telemetry file and global config are untouched.
|
||||
/// </summary>
|
||||
class EvalUsageTelemetryTests
|
||||
{
|
||||
private string m_Dir;
|
||||
private string m_JsonlPath;
|
||||
private string m_OldPath;
|
||||
|
||||
private bool m_SavedEnabled;
|
||||
private bool m_SavedStoreSource;
|
||||
private string m_SavedOverrideDir;
|
||||
private long m_SavedMaxBytes;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
m_Dir = Path.Combine(Path.GetTempPath(), "EvalUsageTelemetryTest_" + Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(m_Dir);
|
||||
m_JsonlPath = Path.Combine(m_Dir, "eval-usage.jsonl");
|
||||
m_OldPath = Path.Combine(m_Dir, "eval-usage.old.jsonl");
|
||||
|
||||
m_SavedEnabled = EvalUsageTelemetry.Enabled;
|
||||
m_SavedStoreSource = EvalUsageTelemetry.StoreSource;
|
||||
m_SavedOverrideDir = EvalUsageTelemetry.OverrideDirectory;
|
||||
m_SavedMaxBytes = EvalUsageTelemetry.MaxFileBytes;
|
||||
|
||||
EvalUsageTelemetry.Enabled = true;
|
||||
EvalUsageTelemetry.StoreSource = false;
|
||||
EvalUsageTelemetry.OverrideDirectory = m_Dir;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
EvalUsageTelemetry.Enabled = m_SavedEnabled;
|
||||
EvalUsageTelemetry.StoreSource = m_SavedStoreSource;
|
||||
EvalUsageTelemetry.OverrideDirectory = m_SavedOverrideDir;
|
||||
EvalUsageTelemetry.MaxFileBytes = m_SavedMaxBytes;
|
||||
|
||||
if (Directory.Exists(m_Dir))
|
||||
{
|
||||
try { Directory.Delete(m_Dir, true); }
|
||||
catch { /* ignore cleanup failures */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Record_WritesOneJsonlLine_WithExpectedFields()
|
||||
{
|
||||
EvalUsageTelemetry.Record("eval", "return MatchManager.Instance.State;", success: true, error: null, executionTimeMs: 12);
|
||||
|
||||
var lines = File.ReadAllLines(m_JsonlPath);
|
||||
Assert.AreEqual(1, lines.Length, "One eval should append exactly one JSONL line");
|
||||
|
||||
var obj = JObject.Parse(lines[0]);
|
||||
Assert.AreEqual("eval", (string)obj["command"]);
|
||||
Assert.IsTrue((bool)obj["success"]);
|
||||
Assert.AreEqual(EvalClassification.SingleExpression, (string)obj["classification"]);
|
||||
Assert.AreEqual(12, (long)obj["executionTimeMs"]);
|
||||
Assert.AreEqual("return MatchManager.Instance.State;".Length, (int)obj["payloadLength"]);
|
||||
Assert.AreEqual(1, (int)obj["lineCount"]);
|
||||
CollectionAssert.Contains(obj["fingerprints"].Select(t => (string)t).ToList(), "MatchManager.Instance.State");
|
||||
Assert.DoesNotThrow(() => DateTime.Parse((string)obj["time"],
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Record_ByDefault_DoesNotStoreRawSource()
|
||||
{
|
||||
EvalUsageTelemetry.StoreSource = false;
|
||||
EvalUsageTelemetry.Record("eval", "return SecretApi.Token;", success: true, error: null, executionTimeMs: 1);
|
||||
|
||||
var line = File.ReadAllLines(m_JsonlPath).Single();
|
||||
var obj = JObject.Parse(line);
|
||||
Assert.IsFalse(obj.ContainsKey("source"), "Raw source must be absent by default");
|
||||
Assert.IsFalse(line.Contains("SecretApi.Token;"), "Raw source string must not appear in the record");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Record_WithStoreSourceOptIn_StoresRawSource()
|
||||
{
|
||||
EvalUsageTelemetry.StoreSource = true;
|
||||
EvalUsageTelemetry.Record("eval", "return 1 + 1;", success: true, error: null, executionTimeMs: 1);
|
||||
|
||||
var obj = JObject.Parse(File.ReadAllLines(m_JsonlPath).Single());
|
||||
Assert.AreEqual("return 1 + 1;", (string)obj["source"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Record_WithStoreSourceOptIn_TruncatesOversizedSourceWithMarker()
|
||||
{
|
||||
EvalUsageTelemetry.StoreSource = true;
|
||||
var code = "return 1; // " + new string('x', EvalUsageTelemetry.MaxStoredSourceChars + 100);
|
||||
|
||||
EvalUsageTelemetry.Record("eval", code, success: true, error: null, executionTimeMs: 1);
|
||||
|
||||
var obj = JObject.Parse(File.ReadAllLines(m_JsonlPath).Single());
|
||||
var stored = (string)obj["source"];
|
||||
Assert.AreEqual(
|
||||
EvalUsageTelemetry.MaxStoredSourceChars + EvalUsageTelemetry.SourceTruncationMarker.Length,
|
||||
stored.Length,
|
||||
"Stored source is cut at the bound, plus the explicit truncation marker");
|
||||
StringAssert.EndsWith(EvalUsageTelemetry.SourceTruncationMarker, stored);
|
||||
Assert.AreEqual(code.Length, (int)obj["payloadLength"], "payloadLength still reports the full original length");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RecordInBackground_WritesTheSameRecord_OffTheCallingThread()
|
||||
{
|
||||
EvalUsageTelemetry.RecordInBackground("eval", "AssetDatabase.Refresh();", success: true, error: null, executionTimeMs: 7);
|
||||
|
||||
Assert.IsTrue(EvalUsageTelemetry.WaitForPendingRecords(), "Background record should flush promptly");
|
||||
|
||||
var record = EvalUsageTelemetry.ReadRecords(m_Dir).Single();
|
||||
Assert.AreEqual("eval", record.Command);
|
||||
Assert.AreEqual(7, record.ExecutionTimeMs);
|
||||
CollectionAssert.Contains(record.Fingerprints, "AssetDatabase.Refresh");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RecordInBackground_UsesConfigSnapshotFromCallTime()
|
||||
{
|
||||
// StoreSource is captured when the record is queued; a later flip must not affect the
|
||||
// in-flight record (the writer works from the snapshot, not the live statics).
|
||||
EvalUsageTelemetry.StoreSource = false;
|
||||
EvalUsageTelemetry.RecordInBackground("eval", "return SecretApi.Token;", success: true, error: null, executionTimeMs: 1);
|
||||
EvalUsageTelemetry.StoreSource = true;
|
||||
|
||||
Assert.IsTrue(EvalUsageTelemetry.WaitForPendingRecords());
|
||||
Assert.IsNull(EvalUsageTelemetry.ReadRecords(m_Dir).Single().Source,
|
||||
"Raw source must follow the opt-in value captured at record time");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Record_Failure_StoresErrorCategory()
|
||||
{
|
||||
EvalUsageTelemetry.Record("eval", "return 2 +;", success: false, error: "Compilation Failed", executionTimeMs: 3);
|
||||
|
||||
var obj = JObject.Parse(File.ReadAllLines(m_JsonlPath).Single());
|
||||
Assert.IsFalse((bool)obj["success"]);
|
||||
Assert.AreEqual("Compilation Failed", (string)obj["error"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Record_WhenDisabled_WritesNothing()
|
||||
{
|
||||
EvalUsageTelemetry.Enabled = false;
|
||||
EvalUsageTelemetry.Record("eval", "return 1;", success: true, error: null, executionTimeMs: 1);
|
||||
|
||||
Assert.IsFalse(File.Exists(m_JsonlPath), "Disabled telemetry must not write locally (and never transmits)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReadRecords_RoundTripsAppendedRecords_InOrder()
|
||||
{
|
||||
EvalUsageTelemetry.Record("eval", "A.B();", true, null, 1);
|
||||
EvalUsageTelemetry.Record("eval_file", "C.D();", false, "Runtime Error", 2);
|
||||
|
||||
var records = EvalUsageTelemetry.ReadRecords(m_Dir);
|
||||
Assert.AreEqual(2, records.Count);
|
||||
Assert.AreEqual("eval", records[0].Command);
|
||||
Assert.AreEqual("eval_file", records[1].Command);
|
||||
CollectionAssert.Contains(records[0].Fingerprints, "A.B");
|
||||
CollectionAssert.Contains(records[1].Fingerprints, "C.D");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReadRecords_SkipsMalformedLines()
|
||||
{
|
||||
EvalUsageTelemetry.Record("eval", "A.B();", true, null, 1);
|
||||
File.AppendAllText(m_JsonlPath, "this is not json\n");
|
||||
EvalUsageTelemetry.Record("eval", "C.D();", true, null, 1);
|
||||
|
||||
var records = EvalUsageTelemetry.ReadRecords(m_Dir);
|
||||
Assert.AreEqual(2, records.Count, "Malformed lines are skipped; valid records survive");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Append_OverSizeCap_RotatesToOldFile_ThenStartsFresh()
|
||||
{
|
||||
// Force the cap so a couple of records trip rotation without writing megabytes.
|
||||
EvalUsageTelemetry.MaxFileBytes = 1;
|
||||
|
||||
EvalUsageTelemetry.Record("eval", "First.Call();", true, null, 1);
|
||||
Assert.IsTrue(File.Exists(m_JsonlPath));
|
||||
|
||||
// Next append sees the file already at/over the cap, rotates it away, then writes fresh.
|
||||
EvalUsageTelemetry.Record("eval", "Second.Call();", true, null, 1);
|
||||
|
||||
Assert.IsTrue(File.Exists(m_OldPath), "Previous log should be rotated to eval-usage.old.jsonl");
|
||||
Assert.AreEqual(1, File.ReadAllLines(m_JsonlPath).Length, "Active file starts fresh after rotation");
|
||||
StringAssert.Contains("Second.Call", File.ReadAllText(m_JsonlPath));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReadRecords_MergesRotatedHistory_OldestFirst()
|
||||
{
|
||||
// The report must not lose pre-rotation history: ReadRecords merges the rotated .old file
|
||||
// first, then the active file, so readable history is bounded to ~2x the size cap and stays
|
||||
// in chronological order.
|
||||
EvalUsageTelemetry.Record("eval", "First.Call();", true, null, 1);
|
||||
EvalUsageTelemetry.Rotate(m_Dir);
|
||||
EvalUsageTelemetry.Record("eval", "Second.Call();", true, null, 1);
|
||||
|
||||
var records = EvalUsageTelemetry.ReadRecords(m_Dir);
|
||||
|
||||
Assert.AreEqual(2, records.Count, "Rotated history merges with the active file");
|
||||
CollectionAssert.Contains(records[0].Fingerprints, "First.Call");
|
||||
CollectionAssert.Contains(records[1].Fingerprints, "Second.Call");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReadRecords_MissingDirectory_ReturnsEmpty()
|
||||
{
|
||||
var missing = Path.Combine(m_Dir, "does-not-exist");
|
||||
|
||||
IReadOnlyList<EvalUsageRecord> records = null;
|
||||
Assert.DoesNotThrow(() => records = EvalUsageTelemetry.ReadRecords(missing));
|
||||
Assert.IsEmpty(records);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReadRecords_ActiveFileLockedByExternalWriter_ReturnsWhatParsed()
|
||||
{
|
||||
// A reader racing an exclusive out-of-process writer (or a rotation TOCTOU) must degrade
|
||||
// to "return what parsed" rather than throw. Hold the ACTIVE file with no sharing; the
|
||||
// rotated history must still come back.
|
||||
EvalUsageTelemetry.Record("eval", "First.Call();", true, null, 1);
|
||||
EvalUsageTelemetry.Rotate(m_Dir);
|
||||
EvalUsageTelemetry.Record("eval", "Second.Call();", true, null, 1);
|
||||
|
||||
using (new FileStream(m_JsonlPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
|
||||
{
|
||||
IReadOnlyList<EvalUsageRecord> records = null;
|
||||
Assert.DoesNotThrow(() => records = EvalUsageTelemetry.ReadRecords(m_Dir));
|
||||
// FileShare enforcement varies by platform: where the OS honours it (Windows) the
|
||||
// locked active file is skipped (1 record); where it does not, both parse. Either way
|
||||
// the contract holds: no throw, and the rotated history is returned.
|
||||
Assert.GreaterOrEqual(records.Count, 1);
|
||||
CollectionAssert.Contains(records[0].Fingerprints, "First.Call");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Rotate_ReplacesPreviousOldFile()
|
||||
{
|
||||
EvalUsageTelemetry.Append(m_Dir, new EvalUsageRecord { Command = "eval", Fingerprints = new List<string> { "A.X" } });
|
||||
EvalUsageTelemetry.Rotate(m_Dir);
|
||||
|
||||
EvalUsageTelemetry.Append(m_Dir, new EvalUsageRecord { Command = "eval", Fingerprints = new List<string> { "B.Y" } });
|
||||
EvalUsageTelemetry.Rotate(m_Dir);
|
||||
|
||||
Assert.IsFalse(File.Exists(m_JsonlPath), "Active file moved away by rotation");
|
||||
var line = File.ReadAllLines(m_OldPath).Single();
|
||||
Assert.IsTrue(line.Contains("B.Y"), "Old file should hold the most recent pre-rotation content");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Rotate_NoActiveFile_IsNoOp()
|
||||
{
|
||||
Assert.DoesNotThrow(() => EvalUsageTelemetry.Rotate(m_Dir));
|
||||
Assert.IsFalse(File.Exists(m_JsonlPath));
|
||||
Assert.IsFalse(File.Exists(m_OldPath));
|
||||
}
|
||||
[Test]
|
||||
public void Record_CountsLoneCarriageReturnLineEndings()
|
||||
{
|
||||
// Review regression: only \n was counted, so classic-Mac (\r) bodies reported
|
||||
// lineCount 1. All three ending styles must agree.
|
||||
EvalUsageTelemetry.Record("eval", "a\rb\rc", true, null, 1);
|
||||
EvalUsageTelemetry.Record("eval", "a\r\nb\r\nc", true, null, 1);
|
||||
EvalUsageTelemetry.Record("eval", "a\nb\nc", true, null, 1);
|
||||
|
||||
var records = EvalUsageTelemetry.ReadRecords(m_Dir);
|
||||
var lastThree = new List<int>();
|
||||
for (var i = records.Count - 3; i < records.Count; i++)
|
||||
lastThree.Add(records[i].LineCount);
|
||||
CollectionAssert.AreEqual(new[] { 3, 3, 3 }, lastThree,
|
||||
"\r, \r\n and \n bodies of three lines must all report lineCount 3");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RecordInBackground_BackToBackCalls_LandInSubmissionOrder()
|
||||
{
|
||||
// Review regression: independent Task.Run per record let back-to-back writes land
|
||||
// out of order; the chain must preserve submission order (ReadRecords documents the
|
||||
// log as chronological).
|
||||
for (var i = 1; i <= 10; i++)
|
||||
EvalUsageTelemetry.RecordInBackground("eval", new string('a', i), success: true, error: null, executionTimeMs: 1);
|
||||
Assert.IsTrue(EvalUsageTelemetry.WaitForPendingRecords(10000), "background records must drain");
|
||||
|
||||
var records = EvalUsageTelemetry.ReadRecords(m_Dir);
|
||||
Assert.GreaterOrEqual(records.Count, 10);
|
||||
var lastTen = new List<int>();
|
||||
for (var i = records.Count - 10; i < records.Count; i++)
|
||||
lastTen.Add(records[i].PayloadLength);
|
||||
CollectionAssert.AreEqual(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }, lastTen,
|
||||
"writes must land in submission order");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ccbfca01f9914ea99012a17cd72d2889
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user