Skip to content

Commit 6cd6655

Browse files
author
Nikola Stojanovic
committed
Add sibling debug scan modifier + tests
1 parent 48d063e commit 6cd6655

File tree

2 files changed

+372
-1
lines changed

2 files changed

+372
-1
lines changed

Sources/SwiftUIDebugScan/DebugScan.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@ public extension View {
167167
/// - filePath: The full file path where the view is defined. Defaults to the current file path (`#filePath`).
168168
///
169169
/// - Returns: A modified view with debug instrumentation applied.
170+
///
171+
/// - SeeAlso: `debugScan(_:file:fileID:filePath:)` for the type-based variant that automatically derives labels from view types.
170172
func debugScan(
171173
_ label: String,
172174
file: StaticString = #file,
@@ -182,4 +184,40 @@ public extension View {
182184
)
183185
)
184186
}
187+
188+
/// Adds a debug instrumentation modifier to the view for logging and tracking render information using type-based labeling.
189+
///
190+
/// This type-safe variant of `debugScan` derives the debug label from the specified view type, providing
191+
/// a more robust and refactor-friendly approach to view debugging. The modifier logs details such as the file,
192+
/// module, redraw count, and timestamp for each render pass, using the view's type name as the identifier.
193+
///
194+
/// - Important: For the best logging experience, it is recommended to apply this modifier to **root views**
195+
/// (e.g., the top-level view in your view hierarchy) rather than leaf views. Applying it to root views ensures
196+
/// that you capture the most meaningful and comprehensive debug information.
197+
///
198+
/// - Parameters:
199+
/// - label: The type to use for generating the debug label. The label will be generated using `String(describing: label)`.
200+
/// Pass the view's type (e.g., `Text.self`, `MyCustomView.self`) to get meaningful debug labels.
201+
/// - file: The file where the view is defined. Defaults to the current file (`#file`).
202+
/// - fileID: The file ID where the view is defined. Defaults to the current file ID (`#fileID`).
203+
/// - filePath: The full file path where the view is defined. Defaults to the current file path (`#filePath`).
204+
///
205+
/// - Returns: A modified view with debug instrumentation applied, using the type-derived label.
206+
///
207+
/// - SeeAlso: `debugScan(_:file:fileID:filePath:)` for the string-based variant that allows custom labels.
208+
func debugScan(
209+
_ label: (some View).Type,
210+
file: StaticString = #file,
211+
fileID: StaticString = #fileID,
212+
filePath: StaticString = #filePath
213+
) -> some View {
214+
modifier(
215+
ViewInstrumentationModifier(
216+
label: String(describing: label),
217+
file: file,
218+
fileID: fileID,
219+
filePath: filePath
220+
)
221+
)
222+
}
185223
}

Tests/SwiftUIDebugScanTests/ViewInspectorTests.swift

