diff --git a/.agents/skills/uloop-pause-point/references/captured-variables.md b/.agents/skills/uloop-pause-point/references/captured-variables.md index e6887c23f..e6ebb1d81 100644 --- a/.agents/skills/uloop-pause-point/references/captured-variables.md +++ b/.agents/skills/uloop-pause-point/references/captured-variables.md @@ -23,7 +23,8 @@ Read this before interpreting unexpected, missing, or truncated captured values, - A value's `Value` string is not always its plain `ToString()`. A materialized collection (`List`, arrays, dictionaries, ...) previews as a shallow JSON array/object instead of the default type-name text. A custom struct/class whose declared type does not override `ToString()` previews the same way — a shallow JSON object of its fields — so you do not need to add a temporary `ToString()` override just to see its contents. A type that does override `ToString()` keeps using that result unchanged. Either kind of preview is capped by depth, element count, and length like any other captured value; the element-count cap (default 10) and the preview's character budget both scale with `enable-pause-point --max-preview-elements` (1–1000). Raising it scales the character budget proportionally, so each element keeps the same ~100-character share it has at the default — plenty for numeric or boolean cells, but individually long elements can still be clipped by the scaled budget. The enable response echoes the effective `MaxPreviewElements`. - A captured `Collision2D` is previewed as `{"Collider":{"Name":...,"UnityObjectPath":...},"OtherCollider":{...},"RelativeVelocity":...,"ContactCount":...}` — read `UnityObjectPath` to identify both colliding objects without an extra `execute-dynamic-code` round-trip. Each of `Collider` / `OtherCollider` is either that object form or the string `"(none)"` when the collider is null or destroyed. - A multidimensional array (`int[,]`, `int[,,]`, ...) previews as `{"Shape":"Int32[2,3]","TotalElements":6,"PreviewedElements":6,"ElementOrder":"row-major (last dimension fastest)","Elements":[...]}` instead of a bare JSON array, since `Elements` flattens every rank in row-major order (last dimension fastest) and would otherwise look like an empty or 1D collection; when `--max-preview-elements` cuts the list, the preview also includes `"ElementsTruncated":true` and a smaller `PreviewedElements`. A `T[]` or jagged `T[][]` array is unaffected and still previews as a plain JSON array. -- `CapturedVariablesTruncated=true` means at least one value was clipped (value-length cap, collection preview element cap, or preview depth cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. +- `CapturedVariablesTruncated=true` means at least one value was clipped (value-length cap or collection preview element cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. +- `TruncatedVariableCount` is the exact number of variables that were dropped whole by the variable-count cap or whose value preview was clipped (`Truncated: true` on the entry). `TruncatedVariableNames` lists that union in capture order, at most 20 names; the count stays exact when more than 20 were affected. The invariant is `CapturedVariablesTruncated == (TruncatedVariableCount > 0)` and `TruncatedVariableCount >= TruncatedVariableNames.Length`. ## Unity Object Values diff --git a/.claude/skills/uloop-pause-point/references/captured-variables.md b/.claude/skills/uloop-pause-point/references/captured-variables.md index e6887c23f..e6ebb1d81 100644 --- a/.claude/skills/uloop-pause-point/references/captured-variables.md +++ b/.claude/skills/uloop-pause-point/references/captured-variables.md @@ -23,7 +23,8 @@ Read this before interpreting unexpected, missing, or truncated captured values, - A value's `Value` string is not always its plain `ToString()`. A materialized collection (`List`, arrays, dictionaries, ...) previews as a shallow JSON array/object instead of the default type-name text. A custom struct/class whose declared type does not override `ToString()` previews the same way — a shallow JSON object of its fields — so you do not need to add a temporary `ToString()` override just to see its contents. A type that does override `ToString()` keeps using that result unchanged. Either kind of preview is capped by depth, element count, and length like any other captured value; the element-count cap (default 10) and the preview's character budget both scale with `enable-pause-point --max-preview-elements` (1–1000). Raising it scales the character budget proportionally, so each element keeps the same ~100-character share it has at the default — plenty for numeric or boolean cells, but individually long elements can still be clipped by the scaled budget. The enable response echoes the effective `MaxPreviewElements`. - A captured `Collision2D` is previewed as `{"Collider":{"Name":...,"UnityObjectPath":...},"OtherCollider":{...},"RelativeVelocity":...,"ContactCount":...}` — read `UnityObjectPath` to identify both colliding objects without an extra `execute-dynamic-code` round-trip. Each of `Collider` / `OtherCollider` is either that object form or the string `"(none)"` when the collider is null or destroyed. - A multidimensional array (`int[,]`, `int[,,]`, ...) previews as `{"Shape":"Int32[2,3]","TotalElements":6,"PreviewedElements":6,"ElementOrder":"row-major (last dimension fastest)","Elements":[...]}` instead of a bare JSON array, since `Elements` flattens every rank in row-major order (last dimension fastest) and would otherwise look like an empty or 1D collection; when `--max-preview-elements` cuts the list, the preview also includes `"ElementsTruncated":true` and a smaller `PreviewedElements`. A `T[]` or jagged `T[][]` array is unaffected and still previews as a plain JSON array. -- `CapturedVariablesTruncated=true` means at least one value was clipped (value-length cap, collection preview element cap, or preview depth cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. +- `CapturedVariablesTruncated=true` means at least one value was clipped (value-length cap or collection preview element cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. +- `TruncatedVariableCount` is the exact number of variables that were dropped whole by the variable-count cap or whose value preview was clipped (`Truncated: true` on the entry). `TruncatedVariableNames` lists that union in capture order, at most 20 names; the count stays exact when more than 20 were affected. The invariant is `CapturedVariablesTruncated == (TruncatedVariableCount > 0)` and `TruncatedVariableCount >= TruncatedVariableNames.Length`. ## Unity Object Values diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs new file mode 100644 index 000000000..1e9d63fa8 --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; + +using NUnit.Framework; + +using UnityEngine; +using UnityEngine.TestTools; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies count-cap drops and preview-clipped entries share one truncation aggregate. + /// + [TestFixture] + public sealed class SourcePausePointTruncationAggregateTests + { + /// + /// What: preview clipping alone puts that variable name on the aggregate with count 1. + /// + [Test] + public void CaptureFrame_WhenOnlyPreviewIsClipped_ReportsThatNameAndCountOne() + { + string longValue = new string('a', SourcePausePointConstants.MaxCapturedVariableValueLength + 10); + object[] locals = { "longText", longValue, "hp", 42 }; + + (UloopPausePointCapturedVariableFrame frame, List variables, bool truncated) = + SourcePausePointCapture.CaptureFrame(null, Array.Empty(), locals); + + Assert.That(truncated, Is.True); + Assert.That(frame.Truncated, Is.True); + Assert.That(frame.TruncatedVariableCount, Is.EqualTo(1)); + Assert.That(frame.TruncatedVariableNames, Is.EqualTo(new[] { "longText" })); + Assert.That(variables.Find(variable => variable.Name == "longText").Truncated, Is.True); + Assert.That(variables.Find(variable => variable.Name == "hp").Truncated, Is.False); + } + + /// + /// What: count-cap overflow alone reports count 25 and local50 through local69 in order. + /// + [Test] + public void CaptureFrame_WhenOnlyCountCapDropsVariables_ReportsTwentyNamesAndExactCount() + { + object[] locals = new object[150]; + for (int index = 0; index < 75; index++) + { + locals[index * 2] = $"local{index}"; + locals[index * 2 + 1] = index; + } + + (UloopPausePointCapturedVariableFrame frame, _, bool truncated) = + SourcePausePointCapture.CaptureFrame(null, Array.Empty(), locals); + + Assert.That(truncated, Is.True); + Assert.That(frame.TruncatedVariableCount, Is.EqualTo(25)); + Assert.That( + frame.TruncatedVariableNames, + Is.EqualTo(new[] + { + "local50", + "local51", + "local52", + "local53", + "local54", + "local55", + "local56", + "local57", + "local58", + "local59", + "local60", + "local61", + "local62", + "local63", + "local64", + "local65", + "local66", + "local67", + "local68", + "local69" + })); + } + + /// + /// What: preview clipping plus count-cap drops unions names in capture order. + /// + [Test] + public void CaptureFrame_WhenPreviewClipAndCountCapCombine_UnionsNamesInCaptureOrder() + { + object[] locals = new object[106]; + locals[0] = "longText"; + locals[1] = new string('a', SourcePausePointConstants.MaxCapturedVariableValueLength + 10); + for (int index = 1; index < 53; index++) + { + locals[index * 2] = $"local{index}"; + locals[index * 2 + 1] = index; + } + + (UloopPausePointCapturedVariableFrame frame, _, bool truncated) = + SourcePausePointCapture.CaptureFrame(null, Array.Empty(), locals); + + Assert.That(truncated, Is.True); + Assert.That(frame.TruncatedVariableCount, Is.EqualTo(4)); + Assert.That( + frame.TruncatedVariableNames, + Is.EqualTo(new[] { "longText", "local50", "local51", "local52" })); + } + + /// + /// What: no clipping and no count-cap drop leaves the aggregate empty. + /// + [Test] + public void CaptureFrame_WhenNothingIsTruncated_ReportsEmptyAggregate() + { + object[] locals = { "speed", 5, "damage", 3 }; + + (UloopPausePointCapturedVariableFrame frame, _, bool truncated) = + SourcePausePointCapture.CaptureFrame(null, Array.Empty(), locals); + + Assert.That(truncated, Is.False); + Assert.That(frame.Truncated, Is.False); + Assert.That(frame.TruncatedVariableCount, Is.EqualTo(0)); + Assert.That(frame.TruncatedVariableNames, Is.Empty); + } + + /// + /// What: a clipped value with an empty name still increments the aggregate count. + /// + [Test] + public void CaptureFrame_WhenClippedValueHasEmptyName_CountsItWithoutReportingAName() + { + string longValue = new string('a', SourcePausePointConstants.MaxCapturedVariableValueLength + 10); + object[] locals = { "", longValue }; + + LogAssert.Expect(LogType.Assert, "name must not be null or empty"); + + (UloopPausePointCapturedVariableFrame frame, _, bool truncated) = + SourcePausePointCapture.CaptureFrame(null, Array.Empty(), locals); + + Assert.That(truncated, Is.True); + Assert.That(frame.Truncated, Is.True); + Assert.That(frame.TruncatedVariableCount, Is.EqualTo(1)); + Assert.That(frame.TruncatedVariableNames, Is.Empty); + } + + /// + /// What: more than 20 preview clips keep the first 20 names and the exact total count. + /// + [Test] + public void CaptureFrame_WhenPreviewClipsExceedNameCap_ReportsFirstTwentyNamesAndExactCount() + { + string longValue = new string('a', SourcePausePointConstants.MaxCapturedVariableValueLength + 10); + object[] locals = new object[42]; + for (int index = 0; index < 21; index++) + { + locals[index * 2] = $"clip{index}"; + locals[index * 2 + 1] = longValue; + } + + (UloopPausePointCapturedVariableFrame frame, _, bool truncated) = + SourcePausePointCapture.CaptureFrame(null, Array.Empty(), locals); + + Assert.That(truncated, Is.True); + Assert.That(frame.TruncatedVariableCount, Is.EqualTo(21)); + Assert.That( + frame.TruncatedVariableNames, + Is.EqualTo(new[] + { + "clip0", + "clip1", + "clip2", + "clip3", + "clip4", + "clip5", + "clip6", + "clip7", + "clip8", + "clip9", + "clip10", + "clip11", + "clip12", + "clip13", + "clip14", + "clip15", + "clip16", + "clip17", + "clip18", + "clip19" + })); + } + } +} diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs.meta b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs.meta new file mode 100644 index 000000000..35822c62e --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointTruncationAggregateTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7fb1db5152148499d8c95d68be2d74a0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md index e6887c23f..e6ebb1d81 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md @@ -23,7 +23,8 @@ Read this before interpreting unexpected, missing, or truncated captured values, - A value's `Value` string is not always its plain `ToString()`. A materialized collection (`List`, arrays, dictionaries, ...) previews as a shallow JSON array/object instead of the default type-name text. A custom struct/class whose declared type does not override `ToString()` previews the same way — a shallow JSON object of its fields — so you do not need to add a temporary `ToString()` override just to see its contents. A type that does override `ToString()` keeps using that result unchanged. Either kind of preview is capped by depth, element count, and length like any other captured value; the element-count cap (default 10) and the preview's character budget both scale with `enable-pause-point --max-preview-elements` (1–1000). Raising it scales the character budget proportionally, so each element keeps the same ~100-character share it has at the default — plenty for numeric or boolean cells, but individually long elements can still be clipped by the scaled budget. The enable response echoes the effective `MaxPreviewElements`. - A captured `Collision2D` is previewed as `{"Collider":{"Name":...,"UnityObjectPath":...},"OtherCollider":{...},"RelativeVelocity":...,"ContactCount":...}` — read `UnityObjectPath` to identify both colliding objects without an extra `execute-dynamic-code` round-trip. Each of `Collider` / `OtherCollider` is either that object form or the string `"(none)"` when the collider is null or destroyed. - A multidimensional array (`int[,]`, `int[,,]`, ...) previews as `{"Shape":"Int32[2,3]","TotalElements":6,"PreviewedElements":6,"ElementOrder":"row-major (last dimension fastest)","Elements":[...]}` instead of a bare JSON array, since `Elements` flattens every rank in row-major order (last dimension fastest) and would otherwise look like an empty or 1D collection; when `--max-preview-elements` cuts the list, the preview also includes `"ElementsTruncated":true` and a smaller `PreviewedElements`. A `T[]` or jagged `T[][]` array is unaffected and still previews as a plain JSON array. -- `CapturedVariablesTruncated=true` means at least one value was clipped (value-length cap, collection preview element cap, or preview depth cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. +- `CapturedVariablesTruncated=true` means at least one value was clipped (value-length cap or collection preview element cap) or the variable-count cap stopped enumeration; clipped values are still present up to the cap. +- `TruncatedVariableCount` is the exact number of variables that were dropped whole by the variable-count cap or whose value preview was clipped (`Truncated: true` on the entry). `TruncatedVariableNames` lists that union in capture order, at most 20 names; the count stays exact when more than 20 were affected. The invariant is `CapturedVariablesTruncated == (TruncatedVariableCount > 0)` and `TruncatedVariableCount >= TruncatedVariableNames.Length`. ## Unity Object Values diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs index e439206ea..93ce1ee7f 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs @@ -71,7 +71,8 @@ internal static (UloopPausePointCapturedVariableFrame Frame, List variables, bool truncated) = SourcePausePointVariableFormatter.FormatFrame(frame, maxPreviewElements); - return (frame, variables, truncated); + frame = SourcePausePointTruncationAggregate.Merge(frame, variables); + return (frame, variables, truncated || frame.Truncated); } } } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs new file mode 100644 index 000000000..f765dc0b2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Merges count-cap drops with per-entry preview clipping so TruncatedVariableCount + /// and TruncatedVariableNames match CapturedVariablesTruncated. + /// + internal static class SourcePausePointTruncationAggregate + { + public static UloopPausePointCapturedVariableFrame Merge( + UloopPausePointCapturedVariableFrame frame, + IReadOnlyList variables) + { + Debug.Assert(frame != null, "frame must not be null"); + Debug.Assert(variables != null, "variables must not be null"); + + List names = new List(); + HashSet seen = new HashSet(StringComparer.Ordinal); + int previewClippedCount = 0; + + for (int index = 0; index < variables.Count; index++) + { + UloopCapturedVariable variable = variables[index]; + if (!variable.Truncated) + { + continue; + } + + // Why count before name checks: CapturedVariablesTruncated==(Count>0) must + // stay true even when a clipped entry has a null or empty name. + previewClippedCount++; + if (string.IsNullOrEmpty(variable.Name) || !seen.Add(variable.Name)) + { + continue; + } + + TryAddReportedName(names, variable.Name); + } + + IReadOnlyList droppedNames = frame.TruncatedVariableNames; + for (int index = 0; index < droppedNames.Count; index++) + { + string droppedName = droppedNames[index]; + if (string.IsNullOrEmpty(droppedName) || !seen.Add(droppedName)) + { + continue; + } + + TryAddReportedName(names, droppedName); + } + + // Why add the collector count as a whole: TruncatedVariableNames is capped at 20, + // so walking that list cannot recover the exact dropped-whole total. + int count = previewClippedCount + frame.TruncatedVariableCount; + bool truncated = count > 0; + return new UloopPausePointCapturedVariableFrame( + frame.Entries, + truncated, + names, + count); + } + + private static void TryAddReportedName(List names, string name) + { + if (names.Count < SourcePausePointConstants.MaxTruncatedVariableNamesReported) + { + names.Add(name); + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs.meta new file mode 100644 index 000000000..b17936612 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointTruncationAggregate.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2d8cf2f25444d41149902d7e0452302c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs index a90333b16..93994ca36 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; +using UnityEngine; + namespace io.github.hatayama.UnityCliLoop.Runtime { /// @@ -15,19 +17,27 @@ public UloopPausePointCapturedVariableFrame( IReadOnlyList truncatedVariableNames, int truncatedVariableCount) { + IReadOnlyList names = truncatedVariableNames ?? Array.Empty(); + Debug.Assert(truncated == (truncatedVariableCount > 0), "truncated must match a positive count"); + Debug.Assert( + truncatedVariableCount >= names.Count, + "truncatedVariableCount must be at least the reported name count"); + Entries = entries; Truncated = truncated; - TruncatedVariableNames = truncatedVariableNames ?? Array.Empty(); + TruncatedVariableNames = names; TruncatedVariableCount = truncatedVariableCount; } public IReadOnlyList Entries { get; } public bool Truncated { get; } - // Names dropped by the variable-count cap (at most MaxTruncatedVariableNamesReported). + // Names dropped by the variable-count cap or whose preview was clipped + // (at most MaxTruncatedVariableNamesReported), in capture order. public IReadOnlyList TruncatedVariableNames { get; } - // Exact number of variables dropped by the count cap (not capped at the names list length). + // Exact number of variables dropped by the count cap or whose preview was + // clipped (not capped at the names list length). public int TruncatedVariableCount { get; } } } diff --git a/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter.go b/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter.go index dbc0e4b56..9c1b81c31 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter.go +++ b/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter.go @@ -46,6 +46,10 @@ func filterPausePointCapturedVariablesByName( // marker has empty CapturedVariables/history, which would otherwise look like a name miss // and a Warning blaming the requested names would misdiagnose "not hit yet". hadCapturedVariables := pausePointResponseHasCapturedVariables(response) + // Why current only: top-level CapturedVariablesTruncated/Count/Names come from the + // latest hit. A clipped variable that lives only in history must not make the CLI + // describe a count-cap flag as a name-filter drop. + hadListedTruncated := pausePointCurrentCapturedVariablesHaveTruncated(response) nameSet := make(map[string]struct{}, len(names)) for _, name := range names { @@ -80,7 +84,7 @@ func filterPausePointCapturedVariablesByName( "No captured variable matched the requested names; the hit captured other variables. Check CapturedVariableNamesNotFound for the names that were absent.") } - return applyPausePointCapturedVariablesTruncatedNote(response) + return applyPausePointCapturedVariablesTruncatedNote(response, hadListedTruncated) } // pausePointResponseHasCapturedVariables reports whether the snapshot already holds any @@ -141,16 +145,19 @@ func filterCapturedVariablesByNameSet( // applyPausePointCapturedVariablesTruncatedNote records that the Unity truncation // flag is about a variable the name filter excluded. The flag itself is left // unchanged: clearing it would hide that a captured value was clipped. +// Count is no longer a preview-clip signal: Unity now counts clipped previews +// in TruncatedVariableCount, so a non-zero count must not suppress the note. func applyPausePointCapturedVariablesTruncatedNote( response pausePointStatusResponse, + hadListedTruncatedBeforeFilter bool, ) pausePointStatusResponse { if !response.CapturedVariablesTruncated { return response } - if response.TruncatedVariableCount != 0 { + if pausePointResponseHasTruncatedCapturedVariable(response) { return response } - if pausePointResponseHasTruncatedCapturedVariable(response) { + if !hadListedTruncatedBeforeFilter { return response } @@ -158,19 +165,30 @@ func applyPausePointCapturedVariablesTruncatedNote( return response } +// pausePointCurrentCapturedVariablesHaveTruncated reports whether the latest-hit +// CapturedVariables list (not history) already contains a Truncated entry. +func pausePointCurrentCapturedVariablesHaveTruncated(response pausePointStatusResponse) bool { + return capturedVariablesHaveTruncated(response.CapturedVariables) +} + // pausePointResponseHasTruncatedCapturedVariable reports whether any remaining // captured variable (current or history) still has Truncated set. func pausePointResponseHasTruncatedCapturedVariable(response pausePointStatusResponse) bool { - for _, variable := range response.CapturedVariables { - if variable.Truncated { + if capturedVariablesHaveTruncated(response.CapturedVariables) { + return true + } + for _, frame := range response.CapturedVariableHistory { + if capturedVariablesHaveTruncated(frame.CapturedVariables) { return true } } - for _, frame := range response.CapturedVariableHistory { - for _, variable := range frame.CapturedVariables { - if variable.Truncated { - return true - } + return false +} + +func capturedVariablesHaveTruncated(variables []pausePointCapturedVariable) bool { + for _, variable := range variables { + if variable.Truncated { + return true } } return false diff --git a/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter_test.go b/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter_test.go index e178790ef..516302a77 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_captured_variable_names_filter_test.go @@ -206,11 +206,12 @@ func TestFilterPausePointCapturedVariablesByNameReportsNotFound(t *testing.T) { } // truncatedNameFilterResponse is a Unity snapshot where preview clipping set -// CapturedVariablesTruncated without a variable-count drop (Count==0). +// CapturedVariablesTruncated and counted the clipped variable. func truncatedNameFilterResponse() pausePointStatusResponse { return pausePointStatusResponse{ CapturedVariablesTruncated: true, - TruncatedVariableCount: 0, + TruncatedVariableCount: 1, + TruncatedVariableNames: []string{"board"}, CapturedVariables: []pausePointCapturedVariable{ {Name: "health", Scope: "Local", TypeName: "Int32", Value: pausePointVariableValue("100")}, {Name: "board", Scope: "Local", TypeName: "Boolean[,]", Value: pausePointVariableValue("[...]"), Truncated: true}, @@ -266,10 +267,38 @@ func TestFilterPausePointCapturedVariablesByNameSetsTruncatedNote(t *testing.T) // TestFilterPausePointCapturedVariablesByNameOmitsTruncatedNote verifies additional // negative cases where the CLI must not attach CapturedVariablesTruncatedNote. func TestFilterPausePointCapturedVariablesByNameOmitsTruncatedNote(t *testing.T) { - t.Run("note is omitted when TruncatedVariableCount is already non-zero", func(t *testing.T) { - response := truncatedNameFilterResponse() - response.TruncatedVariableCount = 1 - response.TruncatedVariableNames = []string{"extraField"} + t.Run("note is omitted when latest hit is count-cap only and history clip is excluded", func(t *testing.T) { + response := pausePointStatusResponse{ + CapturedVariablesTruncated: true, + TruncatedVariableCount: 1, + TruncatedVariableNames: []string{"extraField"}, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "health", Scope: "Local", TypeName: "Int32", Value: pausePointVariableValue("100")}, + }, + CapturedVariableHistory: []pausePointCapturedHistoryFrame{ + { + HitSequence: 1, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "board", Scope: "Local", TypeName: "Boolean[,]", Value: pausePointVariableValue("[...]"), Truncated: true}, + }, + }, + }, + } + result := filterPausePointCapturedVariablesByName(response, []string{"health"}) + if result.CapturedVariablesTruncatedNote != "" { + t.Fatalf("count-cap latest hit must not inherit a history-clip note: %q", result.CapturedVariablesTruncatedNote) + } + }) + + t.Run("note is omitted when only count-cap drops remain", func(t *testing.T) { + response := pausePointStatusResponse{ + CapturedVariablesTruncated: true, + TruncatedVariableCount: 1, + TruncatedVariableNames: []string{"extraField"}, + CapturedVariables: []pausePointCapturedVariable{ + {Name: "health", Scope: "Local", TypeName: "Int32", Value: pausePointVariableValue("100")}, + }, + } result := filterPausePointCapturedVariablesByName(response, []string{"health"}) if result.CapturedVariablesTruncatedNote != "" { t.Fatalf("count-cap truncation must not be described as a name-filter drop: %q", result.CapturedVariablesTruncatedNote) @@ -288,7 +317,8 @@ func TestFilterPausePointCapturedVariablesByNameOmitsTruncatedNote(t *testing.T) t.Run("note is omitted when only a history variable remains truncated", func(t *testing.T) { response := pausePointStatusResponse{ CapturedVariablesTruncated: true, - TruncatedVariableCount: 0, + TruncatedVariableCount: 1, + TruncatedVariableNames: []string{"board"}, CapturedVariables: []pausePointCapturedVariable{ {Name: "health", Scope: "Local", TypeName: "Int32", Value: pausePointVariableValue("100")}, {Name: "velocity", Scope: "Local", TypeName: "Vector3", Value: pausePointVariableValue("(1,0,0)")},