Skip to content

Commit 832ef1b

Browse files
test(audience-sdk,sample): Unity-only DeviceCollector and UXML alignment drafts
Two Unity-only test fixtures the dotnet test runner cannot reach (DeviceCollector depends on UnityEngine.SystemInfo / Application; SampleAppUxml needs Unity test framework gating). Both run under the Unity Test Framework once the project is opened in the editor. DeviceCollectorTests (src/Packages/Audience/Tests/Editor/, excluded from the headless dotnet build by Audience.Tests.csproj's Compile Remove="Editor/**/*.cs" rule) pin DeviceCollector's emitted key sets against GameLaunchPropertyKeys and ContextKeys, assert that no unknown keys leak in either direction, and verify every string-typed value is capped at Constants.MaxFieldLength. SampleAppUxmlAlignmentTests (examples/audience/Assets/SampleApp/Tests/ Runtime/, gated by the existing UNITY_INCLUDE_TESTS define on the SampleApp.Tests asmdef) reads Resources/AudienceSample.uxml as XML and asserts every SampleAppUi name and Css constant that is slug-shaped (lowercase / dashes / no spaces) appears as a name= or class= attribute somewhere in the markup. Runtime-only CSS toggles (state-warn, copied, narrow, has-value, etc.) are filtered by the slug-shape heuristic so the test only flags constants that look like they should map directly to UXML. Both files compile cleanly. They will not run under dotnet test. Run them via Unity Test Runner. Follow-up to SDK-272 (centralisation of duplicated literals).
1 parent 19d3f35 commit 832ef1b

2 files changed

Lines changed: 238 additions & 0 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
#nullable enable
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.IO;
6+
using System.Linq;
7+
using System.Reflection;
8+
using System.Xml.Linq;
9+
using NUnit.Framework;
10+
using UnityEngine;
11+
12+
namespace Immutable.Audience.Samples.SampleApp.Tests
13+
{
14+
// Pins SampleAppUi name/class strings against AudienceSample.uxml so a rename on either side fails the test.
15+
// Unity-only (UNITY_INCLUDE_TESTS gated).
16+
[TestFixture]
17+
internal class SampleAppUxmlAlignmentTests
18+
{
19+
// Names synthesised at runtime; not expected as static UXML attributes.
20+
private static readonly HashSet<string> NamesNotInStaticUxml = new HashSet<string>
21+
{
22+
// Typed-event button/field names are built per-spec at runtime, not present as static UXML.
23+
};
24+
25+
[Test]
26+
public void EveryNameConstant_AppearsInUxmlNameAttribute()
27+
{
28+
var uxmlNames = ExtractAttributeValues(LoadUxmlDocument(), "name");
29+
var missing = new List<string>();
30+
foreach (var (path, value) in CollectStringConstants(typeof(SampleAppUi)))
31+
{
32+
if (NamesNotInStaticUxml.Contains(value)) continue;
33+
if (!IsLikelyElementName(value)) continue;
34+
if (!uxmlNames.Contains(value))
35+
missing.Add($"{path} = \"{value}\"");
36+
}
37+
Assert.IsEmpty(missing,
38+
"SampleAppUi element-name constants missing from UXML name= attributes:\n "
39+
+ string.Join("\n ", missing));
40+
}
41+
42+
[Test]
43+
public void EveryCssClassConstant_AppearsInUxmlClassAttribute()
44+
{
45+
var uxmlClasses = ExtractClassTokens(LoadUxmlDocument());
46+
var missing = new List<string>();
47+
foreach (var (path, value) in CollectStringConstants(typeof(SampleAppUi.Css)))
48+
{
49+
if (!IsLikelyCssClass(value)) continue;
50+
if (!uxmlClasses.Contains(value))
51+
missing.Add($"{path} = \"{value}\"");
52+
}
53+
// Runtime-toggled CSS classes are not in the static UXML class attributes.
54+
Assert.IsEmpty(missing,
55+
"SampleAppUi.Css constants are runtime-only or missing from UXML class= attributes:\n "
56+
+ string.Join("\n ", missing));
57+
}
58+
59+
// Helpers.
60+
61+
private static XDocument LoadUxmlDocument()
62+
{
63+
// Walk up from the test binary to find the sample-app Resources directory.
64+
var current = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
65+
while (current != null)
66+
{
67+
var candidate = Path.Combine(
68+
current.FullName,
69+
"examples", "audience", "Assets", "SampleApp",
70+
"Resources", "AudienceSample.uxml");
71+
if (File.Exists(candidate)) return XDocument.Load(candidate);
72+
current = current.Parent;
73+
}
74+
throw new FileNotFoundException(
75+
$"AudienceSample.uxml not found by walking up from {TestContext.CurrentContext.TestDirectory}");
76+
}
77+
78+
private static HashSet<string> ExtractAttributeValues(XDocument doc, string attrName)
79+
{
80+
return new HashSet<string>(
81+
doc.Descendants()
82+
.Select(e => e.Attribute(attrName)?.Value)
83+
.Where(v => !string.IsNullOrEmpty(v))
84+
.Cast<string>(),
85+
StringComparer.Ordinal);
86+
}
87+
88+
private static HashSet<string> ExtractClassTokens(XDocument doc)
89+
{
90+
var tokens = new HashSet<string>(StringComparer.Ordinal);
91+
foreach (var attr in doc.Descendants().Select(e => e.Attribute("class")?.Value))
92+
if (!string.IsNullOrEmpty(attr))
93+
foreach (var t in attr!.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries))
94+
tokens.Add(t);
95+
return tokens;
96+
}
97+
98+
// Recursively yield every public/internal const string and its dot-path
99+
// identifier from the given type and any nested types.
100+
private static IEnumerable<(string Path, string Value)> CollectStringConstants(Type type, string? prefix = null)
101+
{
102+
prefix ??= type.Name;
103+
foreach (var f in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
104+
{
105+
if (!f.IsLiteral || f.IsInitOnly) continue;
106+
if (f.FieldType != typeof(string)) continue;
107+
var raw = f.GetRawConstantValue();
108+
if (raw is string s) yield return ($"{prefix}.{f.Name}", s);
109+
}
110+
foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic))
111+
foreach (var entry in CollectStringConstants(nested, $"{prefix}.{nested.Name}"))
112+
yield return entry;
113+
}
114+
115+
// Heuristic: UXML element names are slug-shaped (lowercase, dashes).
116+
// Skip values that are clearly something else (URLs, labels,
117+
// glyphs, formats, env-var names, log labels with parens, etc.).
118+
private static bool IsLikelyElementName(string value) =>
119+
!string.IsNullOrEmpty(value)
120+
&& value.IndexOfAny(new[] { ' ', '/', ':', '(', ')', '.', '"' }) < 0
121+
&& value.All(c => char.IsLower(c) || char.IsDigit(c) || c == '-' || c == '_');
122+
123+
private static bool IsLikelyCssClass(string value) => IsLikelyElementName(value);
124+
}
125+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
#nullable enable
2+
3+
using System.Collections.Generic;
4+
using NUnit.Framework;
5+
using Immutable.Audience.Unity;
6+
7+
namespace Immutable.Audience.Tests.Editor
8+
{
9+
// Editor-only (DeviceCollector needs a Unity domain; skipped by the headless dotnet build).
10+
// Pins emitted payload keys against GameLaunchPropertyKeys and ContextKeys.
11+
[TestFixture]
12+
internal class DeviceCollectorTests
13+
{
14+
[Test]
15+
public void CollectGameLaunchProperties_EmitsTheExpectedKeySet()
16+
{
17+
var props = DeviceCollector.CollectGameLaunchProperties();
18+
19+
// Always-present keys. ScreenDpi is conditional (0 on some Linux WMs).
20+
CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.Platform);
21+
CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.Version);
22+
CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.BuildGuid);
23+
CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.UnityVersion);
24+
CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.OsFamily);
25+
CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.DeviceModel);
26+
CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.Gpu);
27+
CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.GpuVendor);
28+
CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.Cpu);
29+
CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.CpuCores);
30+
CollectionAssert.Contains(props.Keys, GameLaunchPropertyKeys.RamMb);
31+
}
32+
33+
[Test]
34+
public void CollectGameLaunchProperties_EmitsNoUnknownKeys()
35+
{
36+
// Confirms the payload only carries known GameLaunchPropertyKeys entries.
37+
var allowed = new HashSet<string>
38+
{
39+
GameLaunchPropertyKeys.Platform,
40+
GameLaunchPropertyKeys.Version,
41+
GameLaunchPropertyKeys.BuildGuid,
42+
GameLaunchPropertyKeys.UnityVersion,
43+
GameLaunchPropertyKeys.OsFamily,
44+
GameLaunchPropertyKeys.DeviceModel,
45+
GameLaunchPropertyKeys.Gpu,
46+
GameLaunchPropertyKeys.GpuVendor,
47+
GameLaunchPropertyKeys.Cpu,
48+
GameLaunchPropertyKeys.CpuCores,
49+
GameLaunchPropertyKeys.RamMb,
50+
GameLaunchPropertyKeys.ScreenDpi,
51+
};
52+
53+
var props = DeviceCollector.CollectGameLaunchProperties();
54+
foreach (var key in props.Keys)
55+
Assert.IsTrue(allowed.Contains(key),
56+
$"DeviceCollector.CollectGameLaunchProperties emitted unknown key '{key}' "
57+
+ "with no matching GameLaunchPropertyKeys constant");
58+
}
59+
60+
[Test]
61+
public void CollectGameLaunchProperties_TruncatesStringValuesToMaxFieldLength()
62+
{
63+
// Every string value respects MaxFieldLength; an untruncated .ToString() would fail here.
64+
var props = DeviceCollector.CollectGameLaunchProperties();
65+
foreach (var kv in props)
66+
{
67+
if (kv.Value is string s)
68+
Assert.LessOrEqual(s.Length, Constants.MaxFieldLength,
69+
$"GameLaunchPropertyKeys.{kv.Key} value exceeds Constants.MaxFieldLength");
70+
}
71+
}
72+
73+
[Test]
74+
public void CollectContext_EmitsTheExpectedKeySet()
75+
{
76+
var ctx = DeviceCollector.CollectContext();
77+
78+
// UserAgent is unconditional. Timezone / Locale / Screen are
79+
// best-effort and may be absent under unusual hosts.
80+
CollectionAssert.Contains(ctx.Keys, ContextKeys.UserAgent);
81+
}
82+
83+
[Test]
84+
public void CollectContext_EmitsNoUnknownKeys()
85+
{
86+
var allowed = new HashSet<string>
87+
{
88+
ContextKeys.UserAgent,
89+
ContextKeys.Timezone,
90+
ContextKeys.Locale,
91+
ContextKeys.Screen,
92+
};
93+
94+
var ctx = DeviceCollector.CollectContext();
95+
foreach (var key in ctx.Keys)
96+
Assert.IsTrue(allowed.Contains(key),
97+
$"DeviceCollector.CollectContext emitted unknown key '{key}' "
98+
+ "with no matching ContextKeys constant");
99+
}
100+
101+
[Test]
102+
public void CollectContext_TruncatesStringValuesToMaxFieldLength()
103+
{
104+
var ctx = DeviceCollector.CollectContext();
105+
foreach (var kv in ctx)
106+
{
107+
if (kv.Value is string s)
108+
Assert.LessOrEqual(s.Length, Constants.MaxFieldLength,
109+
$"ContextKeys.{kv.Key} value exceeds Constants.MaxFieldLength");
110+
}
111+
}
112+
}
113+
}

0 commit comments

Comments
 (0)