Lines changed: 334 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,340 @@ struct ViewInspectorTests {
121121
let emptyContent = try emptyText.string()
122122
#expect(emptyContent == "", "Empty text should be detectable as empty string")
123123
} catch {
124-
#expect(true, "ViewInspector caught that empty text behaves differently than expected")
124+
#expect(Bool(true), "ViewInspector caught that empty text behaves differently than expected")
125125
}
126126
}
127+
128+
@Test("Type-based debugScan explicit type specification")
129+
@MainActor func testTypeBased_debugScan_ExplicitTypeSpec() {
130+
// Test the new sibling modifier: debugScan(_ label: (some View).Type)
131+
132+
// Test built-in SwiftUI types with explicit type specification
133+
let textView = Text("Hello").debugScan(Text.self)
134+
let buttonView = Button("Tap") {}.debugScan(Button<Text>.self)
135+
let imageView = Image(systemName: "star").debugScan(Image.self)
136+
let vstackView = VStack { Text("Test") }.debugScan(VStack<Text>.self)
137+
let hstackView = HStack { Text("Test") }.debugScan(HStack<Text>.self)
138+
139+
// All should be wrapped with ModifiedContent
140+
let allViews: [Any] = [textView, buttonView, imageView, vstackView, hstackView]
141+
for (index, view) in allViews.enumerated() {
142+
let typeName = String(describing: type(of: view))
143+
#expect(typeName.contains("ModifiedContent"), "View \(index) should be wrapped with ModifiedContent, got: \(typeName)")
144+
}
145+
146+
// Test that String(describing:) produces expected results for various types
147+
#expect(String(describing: Text.self) == "Text", "String(describing: Text.self) should be 'Text'")
148+
#expect(String(describing: Image.self) == "Image", "String(describing: Image.self) should be 'Image'")
149+
150+
// Test generic types
151+
let buttonType = String(describing: Button<Text>.self)
152+
let vstackType = String(describing: VStack<Text>.self)
153+
154+
#expect(buttonType.contains("Button"), "Button type should contain 'Button', got: \(buttonType)")
155+
#expect(vstackType.contains("VStack"), "VStack type should contain 'VStack', got: \(vstackType)")
156+
}
157+
158+
@Test("Type-based debugScan with custom view types")
159+
@MainActor func testTypeBased_debugScan_CustomTypes() {
160+
// Define custom view types to test explicit type specification with the new modifier
161+
struct MyCustomView: View {
162+
var body: some View {
163+
Text("Custom View Content")
164+
}
165+
}
166+
167+
struct AnotherTestView: View {
168+
var body: some View {
169+
VStack {
170+
Text("Another")
171+
Text("Test View")
172+
}
173+
}
174+
}
175+
176+
struct ViewWithLongName: View {
177+
var body: some View {
178+
EmptyView()
179+
}
180+
}
181+
182+
// Test custom views with explicit type specification
183+
let customView = MyCustomView().debugScan(MyCustomView.self)
184+
let anotherView = AnotherTestView().debugScan(AnotherTestView.self)
185+
let longNameView = ViewWithLongName().debugScan(ViewWithLongName.self)
186+
187+
// Verify they're properly wrapped
188+
#expect(String(describing: type(of: customView)).contains("ModifiedContent"))
189+
#expect(String(describing: type(of: anotherView)).contains("ModifiedContent"))
190+
#expect(String(describing: type(of: longNameView)).contains("ModifiedContent"))
191+
192+
// Test String(describing:) with custom types
193+
#expect(String(describing: MyCustomView.self) == "MyCustomView")
194+
#expect(String(describing: AnotherTestView.self) == "AnotherTestView")
195+
#expect(String(describing: ViewWithLongName.self) == "ViewWithLongName")
196+
197+
// Verify we can create ViewInstrumentationModifier with the same mechanism
198+
let customModifier = ViewInstrumentationModifier(
199+
label: String(describing: MyCustomView.self),
200+
file: #file,
201+
fileID: #fileID,
202+
filePath: #filePath
203+
)
204+
#expect(customModifier.label == "MyCustomView")
205+
}
206+
207+
@Test("Type-based debugScan explicit type passing")
208+
@MainActor func testTypeBased_debugScan_ExplicitTypes() {
209+
// Test that we can explicitly pass different types to the new modifier
210+
211+
// Create a text view but explicitly label it with different types
212+
let _ = Text("Test Content")
213+
214+
// We can't directly test the internal label since the modifier is private,
215+
// but we can test the mechanism by creating ViewInstrumentationModifier
216+
// with the same String(describing:) approach
217+
218+
let textTypeModifier = ViewInstrumentationModifier(
219+
label: String(describing: Text.self),
220+
file: #file,
221+
fileID: #fileID,
222+
filePath: #filePath
223+
)
224+
225+
let buttonTypeModifier = ViewInstrumentationModifier(
226+
label: String(describing: Button<Text>.self),
227+
file: #file,
228+
fileID: #fileID,
229+
filePath: #filePath
230+
)
231+
232+
let imageTypeModifier = ViewInstrumentationModifier(
233+
label: String(describing: Image.self),
234+
file: #file,
235+
fileID: #fileID,
236+
filePath: #filePath
237+
)
238+
239+
// Verify the labels are different and correctly formatted
240+
#expect(textTypeModifier.label == "Text")
241+
#expect(buttonTypeModifier.label.contains("Button"))
242+
#expect(imageTypeModifier.label == "Image")
243+
244+
// All should be different
245+
let labels = [textTypeModifier.label, buttonTypeModifier.label, imageTypeModifier.label]
246+
let uniqueLabels = Set(labels)
247+
#expect(uniqueLabels.count == labels.count, "All type labels should be unique")
248+
}
249+
250+
@Test("Type-based debugScan String(describing:) behavior")
251+
@MainActor func testStringDescribing_TypeBehavior() {
252+
// Test the core mechanism: String(describing: SomeType.self)
253+
// This is what the new debugScan modifier uses internally
254+
255+
// Test basic SwiftUI types
256+
let typeDescriptions: [(Any.Type, String)] = [
257+
(Text.self, "Text"),
258+
(Image.self, "Image"),
259+
(EmptyView.self, "EmptyView"),
260+
(Spacer.self, "Spacer")
261+
]
262+
263+
for (type, expectedDescription) in typeDescriptions {
264+
let actualDescription = String(describing: type)
265+
#expect(actualDescription == expectedDescription,
266+
"String(describing: \(type)) should be '\(expectedDescription)', got '\(actualDescription)'")
267+
}
268+
269+
// Test generic types (these may have more complex descriptions)
270+
let genericTypes: [Any.Type] = [
271+
Button<Text>.self,
272+
VStack<Text>.self,
273+
HStack<EmptyView>.self
274+
]
275+
276+
for type in genericTypes {
277+
let description = String(describing: type)
278+
#expect(!description.isEmpty, "String(describing:) should not be empty for \(type)")
279+
#expect(description.count > 3, "Type description should be substantial for \(type), got '\(description)'")
280+
}
281+
282+
// Test custom types
283+
struct TestCustomType: View {
284+
var body: some View { Text("Test") }
285+
}
286+
287+
let customDescription = String(describing: TestCustomType.self)
288+
#expect(customDescription == "TestCustomType",
289+
"Custom type description should be 'TestCustomType', got '\(customDescription)'")
290+
}
291+
292+
@Test("Type-based debugScan comprehensive integration")
293+
@MainActor func testTypeBased_debugScan_Integration() {
294+
// Comprehensive test that exercises the new type-based modifier in various scenarios
295+
296+
// Define a complex custom view hierarchy
297+
struct ContentView: View {
298+
var body: some View {
299+
VStack {
300+
HeaderView()
301+
BodyView()
302+
FooterView()
303+
}
304+
}
305+
}
306+
307+
struct HeaderView: View {
308+
var body: some View {
309+
Text("Header").font(.title)
310+
}
311+
}
312+
313+
struct BodyView: View {
314+
var body: some View {
315+
ScrollView {
316+
LazyVStack {
317+
ForEach(0..<5, id: \.self) { index in
318+
Text("Item \(index)")
319+
}
320+
}
321+
}
322+
}
323+
}
324+
325+
struct FooterView: View {
326+
var body: some View {
327+
HStack {
328+
Button("Cancel") {}
329+
Spacer()
330+
Button("Save") {}
331+
}
332+
}
333+
}
334+
335+
// Test the hierarchy with the new type-based debugScan
336+
let contentView = ContentView().debugScan(ContentView.self)
337+
let headerView = HeaderView().debugScan(HeaderView.self)
338+
let bodyView = BodyView().debugScan(BodyView.self)
339+
let footerView = FooterView().debugScan(FooterView.self)
340+
341+
// All should be properly wrapped
342+
let views: [Any] = [contentView, headerView, bodyView, footerView]
343+
for (index, view) in views.enumerated() {
344+
let typeName = String(describing: type(of: view))
345+
#expect(typeName.contains("ModifiedContent"), "View \(index) should be wrapped, got: \(typeName)")
346+
}
347+
348+
// Test that the String(describing:) mechanism produces consistent results
349+
let typeNames = [
350+
String(describing: ContentView.self),
351+
String(describing: HeaderView.self),
352+
String(describing: BodyView.self),
353+
String(describing: FooterView.self)
354+
]
355+
356+
let expectedNames = ["ContentView", "HeaderView", "BodyView", "FooterView"]
357+
358+
for (actual, expected) in zip(typeNames, expectedNames) {
359+
#expect(actual == expected, "Type name should be '\(expected)', got '\(actual)'")
360+
}
361+
362+
// Verify all type names are unique and non-empty
363+
#expect(Set(typeNames).count == typeNames.count, "All type names should be unique")
364+
for typeName in typeNames {
365+
#expect(!typeName.isEmpty, "Type name should not be empty")
366+
#expect(typeName.allSatisfy { $0.isLetter }, "Type name should only contain letters: '\(typeName)'")
367+
}
368+
}
369+
370+
@Test("Type-based vs String-based debugScan equivalence")
371+
@MainActor func testTypeBased_vs_StringBased_Equivalence() {
372+
// Test that the new type-based approach produces equivalent results to string interpolation
373+
374+
struct TestView: View {
375+
var body: some View { Text("Test") }
376+
}
377+
378+
// Test the equivalence between the two approaches
379+
let stringInterpolationResult = "\(TestView.self)"
380+
let stringDescribingResult = String(describing: TestView.self)
381+
382+
#expect(stringInterpolationResult == stringDescribingResult,
383+
"String interpolation and String(describing:) should produce the same result for custom types")
384+
385+
// Test with built-in types
386+
let builtinTypes: [Any.Type] = [Text.self, Image.self, EmptyView.self]
387+
388+
for type in builtinTypes {
389+
let interpolated = "\(type)"
390+
let described = String(describing: type)
391+
#expect(interpolated == described,
392+
"String interpolation and String(describing:) should match for \(type)")
393+
}
394+
395+
// Create modifiers using both approaches to verify they produce the same labels
396+
let stringBasedModifier = ViewInstrumentationModifier(
397+
label: "\(TestView.self)",
398+
file: #file,
399+
fileID: #fileID,
400+
filePath: #filePath
401+
)
402+
403+
let typeBasedModifier = ViewInstrumentationModifier(
404+
label: String(describing: TestView.self),
405+
file: #file,
406+
fileID: #fileID,
407+
filePath: #filePath
408+
)
409+
410+
#expect(stringBasedModifier.label == typeBasedModifier.label,
411+
"Both approaches should produce identical labels")
412+
}
413+
414+
@Test("Type-based debugScan requires explicit type specification")
415+
@MainActor func testTypeBased_debugScan_ExplicitTypeRequired() {
416+
// This test verifies that the type-based approach works with explicit type specification
417+
418+
struct TestView: View {
419+
var body: some View { Text("Test") }
420+
}
421+
422+
// Type-based approach requires explicit type specification
423+
let viewWithExplicitType = TestView().debugScan(TestView.self)
424+
#expect(String(describing: type(of: viewWithExplicitType)).contains("ModifiedContent"))
425+
426+
// Test that explicit type specification produces the expected label
427+
let testModifier = ViewInstrumentationModifier(
428+
label: String(describing: TestView.self),
429+
file: #file,
430+
fileID: #fileID,
431+
filePath: #filePath
432+
)
433+
#expect(testModifier.label == "TestView")
434+
435+
// Verify String(describing:) works correctly with various types
436+
let typeResults = [
437+
("Text", String(describing: Text.self)),
438+
("TestView", String(describing: TestView.self)),
439+
("EmptyView", String(describing: EmptyView.self))
440+
]
441+
442+
for (expected, actual) in typeResults {
443+
#expect(actual == expected, "String(describing:) should produce '\(expected)', got '\(actual)'")
444+
}
445+
446+
// Test explicit type specification works for different view types
447+
let textView = Text("Hello").debugScan(Text.self)
448+
let emptyView = EmptyView().debugScan(EmptyView.self)
449+
let testViewInstance = TestView().debugScan(TestView.self)
450+
451+
let allViews: [Any] = [textView, emptyView, testViewInstance]
452+
for (index, view) in allViews.enumerated() {
453+
let typeName = String(describing: type(of: view))
454+
#expect(typeName.contains("ModifiedContent"), "View \(index) should be wrapped with ModifiedContent")
455+
}
456+
457+
// Verify that the type-based approach works consistently with explicit types
458+
#expect(Bool(true), "Type-based debugScan works reliably with explicit type specification")
459+
}
127460
}

0 commit comments

Comments
 (0)