Skip to content

Commit 3fd572a

Browse files
committed
Refactor tests.
This PR significantly improves the testing infrastructure by adopting the emoji markers used by SwiftSyntax's parser tests. This makes it easier to read and write tests, because the markers indicate in the string where the finding should be emitted and we don't have to compute line/column numbers manually. Additional testing improvements include: - Tests are no longer stateful w.r.t. collecting findings, so a single test can safely call its `assert*` helpers multiple times. - Tests verify the actual text of the findings, not just their symbolic values/constructors. Previously, we weren't actually verifying that the *messages* were what we wanted them to be, and this has revealed a couple minor issues. - I've dropped the `XCT*` prefix on our own custom assertions, because we shouldn't be using their namespace. I've added a handful of FIXME comments for things I caught during the migration that need to be addressed.
1 parent a7f16de commit 3fd572a

File tree

49 files changed

+4199
-3615
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+4199
-3615
lines changed

Sources/SwiftFormat/Core/Context.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public final class Context {
5353
public var importsXCTest: XCTestImportState
5454

5555
/// An object that converts `AbsolutePosition` values to `SourceLocation` values.
56-
let sourceLocationConverter: SourceLocationConverter
56+
public let sourceLocationConverter: SourceLocationConverter
5757

5858
/// Contains the rules have been disabled by comments for certain line numbers.
5959
let ruleMask: RuleMask

Sources/_SwiftFormatTestSupport/DiagnosingTestCase.swift

Lines changed: 154 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -7,100 +7,183 @@ import XCTest
77
/// DiagnosingTestCase is an XCTestCase subclass meant to inject diagnostic-specific testing
88
/// routines into specific formatting test cases.
99
open class DiagnosingTestCase: XCTestCase {
10-
/// Set during lint tests to indicate that we should check for unasserted diagnostics when the
11-
/// test is torn down and fail if there were any.
12-
public var shouldCheckForUnassertedDiagnostics = false
13-
14-
/// A helper that will keep track of the findings that were emitted.
15-
private var consumer = TestingFindingConsumer()
16-
17-
override open func setUp() {
18-
shouldCheckForUnassertedDiagnostics = false
19-
}
20-
21-
override open func tearDown() {
22-
guard shouldCheckForUnassertedDiagnostics else { return }
23-
24-
// This will emit a test failure if a diagnostic is thrown but we don't explicitly call
25-
// XCTAssertDiagnosed for it.
26-
for finding in consumer.emittedFindings {
27-
XCTFail("unexpected finding '\(finding)' emitted")
28-
}
29-
}
30-
3110
/// Creates and returns a new `Context` from the given syntax tree and configuration.
3211
///
33-
/// The returned context is configured with a diagnostic consumer that records diagnostics emitted
34-
/// during the tests, which can then be asserted using the `XCTAssertDiagnosed` and
35-
/// `XCTAssertNotDiagnosed` methods.
12+
/// The returned context is configured with the given finding consumer to record findings emitted
13+
/// during the tests, so that they can be asserted later using the `assertFindings` method.
3614
@_spi(Testing)
37-
public func makeContext(sourceFileSyntax: SourceFileSyntax, configuration: Configuration? = nil)
38-
-> Context
39-
{
40-
consumer = TestingFindingConsumer()
15+
public func makeContext(
16+
sourceFileSyntax: SourceFileSyntax,
17+
configuration: Configuration? = nil,
18+
findingConsumer: @escaping (Finding) -> Void
19+
) -> Context {
4120
let context = Context(
4221
configuration: configuration ?? Configuration(),
4322
operatorTable: .standardOperators,
44-
findingConsumer: consumer.consume,
23+
findingConsumer: findingConsumer,
4524
fileURL: URL(fileURLWithPath: "/tmp/test.swift"),
4625
sourceFileSyntax: sourceFileSyntax,
4726
ruleNameCache: ruleNameCache)
4827
return context
4928
}
5029

51-
/// Stops tracking diagnostics emitted during formatting/linting.
52-
///
53-
/// This used by the pretty-printer tests to suppress any diagnostics that might be emitted during
54-
/// the second format pass (which checks for idempotence).
55-
public func stopTrackingDiagnostics() {
56-
consumer.stopTrackingFindings()
30+
/// Asserts that the given list of findings matches a set of specs.
31+
@_spi(Testing)
32+
public final func assertFindings(
33+
expected specs: [FindingSpec],
34+
markerLocations: [String: Int],
35+
emittedFindings: [Finding],
36+
context: Context,
37+
file: StaticString = #file,
38+
line: UInt = #line
39+
) {
40+
var emittedFindings = emittedFindings
41+
42+
// Check for a finding that matches each spec, removing it from the array if found.
43+
for spec in specs {
44+
assertAndRemoveFinding(
45+
findingSpec: spec,
46+
markerLocations: markerLocations,
47+
emittedFindings: &emittedFindings,
48+
context: context,
49+
file: file,
50+
line: line)
51+
}
52+
53+
// Emit test failures for any findings that did not have matches.
54+
for finding in emittedFindings {
55+
let locationString: String
56+
if let location = finding.location {
57+
locationString = "line:col \(location.line):\(location.column)"
58+
} else {
59+
locationString = "no location provided"
60+
}
61+
XCTFail(
62+
"Unexpected finding '\(finding.message)' was emitted (\(locationString))",
63+
file: file,
64+
line: line)
65+
}
5766
}
5867

59-
/// Asserts that a specific diagnostic message was emitted.
60-
///
61-
/// - Parameters:
62-
/// - message: The diagnostic message expected to be emitted.
63-
/// - file: The file in which failure occurred. Defaults to the file name of the test case in
64-
/// which this function was called.
65-
/// - line: The line number on which failure occurred. Defaults to the line number on which this
66-
/// function was called.
67-
public final func XCTAssertDiagnosed(
68-
_ message: Finding.Message,
69-
line diagnosticLine: Int? = nil,
70-
column diagnosticColumn: Int? = nil,
68+
private func assertAndRemoveFinding(
69+
findingSpec: FindingSpec,
70+
markerLocations: [String: Int],
71+
emittedFindings: inout [Finding],
72+
context: Context,
7173
file: StaticString = #file,
7274
line: UInt = #line
7375
) {
74-
let wasEmitted: Bool
75-
if let diagnosticLine = diagnosticLine, let diagnosticColumn = diagnosticColumn {
76-
wasEmitted = consumer.popFinding(
77-
containing: message.text, atLine: diagnosticLine, column: diagnosticColumn)
78-
} else {
79-
wasEmitted = consumer.popFinding(containing: message.text)
76+
guard let utf8Offset = markerLocations[findingSpec.marker] else {
77+
XCTFail("Marker '\(findingSpec.marker)' was not found in the input", file: file, line: line)
78+
return
79+
}
80+
81+
let markerLocation =
82+
context.sourceLocationConverter.location(for: AbsolutePosition(utf8Offset: utf8Offset))
83+
84+
// Find a finding that has the expected line/column location, ignoring the text.
85+
// FIXME: We do this to provide a better error message if the finding is in the right place but
86+
// doesn't have the right message, but this also introduces an order-sensitivity among the
87+
// specs. Fix this if it becomes an issue.
88+
let maybeIndex = emittedFindings.firstIndex {
89+
markerLocation.line == $0.location?.line && markerLocation.column == $0.location?.column
90+
}
91+
guard let index = maybeIndex else {
92+
XCTFail(
93+
"""
94+
Finding '\(findingSpec.message)' was not emitted at marker '\(findingSpec.marker)' \
95+
(line:col \(markerLocation.line):\(markerLocation.column), offset \(utf8Offset))
96+
""",
97+
file: file,
98+
line: line)
99+
return
80100
}
81-
if !wasEmitted {
82-
XCTFail("diagnostic '\(message.text)' not emitted", file: file, line: line)
101+
102+
// Verify that the finding text also matches what we expect.
103+
let matchedFinding = emittedFindings.remove(at: index)
104+
XCTAssertEqual(
105+
matchedFinding.message.text,
106+
findingSpec.message,
107+
"""
108+
Finding emitted at marker '\(findingSpec.marker)' \
109+
(line:col \(markerLocation.line):\(markerLocation.column), offset \(utf8Offset)) \
110+
had the wrong message
111+
""",
112+
file: file,
113+
line: line)
114+
115+
// Assert that a note exists for each of the expected nodes in the finding.
116+
var emittedNotes = matchedFinding.notes
117+
for noteSpec in findingSpec.notes {
118+
assertAndRemoveNote(
119+
noteSpec: noteSpec,
120+
markerLocations: markerLocations,
121+
emittedNotes: &emittedNotes,
122+
context: context,
123+
file: file,
124+
line: line)
125+
}
126+
127+
// Emit test failures for any notes that weren't specified.
128+
for note in emittedNotes {
129+
let locationString: String
130+
if let location = note.location {
131+
locationString = "line:col \(location.line):\(location.column)"
132+
} else {
133+
locationString = "no location provided"
134+
}
135+
XCTFail(
136+
"Unexpected note '\(note.message)' was emitted (\(locationString))",
137+
file: file,
138+
line: line)
83139
}
84140
}
85141

86-
/// Asserts that a specific diagnostic message was not emitted.
87-
///
88-
/// - Parameters:
89-
/// - message: The diagnostic message expected to not be emitted.
90-
/// - file: The file in which failure occurred. Defaults to the file name of the test case in
91-
/// which this function was called.
92-
/// - line: The line number on which failure occurred. Defaults to the line number on which this
93-
/// function was called.
94-
public final func XCTAssertNotDiagnosed(
95-
_ message: Finding.Message,
142+
private func assertAndRemoveNote(
143+
noteSpec: NoteSpec,
144+
markerLocations: [String: Int],
145+
emittedNotes: inout [Finding.Note],
146+
context: Context,
96147
file: StaticString = #file,
97148
line: UInt = #line
98149
) {
99-
let wasEmitted = consumer.popFinding(containing: message.text)
100-
XCTAssertFalse(
101-
wasEmitted,
102-
"diagnostic '\(message.text)' should not have been emitted",
103-
file: file, line: line)
150+
guard let utf8Offset = markerLocations[noteSpec.marker] else {
151+
XCTFail("Marker '\(noteSpec.marker)' was not found in the input", file: file, line: line)
152+
return
153+
}
154+
155+
let markerLocation =
156+
context.sourceLocationConverter.location(for: AbsolutePosition(utf8Offset: utf8Offset))
157+
158+
// FIXME: We do this to provide a better error message if the note is in the right place but
159+
// doesn't have the right message, but this also introduces an order-sensitivity among the
160+
// specs. Fix this if it becomes an issue.
161+
let maybeIndex = emittedNotes.firstIndex {
162+
markerLocation.line == $0.location?.line && markerLocation.column == $0.location?.column
163+
}
164+
guard let index = maybeIndex else {
165+
XCTFail(
166+
"""
167+
Note '\(noteSpec.message)' was not emitted at marker '\(noteSpec.marker)' \
168+
(line:col \(markerLocation.line):\(markerLocation.column), offset \(utf8Offset))
169+
""",
170+
file: file,
171+
line: line)
172+
return
173+
}
174+
175+
// Verify that the note text also matches what we expect.
176+
let matchedNote = emittedNotes.remove(at: index)
177+
XCTAssertEqual(
178+
matchedNote.message.text,
179+
noteSpec.message,
180+
"""
181+
Note emitted at marker '\(noteSpec.marker)' \
182+
(line:col \(markerLocation.line):\(markerLocation.column), offset \(utf8Offset)) \
183+
had the wrong message
184+
""",
185+
file: file,
186+
line: line)
104187
}
105188

106189
/// Asserts that the two strings are equal, providing Unix `diff`-style output if they are not.
@@ -113,7 +196,7 @@ open class DiagnosingTestCase: XCTestCase {
113196
/// which this function was called.
114197
/// - line: The line number on which failure occurred. Defaults to the line number on which this
115198
/// function was called.
116-
public final func XCTAssertStringsEqualWithDiff(
199+
public final func assertStringsEqualWithDiff(
117200
_ actual: String,
118201
_ expected: String,
119202
_ message: String = "",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
/// A description of a `Finding` that can be asserted during tests.
14+
public struct FindingSpec {
15+
/// The marker that identifies the finding.
16+
public var marker: String
17+
18+
/// The message text associated with the finding.
19+
public var message: String
20+
21+
/// A description of a `Note` that should be associated with this finding.
22+
public var notes: [NoteSpec]
23+
24+
/// Creates a new `FindingSpec` with the given values.
25+
public init(_ marker: String = "1️⃣", message: String, notes: [NoteSpec] = []) {
26+
self.marker = marker
27+
self.message = message
28+
self.notes = notes
29+
}
30+
}
31+
32+
/// A description of a `Note` that can be asserted during tests.
33+
public struct NoteSpec {
34+
/// The marker that identifies the note.
35+
public var marker: String
36+
37+
/// The message text associated with the note.
38+
public var message: String
39+
40+
/// Creates a new `NoteSpec` with the given values.
41+
public init(_ marker: String, message: String) {
42+
self.marker = marker
43+
self.message = message
44+
}
45+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
/// Encapsulates the locations of emoji markers extracted from source text.
14+
public struct MarkedText {
15+
/// A mapping from marker names to the UTF-8 offset where the marker was found in the string.
16+
public let markers: [String: Int]
17+
18+
/// The text with all markers removed.
19+
public let textWithoutMarkers: String
20+
21+
/// Creates a new `MarkedText` value by extracting emoji markers from the given text.
22+
public init(textWithMarkers markedText: String) {
23+
var text = ""
24+
var markers = [String: Int]()
25+
var lastIndex = markedText.startIndex
26+
for marker in findMarkedRanges(in: markedText) {
27+
text += markedText[lastIndex..<marker.range.lowerBound]
28+
lastIndex = marker.range.upperBound
29+
30+
assert(markers[marker.name] == nil, "Marker names must be unique")
31+
markers[marker.name] = text.utf8.count
32+
}
33+
34+
text += markedText[lastIndex..<markedText.endIndex]
35+
36+
self.markers = markers
37+
self.textWithoutMarkers = text
38+
}
39+
}
40+
41+
private struct Marker {
42+
/// The name (i.e., emoji identifier) of the marker.
43+
var name: String
44+
45+
/// The range of the marker.
46+
///
47+
/// If the marker contains all the non-whitespace characters on the line, then this is the range
48+
/// of the entire line. Otherwise, it's the range of the marker itself.
49+
var range: Range<String.Index>
50+
}
51+
52+
private func findMarkedRanges(in text: String) -> [Marker] {
53+
var markers = [Marker]()
54+
while let marker = nextMarkedRange(in: text, from: markers.last?.range.upperBound ?? text.startIndex) {
55+
markers.append(marker)
56+
}
57+
return markers
58+
}
59+
60+
private func nextMarkedRange(in text: String, from index: String.Index) -> Marker? {
61+
guard let start = text[index...].firstIndex(where: { $0.isMarkerEmoji }) else {
62+
return nil
63+
}
64+
65+
let end = text.index(after: start)
66+
let markerRange = start..<end
67+
let name = String(text[start..<end])
68+
return Marker(name: name, range: markerRange)
69+
}
70+
71+
extension Character {
72+
/// A value indicating whether or not the character is an emoji that we recognize as a source
73+
/// location marker.
74+
fileprivate var isMarkerEmoji: Bool {
75+
switch self {
76+
case "0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟", "ℹ️": return true
77+
default: return false
78+
}
79+
}
80+
}

0 commit comments

Comments
 (0)