Skip to content

Commit a2055fa

Browse files
authored
Merge pull request #595 from allevato/better-tests
Refactor tests.
2 parents a7f16de + 3fd572a commit a2055fa

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)